270 lines
16 KiB
TypeScript
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 };
|
|
}
|