卡密充值与兑换接入统一订单号与支付流水号
此前卡密核销虽已建订单,但订单号硬编码 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:
@@ -62,6 +62,7 @@
|
|||||||
!/tests/admin/charge-cards-dependency.test.cjs
|
!/tests/admin/charge-cards-dependency.test.cjs
|
||||||
!/tests/admin/charge-cards-pagination.test.cjs
|
!/tests/admin/charge-cards-pagination.test.cjs
|
||||||
!/tests/admin/charge-cards-form-constraints.test.cjs
|
!/tests/admin/charge-cards-form-constraints.test.cjs
|
||||||
|
!/tests/admin/card-pass-order-numbers.test.cjs
|
||||||
!/tests/front-submit/
|
!/tests/front-submit/
|
||||||
/tests/front-submit/*
|
/tests/front-submit/*
|
||||||
!/tests/front-submit/video-cover-contract.test.cjs
|
!/tests/front-submit/video-cover-contract.test.cjs
|
||||||
|
|||||||
@@ -99,6 +99,69 @@ final class FinancialFlowService {
|
|||||||
return $this->record_order_event($order, 'order_refund', 1, $caller_transaction);
|
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 */
|
/** @return string|\WP_Error */
|
||||||
public function record_withdrawal_payout(array $withdrawal, string $operation_key, string $created_at, bool $caller_transaction = false) {
|
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));
|
$user_id = max(0, (int) ($withdrawal['user_id'] ?? 0));
|
||||||
@@ -130,13 +193,14 @@ final class FinancialFlowService {
|
|||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $flow
|
* @param array<string,mixed> $flow
|
||||||
* @param array<int,array<string,mixed>> $entries
|
* @param array<int,array<string,mixed>> $entries
|
||||||
|
* @param bool $allow_empty_entries 卡密核销等「无现金/资产分录」的凭证型流水,允许分录为空。
|
||||||
* @return string|\WP_Error
|
* @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;
|
global $wpdb;
|
||||||
$operation_key = substr(sanitize_text_field((string) ($flow['operation_key'] ?? '')), 0, 191);
|
$operation_key = substr(sanitize_text_field((string) ($flow['operation_key'] ?? '')), 0, 191);
|
||||||
$event_type = sanitize_key((string) ($flow['event_type'] ?? ''));
|
$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]);
|
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);
|
$order_number = substr(sanitize_text_field((string) ($order['order_number'] ?? '')), 0, 32);
|
||||||
$user_id = max(0, (int) ($order['user_id'] ?? 0));
|
$user_id = max(0, (int) ($order['user_id'] ?? 0));
|
||||||
if ($order_number === '') {
|
if ($order_number === '') {
|
||||||
@@ -284,6 +353,17 @@ final class FinancialFlowService {
|
|||||||
}
|
}
|
||||||
$payment_method = sanitize_key((string) ($order['payment_method'] ?? ''));
|
$payment_method = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||||
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
|
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
|
||||||
|
$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') {
|
if (in_array($payment_method, ['balance', 'points'], true) || $currency === 'points') {
|
||||||
return new \WP_Error('aksrt_flow_order_asset', __('余额或积分订单必须通过资产账本确认', 'aksrt'), ['status' => 500]);
|
return new \WP_Error('aksrt_flow_order_asset', __('余额或积分订单必须通过资产账本确认', 'aksrt'), ['status' => 500]);
|
||||||
}
|
}
|
||||||
@@ -295,6 +375,15 @@ final class FinancialFlowService {
|
|||||||
if ($amount_minor <= 0) {
|
if ($amount_minor <= 0) {
|
||||||
return new \WP_Error('aksrt_flow_amount', __('订单流水金额无效', 'aksrt'), ['status' => 500]);
|
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(
|
$operation_key = \AKSRT\Ledger\OperationKey::deterministic(
|
||||||
$event_type === 'order_payment' ? 'order-payment' : 'order-refund',
|
$event_type === 'order_payment' ? 'order-payment' : 'order-refund',
|
||||||
[$order_number]
|
[$order_number]
|
||||||
@@ -306,19 +395,9 @@ final class FinancialFlowService {
|
|||||||
'ref_key' => $order_number,
|
'ref_key' => $order_number,
|
||||||
'order_number' => $order_number,
|
'order_number' => $order_number,
|
||||||
'provider_transaction_id' => (string) ($order['payment_transaction_id'] ?? ''),
|
'provider_transaction_id' => (string) ($order['payment_transaction_id'] ?? ''),
|
||||||
'metadata' => [
|
'metadata' => $metadata,
|
||||||
'payment_method' => $payment_method,
|
|
||||||
'currency' => $currency,
|
|
||||||
],
|
|
||||||
'created_at' => self::utc_now(),
|
'created_at' => self::utc_now(),
|
||||||
], [[
|
], $entries, $caller_transaction, $voucher_settled);
|
||||||
'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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function number_for_order_event(string $order_number, string $event_type): string {
|
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) {
|
if (sanitize_key((string) ($order['status'] ?? '')) !== \AKSRT\Payment\Order::STATUS_PAID) {
|
||||||
return false;
|
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));
|
$buyer_id = max(0, (int) ($order['user_id'] ?? 0));
|
||||||
foreach ($entries as $entry) {
|
foreach ($entries as $entry) {
|
||||||
if ((int) ($entry['user_id'] ?? 0) === $buyer_id && (int) ($entry['delta_minor'] ?? 0) < 0) {
|
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 */
|
/** @param array<string,mixed> $order @param array<int,array<string,mixed>> $entries */
|
||||||
private function is_external_recharge(array $order, array $entries): bool {
|
private function is_external_recharge(array $order, array $entries): bool {
|
||||||
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
|
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||||
|
// 这些方式都在平台内部结算,核销当下没有第三方现金流入:balance / points 以已有资产支付,
|
||||||
|
// card_pass 以平台自发凭证支付(现金在售卡时已确认),故都不生成现金分录。
|
||||||
if (sanitize_key((string) ($order['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -444,7 +444,11 @@ class ChargeCard {
|
|||||||
if ($card_id <= 0 || $amount_minor <= 0) {
|
if ($card_id <= 0 || $amount_minor <= 0) {
|
||||||
return new \WP_Error('aksrt_card_amount', __('当前卡密可兑换资产为 0', 'aksrt'), ['status' => 400]);
|
return new \WP_Error('aksrt_card_amount', __('当前卡密可兑换资产为 0', 'aksrt'), ['status' => 400]);
|
||||||
}
|
}
|
||||||
$order_number = 'CARD' . $card_id;
|
$order_number = $this->allocate_redeem_order_number();
|
||||||
|
if (is_wp_error($order_number)) {
|
||||||
|
return $order_number;
|
||||||
|
}
|
||||||
|
$transaction_number = $this->card_pass_transaction_number();
|
||||||
$guard = function () use ($card_id, $user_id, $submitted_card, $type) {
|
$guard = function () use ($card_id, $user_id, $submitted_card, $type) {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$current = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d FOR UPDATE", $card_id), ARRAY_A);
|
$current = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d FOR UPDATE", $card_id), ARRAY_A);
|
||||||
@@ -465,9 +469,9 @@ class ChargeCard {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
$mutation = function () use ($user_id, $type, $meta, $card_id, $order_number, $operation_key) {
|
$mutation = function () use ($user_id, $type, $meta, $card_id, $order_number, $operation_key, $transaction_number) {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$created = $this->create_redeem_order($user_id, $type, $meta, $card_id, '', $operation_key, $order_number, Order::STATUS_PAID);
|
$created = $this->create_redeem_order($user_id, $type, $meta, $card_id, '', $operation_key, $order_number, Order::STATUS_PAID, $transaction_number);
|
||||||
if ($created !== $order_number) {
|
if ($created !== $order_number) {
|
||||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||||
}
|
}
|
||||||
@@ -498,6 +502,22 @@ class ChargeCard {
|
|||||||
if (is_wp_error($result)) {
|
if (is_wp_error($result)) {
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
if ($result->replayed) {
|
||||||
|
// 重放不会重新建单,上面分配的订单号并未落库;这里改从既有订单取回真实号,
|
||||||
|
// 避免把一个新号回给前端。
|
||||||
|
$existing_order = OrderRepository::get_instance()->get_by_request_key($operation_key);
|
||||||
|
if (is_array($existing_order) && (string) ($existing_order['order_number'] ?? '') !== '') {
|
||||||
|
$order_number = (string) $existing_order['order_number'];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 账本命令必须保持确定性(payload_hash 含 metadata,塞入随机订单号会破坏幂等重放),
|
||||||
|
// 因此在账本提交之后再把流水行挂到订单上。
|
||||||
|
\AKSRT\Finance\FinancialFlowService::get_instance()->attach_order_to_ledger_flow(
|
||||||
|
(int) $result->transaction_id,
|
||||||
|
$order_number,
|
||||||
|
$transaction_number
|
||||||
|
);
|
||||||
|
}
|
||||||
$balance = $result->balance($user_id, $asset);
|
$balance = $result->balance($user_id, $asset);
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@@ -533,11 +553,15 @@ class ChargeCard {
|
|||||||
$card_id = (int) ($row['id'] ?? 0);
|
$card_id = (int) ($row['id'] ?? 0);
|
||||||
$level = max(1, min(2, (int) ($meta['level'] ?? 1)));
|
$level = max(1, min(2, (int) ($meta['level'] ?? 1)));
|
||||||
$days = max(0, (int) ($meta['days'] ?? 0));
|
$days = max(0, (int) ($meta['days'] ?? 0));
|
||||||
$order_number = 'CARD' . $card_id;
|
|
||||||
$order_table = \AKSRT\Core\DBMigrator::table('order');
|
$order_table = \AKSRT\Core\DBMigrator::table('order');
|
||||||
if ($card_id <= 0 || sanitize_key((string) ($row['type'] ?? '')) !== self::TYPE_VIP) {
|
if ($card_id <= 0 || sanitize_key((string) ($row['type'] ?? '')) !== self::TYPE_VIP) {
|
||||||
return new \WP_Error('aksrt_card_type', __('当前卡密不是会员卡', 'aksrt'), ['status' => 400]);
|
return new \WP_Error('aksrt_card_type', __('当前卡密不是会员卡', 'aksrt'), ['status' => 400]);
|
||||||
}
|
}
|
||||||
|
$order_number = $this->allocate_redeem_order_number();
|
||||||
|
if (is_wp_error($order_number)) {
|
||||||
|
return $order_number;
|
||||||
|
}
|
||||||
|
$transaction_number = $this->card_pass_transaction_number();
|
||||||
|
|
||||||
for ($attempt = 1; $attempt <= 3; $attempt++) {
|
for ($attempt = 1; $attempt <= 3; $attempt++) {
|
||||||
if ($wpdb->query('START TRANSACTION') === false) {
|
if ($wpdb->query('START TRANSACTION') === false) {
|
||||||
@@ -595,12 +619,28 @@ class ChargeCard {
|
|||||||
(string) ($current['password'] ?? ''),
|
(string) ($current['password'] ?? ''),
|
||||||
$operation_key,
|
$operation_key,
|
||||||
$order_number,
|
$order_number,
|
||||||
Order::STATUS_PAID
|
Order::STATUS_PAID,
|
||||||
|
$transaction_number
|
||||||
);
|
);
|
||||||
if ($created !== $order_number) {
|
if ($created !== $order_number) {
|
||||||
throw new \RuntimeException('vip_card_order_create_failed');
|
throw new \RuntimeException('vip_card_order_create_failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 会员卡没有账本参与(入账的是 usermeta 而非账本资产),流水要单独补一条,
|
||||||
|
// 且它不产生任何现金/资产分录(0 元凭证)。同一事务内写入,失败即整体回滚。
|
||||||
|
$flow_number = \AKSRT\Finance\FinancialFlowService::get_instance()->record_card_pass_payment([
|
||||||
|
'order_number' => $order_number,
|
||||||
|
'user_id' => $user_id,
|
||||||
|
'amount' => '0.00',
|
||||||
|
'currency' => 'CNY',
|
||||||
|
'status' => Order::STATUS_PAID,
|
||||||
|
'payment_method' => 'card_pass',
|
||||||
|
'payment_transaction_id' => $transaction_number,
|
||||||
|
], true);
|
||||||
|
if (is_wp_error($flow_number)) {
|
||||||
|
throw new \RuntimeException('vip_card_flow_failed');
|
||||||
|
}
|
||||||
|
|
||||||
\AKSRT\Content\VipService::get_instance()->activate_vip($user_id, $level, $days);
|
\AKSRT\Content\VipService::get_instance()->activate_vip($user_id, $level, $days);
|
||||||
$actual_level = max(0, (int) get_user_meta($user_id, 'aksrt_vip_level', true));
|
$actual_level = max(0, (int) get_user_meta($user_id, 'aksrt_vip_level', true));
|
||||||
$actual_expiry = max(0, (int) get_user_meta($user_id, 'aksrt_vip_expires', true));
|
$actual_expiry = max(0, (int) get_user_meta($user_id, 'aksrt_vip_expires', true));
|
||||||
@@ -730,10 +770,40 @@ class ChargeCard {
|
|||||||
return preg_match('/\\b(1205|1213)\\b/', (string) ($wpdb->last_error ?? ''), $matches) ? (int) $matches[1] : 0;
|
return preg_match('/\\b(1205|1213)\\b/', (string) ($wpdb->last_error ?? ''), $matches) ? (int) $matches[1] : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡密核销订单号:走统一的订单号分配器,受后台「订单前缀」控制
|
||||||
|
* (Settings::get_order_settings()['prefix'],默认 AKSRT)。
|
||||||
|
*
|
||||||
|
* @return string|\WP_Error
|
||||||
|
*/
|
||||||
|
private function allocate_redeem_order_number() {
|
||||||
|
if (!class_exists('AKSRT\\Payment\\OrderRepository')) {
|
||||||
|
return new \WP_Error('aksrt_order_unavailable', __('订单系统不可用', 'aksrt'), ['status' => 500]);
|
||||||
|
}
|
||||||
|
$order_number = OrderRepository::get_instance()->allocate_order_number();
|
||||||
|
if (!is_string($order_number) || $order_number === '') {
|
||||||
|
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||||
|
}
|
||||||
|
return $order_number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡密核销订单的渠道交易号:与资金流水同源,受后台「流水号前缀」控制
|
||||||
|
* (Settings::get_order_settings()['flow_prefix'],默认 AK)。
|
||||||
|
*/
|
||||||
|
private function card_pass_transaction_number(): string {
|
||||||
|
$prefix = 'AK';
|
||||||
|
if (class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_order_settings')) {
|
||||||
|
$settings = \AKSRT\Settings\Settings::get_order_settings();
|
||||||
|
$prefix = \AKSRT\Finance\BusinessNumber::normalize_prefix($settings['flow_prefix'] ?? 'AK', 'AK');
|
||||||
|
}
|
||||||
|
return \AKSRT\Finance\BusinessNumber::candidate($prefix);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $meta
|
* @param array<string,mixed> $meta
|
||||||
*/
|
*/
|
||||||
private function create_redeem_order(int $user_id, string $type, array $meta, int $card_id, string $password, string $request_key = '', string $order_number = '', string $status = ''): string {
|
private function create_redeem_order(int $user_id, string $type, array $meta, int $card_id, string $password, string $request_key = '', string $order_number = '', string $status = '', string $transaction_number = ''): string {
|
||||||
if (!class_exists('AKSRT\\Payment\\OrderRepository') || !class_exists('\AKSRT\Core\DomainModel')) {
|
if (!class_exists('AKSRT\\Payment\\OrderRepository') || !class_exists('\AKSRT\Core\DomainModel')) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -774,7 +844,7 @@ class ChargeCard {
|
|||||||
'currency' => $currency,
|
'currency' => $currency,
|
||||||
'status' => $order_status,
|
'status' => $order_status,
|
||||||
'payment_method' => 'card_pass',
|
'payment_method' => 'card_pass',
|
||||||
'payment_transaction_id' => $order_status === Order::STATUS_PAID ? 'card_pass_' . $card_id : '',
|
'payment_transaction_id' => $order_status === Order::STATUS_PAID ? $transaction_number : '',
|
||||||
'paid_at' => $order_status === Order::STATUS_PAID ? current_time('mysql') : null,
|
'paid_at' => $order_status === Order::STATUS_PAID ? current_time('mysql') : null,
|
||||||
'note' => is_string($note) ? $note : '',
|
'note' => is_string($note) ? $note : '',
|
||||||
'request_key' => $request_key,
|
'request_key' => $request_key,
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡密核销的订单号 / 渠道交易号 / 支付流水号契约。
|
||||||
|
*
|
||||||
|
* 背景(已修复的缺陷):
|
||||||
|
* 卡密充值(balance/credits)与兑换(vip)虽已建订单,但订单号硬编码为
|
||||||
|
* `CARD{卡ID}`、渠道交易号硬编码为 `card_pass_{卡ID}`,均不受后台
|
||||||
|
* 「订单前缀 / 流水号前缀」控制;且卡密核销产生的资金流水没有挂到订单上
|
||||||
|
* (order_number 为 NULL),订单详情页的「支付流水号」永远显示「—」。
|
||||||
|
*
|
||||||
|
* 处置:
|
||||||
|
* - 订单号走 OrderRepository::allocate_order_number()(消费 payment_order_prefix);
|
||||||
|
* - 渠道交易号走 BusinessNumber::candidate(flow_prefix)(消费 payment_flow_prefix);
|
||||||
|
* - 余额/积分卡的流水由账本落库后用 attach_order_to_ledger_flow() 补挂订单号,
|
||||||
|
* 并把 event_type 归一为 order_payment;会员卡(0 元、无账本参与)由
|
||||||
|
* record_card_pass_payment() 直接落一条零分录的 order_payment 流水。
|
||||||
|
*
|
||||||
|
* 本契约额外钉住两条容易回退的坑:
|
||||||
|
* 1) **账本命令必须保持确定性** —— LedgerCommand::payload_hash() 覆盖 metadata,
|
||||||
|
* 把随机订单号塞进 credit() 的 metadata 会让重放从「幂等返回」变成
|
||||||
|
* `aksrt_ledger_idempotency_conflict`(实测复现过),因此 metadata 只允许 card_type。
|
||||||
|
* 2) **会员卡的零分录流水是刻意设计** —— prepare_entries() 拒绝 delta=0,
|
||||||
|
* 0 元凭证核销既无现金也无资产分录,只能以「零分录流水行」表达,
|
||||||
|
* record() 为此提供 allow_empty_entries 通道;不得用占位分录伪造。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..', '..');
|
||||||
|
const card = fs.readFileSync(path.join(root, 'src/Payment/ChargeCard.php'), 'utf8');
|
||||||
|
const flows = fs.readFileSync(path.join(root, 'src/Finance/FinancialFlowService.php'), 'utf8');
|
||||||
|
|
||||||
|
test('卡密订单号不再硬编码 CARD 前缀,改走统一分配器', () => {
|
||||||
|
assert.doesNotMatch(card, /'CARD'\s*\.\s*\$card_id/);
|
||||||
|
assert.match(card, /allocate_redeem_order_number\(\)/);
|
||||||
|
assert.match(card, /allocate_order_number\(\)/);
|
||||||
|
// 两条兑换路径都必须先取号并在失败时中断
|
||||||
|
const assetPart = card.slice(card.indexOf('private function redeem_asset_card'), card.indexOf('private function redeem_vip_card'));
|
||||||
|
const vipPart = card.slice(card.indexOf('private function redeem_vip_card'), card.indexOf('private function create_redeem_order'));
|
||||||
|
for (const [name, part] of [['redeem_asset_card', assetPart], ['redeem_vip_card', vipPart]]) {
|
||||||
|
assert.match(part, /\$order_number\s*=\s*\$this->allocate_redeem_order_number\(\);/, name);
|
||||||
|
assert.match(part, /is_wp_error\(\$order_number\)/, name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('渠道交易号不再硬编码 card_pass_ 前缀,改走流水号前缀', () => {
|
||||||
|
assert.doesNotMatch(card, /'card_pass_'\s*\.\s*\$card_id/);
|
||||||
|
assert.match(card, /card_pass_transaction_number\(\)/);
|
||||||
|
assert.match(card, /BusinessNumber::normalize_prefix\(\$settings\['flow_prefix'\]/);
|
||||||
|
assert.match(card, /BusinessNumber::candidate\(\$prefix\)/);
|
||||||
|
// create_redeem_order 只接收调用方传入的交易号,不再自行拼前缀
|
||||||
|
assert.match(card, /'payment_transaction_id'\s*=>\s*\$order_status === Order::STATUS_PAID \? \$transaction_number : ''/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('账本命令保持确定性:credit() 的 metadata 不得携带随机订单号', () => {
|
||||||
|
const assetPart = card.slice(card.indexOf('private function redeem_asset_card'), card.indexOf('private function redeem_vip_card'));
|
||||||
|
// metadata 只允许 card_type;order_number 一旦进 metadata,payload_hash 随机化,
|
||||||
|
// 重复请求会撞 uniq_operation_key 后走 replay、hash 不一致即报幂等冲突。
|
||||||
|
assert.match(assetPart, /\['card_type'\s*=>\s*\$type\]/);
|
||||||
|
assert.doesNotMatch(assetPart, /\['card_type'\s*=>\s*\$type,[^\]]*order_number/);
|
||||||
|
// 重放路径必须回取已落库订单号,而不是把本次新分配的号回给前端
|
||||||
|
assert.match(assetPart, /if\s*\(\$result->replayed\)/);
|
||||||
|
assert.match(assetPart, /get_by_request_key\(\$operation_key\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('余额/积分卡:账本提交后补挂订单号,会员卡:直接落零分录支付流水', () => {
|
||||||
|
assert.match(flows, /public function attach_order_to_ledger_flow\(int \$ledger_transaction_id, string \$order_number/);
|
||||||
|
// 只挂到已支付且支付方式为卡密的订单,且不得重复覆盖
|
||||||
|
const attach = flows.slice(flows.indexOf('public function attach_order_to_ledger_flow'));
|
||||||
|
assert.match(attach, /'card_pass'/);
|
||||||
|
assert.match(attach, /order_number IS NULL OR order_number = ''/);
|
||||||
|
assert.match(attach, /event_type = 'order_payment'/);
|
||||||
|
|
||||||
|
assert.match(flows, /public function record_card_pass_payment\(array \$order, bool \$caller_transaction = false\)/);
|
||||||
|
assert.match(flows, /\$voucher_settled = false\)/);
|
||||||
|
// 凭证结算模式豁免两条门槛,且不生成现金分录
|
||||||
|
assert.match(flows, /if \(\$voucher_settled\) \{[\s\S]*?\$metadata\['voucher'\] = 'card_pass';/);
|
||||||
|
assert.match(flows, /private function record\(array \$flow, array \$entries, bool \$caller_transaction, bool \$allow_empty_entries = false\)/);
|
||||||
|
assert.match(flows, /\(\$entries === \[\] && !\$allow_empty_entries\)/);
|
||||||
|
// is_external_recharge 不把卡密当外部收款渠道(核销当下没有现金流入)
|
||||||
|
assert.match(flows, /in_array\(\$method, \['', 'balance', 'points', 'card_pass'\], true\)/);
|
||||||
|
// is_successful_order_payment 认可卡密凭证结算
|
||||||
|
assert.match(flows, /'card_pass'\)\s*\{\s*return true;/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user