Files
server/laf-cloud/functions/paymentProfile.ts
T
2026-10-12 15:40:18 +08:00

270 lines
16 KiB
TypeScript

// Pure daily user features. Activity eligibility, offer selection and prices do not belong here.
export const PAYMENT_PROFILE_RULE = {
version: 'payment_profile_v2_20260930',
moneyThresholdsFen: [3000, 10000, 30000, 100000],
frequencyDays: [2, 5, 10, 16],
recencyDays: [7, 15, 30, 60],
priorFen: 600,
confidenceDays: 4,
minimumObservationDays: 7,
// Only the historical SKU/version window identified in the reference export.
legacyBundle: { sku: 'unlimited_health_bundle_30', before: Date.parse('2026-09-22T00:00:00+08:00') },
starterPackRewardVersion: 'starter_pack_v3_20260930',
// Populate only after reward versions have been checked for comparability.
comparableActivityVersions: { coin: [] as string[], lucky: [] as string[] },
};
const DAY = 86400000;
const OFFSET = 8 * 3600000;
export type ProfileOrder = { amountFen: number; paidAt: number; sku: string; rewardVersion?: string; activity?: string };
export type ProfileInput = {
cutoff: number; registerTime: number | null; payUser: any; missingOpenid: boolean;
confirmedOrders: number; futureOrders: number; invalidOrders: number; missingTimeOrders: number;
fallbackTimeOrders: number; duplicateConflicts: number; previousPaid: boolean;
amount15d: number; amount30d: number; lastPaidAt: number | null; recentOrders: ProfileOrder[];
};
function median(values: number[]) {
if (!values.length) return PAYMENT_PROFILE_RULE.priorFen;
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
}
function evidenceWeight(order: ProfileOrder, context: 'general' | 'coin' | 'lucky') {
const sku = order.sku.toLowerCase();
const rule = PAYMENT_PROFILE_RULE;
if (order.sku === rule.legacyBundle.sku && order.paidAt < rule.legacyBundle.before) return 0.3;
// First-purchase/subscription evidence must not be promoted by activity matching.
if (sku === 'starter_pack' || sku === 'month_card' || /^battlepass(?:_|$)/.test(sku)) return 0.25;
if (/^starter_pack_[2-5]$/.test(sku)) {
return context === 'lucky' && order.activity === 'lucky'
&& order.rewardVersion === rule.starterPackRewardVersion ? 1 : 0.25;
}
if (context !== 'general' && order.activity === context && order.rewardVersion
&& rule.comparableActivityVersions[context].includes(order.rewardVersion)) return 1;
if (sku === 'reborn_gift' || sku.startsWith('jungle_treasure_') || sku.startsWith('gold_miner')) return 0.5;
return 1;
}
function ticketFactors(orders: ProfileOrder[], payDays: number, context: 'general' | 'coin' | 'lucky') {
const prior = PAYMENT_PROFILE_RULE.priorFen;
const daily = new Map<number, number>();
const adjusted: number[] = [];
for (const order of orders) {
const amount = prior + evidenceWeight(order, context) * (order.amountFen - prior);
adjusted.push(amount);
const day = Math.floor((order.paidAt + OFFSET) / DAY);
daily.set(day, Math.max(daily.get(day) ?? 0, amount));
}
const p50 = median(adjusted);
const maxima = [...daily.values()].sort((a, b) => b - a);
const repeated = maxima.length >= 2 ? maxima[1] : p50;
const confidence = Math.min(1, payDays / PAYMENT_PROFILE_RULE.confidenceDays);
return {
confidence,
adjusted_median_fen: p50,
repeat_ticket_fen: repeated,
anchor_fen: prior + confidence * (Math.max(prior, p50, repeated) - prior),
};
}
// Shared by calculation and publication from a committed/retained RFM tuple.
export function paymentVipLevel(hasPaid: boolean, score: number) {
return hasPaid ? Math.max(1, Math.min(5, Math.floor(score + 0.5))) : 0;
}
export function buildPaymentProfile(input: ProfileInput) {
const errors: string[] = [];
const warnings = ['channel_actual_payment_unverified', 'refunds_not_synced'];
const confirmedPaid = input.confirmedOrders > 0 || input.futureOrders > 0 || input.previousPaid;
const conflict = confirmedPaid && input.payUser === false;
const paidStatus = conflict ? 'unknown' : confirmedPaid ? 'paid' : input.payUser === false ? 'never' : 'unknown';
if (input.missingOpenid) errors.push('missing_openid');
if (paidStatus === 'unknown') errors.push('unknown_paid_status');
if ((confirmedPaid || input.payUser === true) && input.confirmedOrders === 0) errors.push('paid_history_missing_or_after_cutoff');
if (conflict) errors.push('paid_status_conflict');
if (input.invalidOrders) errors.push('invalid_orders');
if (input.missingTimeOrders) errors.push('missing_payment_time');
if (input.duplicateConflicts) errors.push('conflicting_duplicate_orders');
if (input.fallbackTimeOrders) warnings.push('creation_time_used_as_payment_time');
if (input.registerTime === null) warnings.push('registration_time_missing');
else if (input.registerTime > input.cutoff) errors.push('registered_after_cutoff');
if (input.recentOrders.some(order => !order.sku)) warnings.push('missing_sku_uses_normal_weight');
if (input.recentOrders.some(order => order.sku === PAYMENT_PROFILE_RULE.legacyBundle.sku
&& order.paidAt >= PAYMENT_PROFILE_RULE.legacyBundle.before)) warnings.push('legacy_bundle_version_unverified');
const metadata = {
profile_schema_version: 1,
attempted_profile_rule_version: PAYMENT_PROFILE_RULE.version,
paid_status: paidStatus,
has_ever_paid: confirmedPaid ? true : paidStatus === 'never' ? false : null,
data_valid: errors.length === 0,
invalid_reasons: errors,
quality_warnings: warnings,
amount_basis: 'confirmed_order_amount',
net_payment_verified: false,
};
if (errors.length) return {
...metadata, vip_level: null, profile_rule_version: null, profile_calculated_as_of: null,
rfm: null, spending: null, ticket_factors: null,
};
const payDays = new Set(input.recentOrders.map(order => Math.floor((order.paidAt + OFFSET) / DAY))).size;
const recency = input.lastPaidAt === null ? null : Math.floor((input.cutoff - input.lastPaidAt) / DAY);
const m = 1 + PAYMENT_PROFILE_RULE.moneyThresholdsFen.filter(value => input.amount30d >= value).length;
const f = 1 + PAYMENT_PROFILE_RULE.frequencyDays.filter(value => payDays >= value).length;
const r = recency === null ? 1 : 5 - PAYMENT_PROFILE_RULE.recencyDays.filter(value => recency >= value).length;
const scoreTenths = 7 * m + 2 * f + r;
const age = input.registerTime === null ? 30 : Math.max(1, (input.cutoff - input.registerTime) / DAY);
const l30 = Math.max(PAYMENT_PROFILE_RULE.minimumObservationDays, Math.min(30, age));
const l15 = Math.max(PAYMENT_PROFILE_RULE.minimumObservationDays, Math.min(15, age));
const d30 = input.amount30d / l30;
const d15 = input.amount15d / l15;
const trend = d30 > 0 ? Math.max(0.75, Math.min(1.25, 1 + 0.3 * (d15 / d30 - 1))) : 1;
return {
...metadata,
vip_level: paymentVipLevel(paidStatus !== 'never', scoreTenths / 10),
profile_rule_version: PAYMENT_PROFILE_RULE.version,
profile_calculated_as_of: input.cutoff,
rfm: {
m30_fen: input.amount30d, pay_days_30d: payDays, orders30: input.recentOrders.length,
last_effective_paid_at: input.lastPaidAt, recency_days: recency,
m_score: m, f_score: f, r_score: r, score: scoreTenths / 10,
},
spending: {
m15_fen: input.amount15d, m30_fen: input.amount30d, observed_days30: l30, observed_days15: l15,
daily30_fen: d30, daily15_fen: d15, trend_factor: trend,
daily_reference_fen: d30 * trend, weekly_reference_fen: 7 * d30 * trend,
two_day_reference_fen: 2 * d30 * trend,
},
ticket_factors: {
general: ticketFactors(input.recentOrders, payDays, 'general'),
coin: ticketFactors(input.recentOrders, payDays, 'coin'),
lucky: ticketFactors(input.recentOrders, payDays, 'lucky'),
},
};
}
// Verified payment facts are a separate input contract; legacy gross-order RFM is not net evidence.
export const VERIFIED_PAYMENT_SCHEMA = 'verified_payment_features_1';
export const VERIFIED_EVIDENCE_VERSION = 'weighted_order_days_1';
export function verifiedProfileError(): never {
throw Object.assign(new Error('可信支付画像暂不可用'), { errorCode: 'PROFILE_UNAVAILABLE' });
}
/** Exact decimal/rational arithmetic. No rounding is performed before a tier comparison. */
export class Exact {
n: bigint; d: bigint;
constructor(value: number | string | bigint | Exact, denominator: bigint = 1n) {
if (value instanceof Exact) { this.n = value.n; this.d = value.d; return; }
const parts = String(value).split('.');
this.d = denominator * 10n ** BigInt(parts[1]?.length || 0);
this.n = BigInt(parts.join(''));
if (!this.d) throw new Error('Zero denominator');
if (this.d < 0n) { this.n = -this.n; this.d = -this.d; }
let a = this.n < 0n ? -this.n : this.n, b = this.d;
while (b) { const r = a % b; a = b; b = r; }
this.n /= a; this.d /= a;
}
add(v: any) { const b = new Exact(v); return new Exact(this.n * b.d + b.n * this.d, this.d * b.d); }
sub(v: any) { const b = new Exact(v); return new Exact(this.n * b.d - b.n * this.d, this.d * b.d); }
mul(v: any) { const b = new Exact(v); return new Exact(this.n * b.n, this.d * b.d); }
div(v: any) { const b = new Exact(v); return new Exact(this.n * b.d, this.d * b.n); }
cmp(v: any) { const b = new Exact(v), n = this.n * b.d - b.n * this.d; return n < 0n ? -1 : n > 0n ? 1 : 0; }
max(v: any) { return this.cmp(v) >= 0 ? this : new Exact(v); }
min(v: any) { return this.cmp(v) <= 0 ? this : new Exact(v); }
number() { return Number(this.n) / Number(this.d); }
toJSON() { return { numerator: String(this.n), denominator: String(this.d) }; }
}
/** Called with a server-owned, refund-reconciled snapshot, never with request JSON.
* All orders in [as_of-30d, as_of) must be retained, including refunded orders.
* Source adapters must attest lifetime identity/last-positive-net history and coverage.
*/
export function buildVerifiedPaymentFeatures(facts: any, asOf: number, weights: any, classificationVersion: string) {
if (!facts || facts.feature_schema_version !== VERIFIED_PAYMENT_SCHEMA
|| facts.evidence_version !== VERIFIED_EVIDENCE_VERSION || facts.sku_classification_version !== classificationVersion
|| facts.net_payment_verified !== true || facts.coverage_complete !== true || facts.full_history_verified !== true
|| facts.dirty || !facts.snapshot_id || facts.revision == null || facts.identity_revision == null || facts.watermark == null
|| typeof facts.has_ever_paid !== 'boolean' || !Number.isSafeInteger(facts.as_of) || facts.as_of > asOf
|| asOf - facts.as_of > 30 * 3600000 || !Number.isSafeInteger(facts.coverage_start)
|| facts.coverage_start > facts.as_of - 30 * DAY || !Number.isSafeInteger(facts.registration_at)
|| facts.registration_at > facts.as_of || !Array.isArray(facts.orders)
|| (facts.quality_flags || []).length) verifiedProfileError();
// A fresh snapshot is evaluated wholly at its cutoff. A dirty/stale source must be rebuilt upstream.
const cutoff = facts.as_of, seen = new Map<string, string>(), recent: any[] = [];
for (const o of facts.orders) {
if (o.test === true || o.duplicate === true) continue;
if (!o.order_id || !Number.isSafeInteger(o.paid_at) || o.paid_at > asOf
|| !Number.isSafeInteger(o.paid_fen) || o.paid_fen <= 0 || !Number.isSafeInteger(o.refunded_fen)
|| o.refunded_fen < 0 || o.refunded_fen > o.paid_fen || o.refund_verified !== true
|| !o.sku || !o.sku_version) verifiedProfileError();
const fingerprint = JSON.stringify([o.paid_at, o.paid_fen, o.refunded_fen, o.sku, o.sku_version, o.evidence_class]);
if (seen.has(o.order_id)) { if (seen.get(o.order_id) !== fingerprint) verifiedProfileError(); continue; }
seen.set(o.order_id, fingerprint);
if (o.paid_at < cutoff - 30 * DAY || o.paid_at >= cutoff) continue;
const net = o.paid_fen - o.refunded_fen;
if (!net) continue;
if (!Object.prototype.hasOwnProperty.call(weights, o.evidence_class)) verifiedProfileError();
recent.push({ ...o, net_fen: net, weight: o.refunded_fen ? 0 : weights[o.evidence_class],
day: Math.floor((o.paid_at + OFFSET) / DAY) });
}
const last = facts.last_positive_net_paid_at;
if (last !== null && (!Number.isSafeInteger(last) || last >= cutoff || last > asOf)) verifiedProfileError();
if ((!facts.has_ever_paid && (last !== null || seen.size))
|| (recent.length && last !== Math.max(...recent.map(o => o.paid_at)))
|| (!recent.length && last !== null && last >= cutoff - 30 * DAY)) verifiedProfileError();
const in15 = recent.filter(o => o.paid_at >= cutoff - 15 * DAY);
const sum = (rows: any[]) => rows.reduce((n, o) => n + o.net_fen, 0);
if (!Number.isSafeInteger(sum(recent))) verifiedProfileError();
return { snapshot_id: facts.snapshot_id, revision: facts.revision, as_of: cutoff, watermark: facts.watermark,
identity_revision: facts.identity_revision, feature_schema_version: facts.feature_schema_version,
evidence_version: facts.evidence_version, sku_classification_version: classificationVersion,
has_ever_paid: facts.has_ever_paid, registration_at: facts.registration_at,
observation_age_days: new Exact(cutoff - facts.registration_at).div(DAY),
last_positive_net_paid_at: last, R_days: last === null ? null : new Exact(asOf - last).div(DAY),
M30_fen: sum(recent), M15_fen: sum(in15), F30: new Set(recent.map(o => o.day)).size,
F15: new Set(in15.map(o => o.day)).size, N30: recent.length, N15: in15.length, orders: recent,
vip_level: facts.vip_level ?? null, vip_profile_version: facts.vip_profile_version ?? null,
diagnostics: facts.diagnostics ?? null };
}
/** Optional common ledger adapter. Only a reconciled source can opt in; existing
* goodsPrice/chargeTime records are deliberately not promoted to verified facts. */
export function rebuildVerifiedPaymentFacts(source: any, ledger: any[], user: any, now: number) {
if (!source || source.net_payment_verified !== true || source.coverage_complete !== true
|| source.full_history_verified !== true || source.revision == null || source.identity_revision == null
|| typeof source.has_ever_paid !== 'boolean' || !Number.isSafeInteger(source.coverage_start)
|| source.coverage_start > now - 30 * DAY || !Number.isSafeInteger(source.registration_at)
|| source.registration_at > now || !source.sku_classification_version) verifiedProfileError();
const orders: any[] = [], fingerprints: string[] = [], seen = new Map<string, string>();
let hasPaid = source.has_ever_paid, last: number | null = null;
for (const row of ledger) {
if (row.paymentAppEnv === 'test' || String(row.outTradeNo).startsWith('wct_')) continue;
if (![1,2].includes(row.state)) continue;
const v = row.verifiedPayment;
if (!v || v.refund_verified !== true || v.revision == null || v.sku_classification_version !== source.sku_classification_version
|| !Number.isSafeInteger(v.paid_at) || v.paid_at > Date.now() || !Number.isSafeInteger(v.paid_fen)
|| !Number.isSafeInteger(v.refunded_fen) || v.paid_fen <= 0 || v.refunded_fen < 0 || v.refunded_fen > v.paid_fen) verifiedProfileError();
if (v.paid_at >= now) continue;
if (typeof row.outTradeNo !== 'string' || !row.outTradeNo) verifiedProfileError();
const fingerprint = JSON.stringify(v);
if (seen.has(row.outTradeNo)) { if (seen.get(row.outTradeNo) !== fingerprint) verifiedProfileError(); continue; }
seen.set(row.outTradeNo, fingerprint);
hasPaid = true;
if (v.paid_fen > v.refunded_fen) last = Math.max(last ?? 0, v.paid_at);
fingerprints.push(`${row.outTradeNo}:${v.revision}`);
if (v.paid_at >= now - 30 * DAY) orders.push({ ...v, order_id: row.outTradeNo });
}
// A source with paid identity must retain its lifetime ledger, including fully refunded orders.
if (hasPaid && !fingerprints.length) verifiedProfileError();
return { feature_schema_version: VERIFIED_PAYMENT_SCHEMA, evidence_version: VERIFIED_EVIDENCE_VERSION,
sku_classification_version: source.sku_classification_version, snapshot_id: `${user._id}:${now}`,
revision: source.revision, identity_revision: source.identity_revision, watermark: fingerprints.sort().join('|'),
net_payment_verified: true, coverage_complete: true, full_history_verified: true, dirty: false, quality_flags: [],
coverage_start: source.coverage_start, as_of: now, registration_at: source.registration_at,
has_ever_paid: hasPaid, last_positive_net_paid_at: last, orders,
vip_level: user.vip_level ?? null, vip_profile_version: user.vip_profile?.profile_rule_version ?? null };
}