卡密充值与兑换接入统一订单号与支付流水号

此前卡密核销虽已建订单,但订单号硬编码 CARD{卡ID}、渠道交易号硬编码
card_pass_{卡ID},均不受后台前缀控制;且核销产生的资金流水没有挂到
订单上(order_number 为 NULL),订单详情页的「支付流水号」恒为「—」。

ChargeCard:
- 新增 allocate_redeem_order_number():走 OrderRepository 统一分配器,
  消费后台「订单前缀」(payment_order_prefix)。
- 新增 card_pass_transaction_number():BusinessNumber::candidate(flow_prefix),
  消费后台「流水号前缀」(payment_flow_prefix)。
- 两条兑换路径(redeem_asset_card / redeem_vip_card)替换硬编码取号;
  create_redeem_order 接收调用方传入的交易号。
- 账本重放(replayed)时回取已落库订单号,不再把本次新分配的号回给前端。

FinancialFlowService:
- record_card_pass_payment():会员卡(0 元、无账本参与)直接落一条
  与订单关联、零分录的 order_payment 流水;record() 新增
  allow_empty_entries 通道(prepare_entries 拒绝 delta=0,占位分录不可行)。
- record_order_event() 新增凭证结算模式:豁免「金额必须 > 0」与
  「资产类订单不得记现金流水」两条门槛(卡密核销既无第三方现金流入,
  订单还可能为 0 元或以积分计价)。
- attach_order_to_ledger_flow():账本提交后把流水补挂到订单上,
  event_type 归一为 order_payment,使「支付流水号」可反查。
- is_successful_order_payment() 认可 card_pass 凭证结算;
  is_external_recharge() 把 card_pass 与 balance/points 一并列为平台内部
  结算方式,不再为卡密充值伪造现金出账分录(现金在售卡时已确认)。

关键取舍:随机订单号不得进入 LedgerCommand 的 metadata —— payload_hash
覆盖 metadata,会破坏账本幂等重放(实测撞 uniq_operation_key 后报
aksrt_ledger_idempotency_conflict),因此订单关联改在账本提交后补挂。

测试:新增 tests/admin/card-pass-order-numbers.test.cjs 钉住上述约束;
容器内三类卡密实测通过(订单号/渠道交易号/支付流水号三项均带后台前缀,
流水行分别携带 balance:in / points:in / 零分录),重放幂等恢复正常。
This commit is contained in:
2026-09-17 14:15:55 +08:00
parent c1c21faf7e
commit ba8d4b1b90
4 changed files with 278 additions and 33 deletions
+112 -26
View File
@@ -99,6 +99,69 @@ final class FinancialFlowService {
return $this->record_order_event($order, 'order_refund', 1, $caller_transaction);
}
/**
* 记录卡密核销产生的订单支付流水。
*
* 卡密是平台自发的预付凭证:核销那一刻既没有第三方现金流入,也不产生账本资产分录,
* 订单金额还可能为 0(会员卡)或以积分计价(积分卡)。因此这里只落一条与订单关联的
* order_payment 流水,不生成任何现金/资产分录,让订单在详情页具备可追溯的「支付流水号」。
*
* 余额卡与积分卡的核销走账本(LedgerService::credit),其流水由 record_ledger() 落库;
* 本方法供会员卡这类无账本参与的卡种调用。
*
* @param array<string,mixed> $order
* @return string|\WP_Error
*/
public function record_card_pass_payment(array $order, bool $caller_transaction = false) {
return $this->record_order_event($order, 'order_payment', -1, $caller_transaction, true);
}
/**
* 把账本流水挂到卡密核销订单上(订单号 + 渠道交易号 + 事件类型归一)。
*
* 卡密核销的资产入账走账本,但订单号是随机生成的、不能进 LedgerCommand
* (payload_hash 覆盖 metadata,塞入随机号会破坏账本幂等重放)。因此账本
* 提交之后再调用本方法补挂关联;同时把 event_type 归一为 order_payment,
* 让订单详情页的「支付流水号」能反查到它。
*
* @return bool 是否发生了更新
*/
public function attach_order_to_ledger_flow(int $ledger_transaction_id, string $order_number, string $provider_transaction_id = ''): bool {
global $wpdb;
if ($ledger_transaction_id <= 0 || $order_number === '') {
return false;
}
$order_number = substr(sanitize_text_field($order_number), 0, 32);
$order = $wpdb->get_row($wpdb->prepare(
"SELECT status, payment_method, payment_transaction_id FROM {$this->order_table()} WHERE order_number = %s LIMIT 1",
$order_number
), ARRAY_A);
if (!is_array($order)
|| sanitize_key((string) ($order['status'] ?? '')) !== \AKSRT\Payment\Order::STATUS_PAID
|| sanitize_key((string) ($order['payment_method'] ?? '')) !== 'card_pass'
) {
return false;
}
if ($provider_transaction_id === '') {
$provider_transaction_id = (string) ($order['payment_transaction_id'] ?? '');
}
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->flow_table()}
SET order_number = %s,
provider_transaction_id = CASE WHEN provider_transaction_id IS NULL OR provider_transaction_id = '' THEN %s ELSE provider_transaction_id END,
event_type = 'order_payment'
WHERE ledger_transaction_id = %d AND (order_number IS NULL OR order_number = '')",
$order_number,
substr(sanitize_text_field($provider_transaction_id), 0, 128),
$ledger_transaction_id
));
return $updated === 1;
}
private function order_table(): string {
return \AKSRT\Core\DBMigrator::table('order');
}
/** @return string|\WP_Error */
public function record_withdrawal_payout(array $withdrawal, string $operation_key, string $created_at, bool $caller_transaction = false) {
$user_id = max(0, (int) ($withdrawal['user_id'] ?? 0));
@@ -130,13 +193,14 @@ final class FinancialFlowService {
/**
* @param array<string,mixed> $flow
* @param array<int,array<string,mixed>> $entries
* @param bool $allow_empty_entries 卡密核销等「无现金/资产分录」的凭证型流水,允许分录为空。
* @return string|\WP_Error
*/
private function record(array $flow, array $entries, bool $caller_transaction) {
private function record(array $flow, array $entries, bool $caller_transaction, bool $allow_empty_entries = false) {
global $wpdb;
$operation_key = substr(sanitize_text_field((string) ($flow['operation_key'] ?? '')), 0, 191);
$event_type = sanitize_key((string) ($flow['event_type'] ?? ''));
if ($operation_key === '' || $event_type === '' || $entries === []) {
if ($operation_key === '' || $event_type === '' || ($entries === [] && !$allow_empty_entries)) {
return new \WP_Error('aksrt_flow_invalid', __('资金流水参数无效', 'aksrt'), ['status' => 500]);
}
@@ -272,7 +336,12 @@ final class FinancialFlowService {
));
}
private function record_order_event(array $order, string $event_type, int $sign, bool $caller_transaction) {
/**
* @param array<string,mixed> $order
* @param bool $voucher_settled 凭证结算(卡密核销):订单可能为 0 元或以积分计价,
* 且没有任何现金/资产分录,故跳过金额与资产口径校验。
*/
private function record_order_event(array $order, string $event_type, int $sign, bool $caller_transaction, bool $voucher_settled = false) {
$order_number = substr(sanitize_text_field((string) ($order['order_number'] ?? '')), 0, 32);
$user_id = max(0, (int) ($order['user_id'] ?? 0));
if ($order_number === '') {
@@ -284,16 +353,36 @@ final class FinancialFlowService {
}
$payment_method = sanitize_key((string) ($order['payment_method'] ?? ''));
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
if (in_array($payment_method, ['balance', 'points'], true) || $currency === 'points') {
return new \WP_Error('aksrt_flow_order_asset', __('余额或积分订单必须通过资产账本确认', 'aksrt'), ['status' => 500]);
}
try {
$amount_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($order['amount'] ?? ''))->minor();
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_flow_amount', __('订单流水金额无效', 'aksrt'), ['status' => 500]);
}
if ($amount_minor <= 0) {
return new \WP_Error('aksrt_flow_amount', __('订单流水金额无效', 'aksrt'), ['status' => 500]);
$metadata = [
'payment_method' => $payment_method,
'currency' => $currency,
];
$entries = [];
if ($voucher_settled) {
// 卡密核销:凭证本身即支付凭据,订单金额可能为 0、也可能以积分计价,
// 因此不做金额校验、也不生成现金分录;流水行即该订单的支付凭证
// (订单详情页经 FinancialFlowQuery::order_payment_number() 反查得到)。
$metadata['voucher'] = 'card_pass';
} else {
if (in_array($payment_method, ['balance', 'points'], true) || $currency === 'points') {
return new \WP_Error('aksrt_flow_order_asset', __('余额或积分订单必须通过资产账本确认', 'aksrt'), ['status' => 500]);
}
try {
$amount_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($order['amount'] ?? ''))->minor();
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_flow_amount', __('订单流水金额无效', 'aksrt'), ['status' => 500]);
}
if ($amount_minor <= 0) {
return new \WP_Error('aksrt_flow_amount', __('订单流水金额无效', 'aksrt'), ['status' => 500]);
}
$entries[] = [
'user_id' => $user_id,
'asset' => 'cash',
'direction' => $sign > 0 ? 'in' : 'out',
'entry_type' => $event_type === 'order_payment' ? 'payment' : 'refund',
'delta_minor' => $sign * $amount_minor,
'remark' => $event_type === 'order_payment' ? __('订单支付', 'aksrt') : __('订单退款', 'aksrt'),
];
}
$operation_key = \AKSRT\Ledger\OperationKey::deterministic(
$event_type === 'order_payment' ? 'order-payment' : 'order-refund',
@@ -306,19 +395,9 @@ final class FinancialFlowService {
'ref_key' => $order_number,
'order_number' => $order_number,
'provider_transaction_id' => (string) ($order['payment_transaction_id'] ?? ''),
'metadata' => [
'payment_method' => $payment_method,
'currency' => $currency,
],
'metadata' => $metadata,
'created_at' => self::utc_now(),
], [[
'user_id' => $user_id,
'asset' => 'cash',
'direction' => $sign > 0 ? 'in' : 'out',
'entry_type' => $event_type === 'order_payment' ? 'payment' : 'refund',
'delta_minor' => $sign * $amount_minor,
'remark' => $event_type === 'order_payment' ? __('订单支付', 'aksrt') : __('订单退款', 'aksrt'),
]], $caller_transaction);
], $entries, $caller_transaction, $voucher_settled);
}
private function number_for_order_event(string $order_number, string $event_type): string {
@@ -359,6 +438,11 @@ final class FinancialFlowService {
if (sanitize_key((string) ($order['status'] ?? '')) !== \AKSRT\Payment\Order::STATUS_PAID) {
return false;
}
// card_pass:卡密即支付凭据,订单已支付就说明凭证已结算,不依赖现金或资产分录为凭。
// 余额卡/积分卡的核销因此也能落成 order_payment 流水,与普通订单同口径。
if (sanitize_key((string) ($order['payment_method'] ?? '')) === 'card_pass') {
return true;
}
$buyer_id = max(0, (int) ($order['user_id'] ?? 0));
foreach ($entries as $entry) {
if ((int) ($entry['user_id'] ?? 0) === $buyer_id && (int) ($entry['delta_minor'] ?? 0) < 0) {
@@ -371,8 +455,10 @@ final class FinancialFlowService {
/** @param array<string,mixed> $order @param array<int,array<string,mixed>> $entries */
private function is_external_recharge(array $order, array $entries): bool {
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
// 这些方式都在平台内部结算,核销当下没有第三方现金流入:balance / points 以已有资产支付,
// card_pass 以平台自发凭证支付(现金在售卡时已确认),故都不生成现金分录。
if (sanitize_key((string) ($order['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|| in_array($method, ['', 'balance', 'points'], true)
|| in_array($method, ['', 'balance', 'points', 'card_pass'], true)
) {
return false;
}