refactor(pay): 统一流水号,移除订单/流水/提现三类号码前缀

订单号、资金流水号、提现单号此前各自带前缀配置,同一笔业务在不同界面会
呈现多个号码口径,对账与客诉排查都要先做一次前缀换算。现收敛为单一流水号
分配器,不再按业务类型区分号码形态。

- 删除 payment_order_prefix / payment_flow_prefix /
  payment_withdrawal_prefix 三项配置项及其生成、校验链路
- BusinessNumber::allocate() 成为唯一入口,输出 {YmdHis}{6 位随机},
  共 20 位无前缀,满足 VARCHAR(32) 与网关 out_trade_no ≤ 32 的限制
- 订单、资金流水、提现、卡密渠道交易号四处生成点全部改走该分配器
- 后台支付中心表头「订单号 / 提现单号」统一为「流水号」
- 前台用户中心与订单卡片文案同步统一;前台订单详情合并为单行流水号,
  账本流水号不再下发到前台,避免同一单据出现两个对外号码
- 同步更新 financial-flow / pay-card / card-pass-order-numbers 契约测试
  与 BusinessNumberTest 断言

按用户要求不做双路逻辑与数据迁移,直接替换为统一方案,历史行不做转换。

验证:node --test tests/**/*.test.cjs 362/364 通过,新增 37 项契约;失败的
2 项在改动前的干净 HEAD 基线上同样失败(基线 327 项 / 失败 2 项),分别命中
遗留的 fa fa- 标记与 FA 版本号断言,与本次改动无关。phpunit 44 项 / 103
断言全部通过;php -l 全部改动的 PHP 文件通过。
This commit is contained in:
kate522 committed 2026-09-18 22:14:27 +08:00
1 parent 8b999444b2
commit aad83072a8
30 files changed
+126 -144

No files matched your search

+10 -8
View File
@@ -5,13 +5,14 @@
*
* 背景(已修复的缺陷):
* 卡密充值(balance/credits)与兑换(vip)虽已建订单,但订单号硬编码为
* `CARD{卡ID}`、渠道交易号硬编码为 `card_pass_{卡ID}`,均不受后台
* 「订单前缀 / 流水号前缀」控制;且卡密核销产生的资金流水没有挂到订单上
* (order_number 为 NULL),订单详情页的「支付流水号」永远显示「—」。
* `CARD{卡ID}`、渠道交易号硬编码为 `card_pass_{卡ID}`;且卡密核销产生的资金流水
* 没有挂到订单上(order_number 为 NULL),订单详情页的「账本流水号」永远显示「—」。
*
* 处置:
* - 订单号走 OrderRepository::allocate_order_number()(消费 payment_order_prefix);
* - 渠道交易号走 BusinessNumber::candidate(flow_prefix)(消费 payment_flow_prefix);
* - 订单号走 OrderRepository::allocate_order_number();
* - 渠道交易号走 BusinessNumber::allocate();
* - 两者与资金流水号、提现单号同源,均为无前缀的统一流水号,
* 后台不再提供任何前缀配置项;
* - 余额/积分卡的流水由账本落库后用 attach_order_to_ledger_flow() 补挂订单号,
* 并把 event_type 归一为 order_payment;会员卡(0 元、无账本参与)由
* record_card_pass_payment() 直接落一条零分录的 order_payment 流水。
@@ -47,11 +48,12 @@ test('卡密订单号不再硬编码 CARD 前缀,改走统一分配器', () =>
}
});
test('渠道交易号不再硬编码 card_pass_ 前缀,改走流水号前缀', () => {
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\)/);
// 不再读取任何前缀配置,直接取统一流水号
assert.doesNotMatch(card, /normalize_prefix|flow_prefix|get_order_settings/);
assert.match(card, /private function card_pass_transaction_number\(\): string \{\s*return \\AKSRT\\Finance\\BusinessNumber::allocate\(\);/);
// create_redeem_order 只接收调用方传入的交易号,不再自行拼前缀
assert.match(card, /'payment_transaction_id'\s*=>\s*\$order_status === Order::STATUS_PAID \? \$transaction_number : ''/);
});
+16 -8
View File
@@ -4,16 +4,24 @@ use AKSRT\Finance\BusinessNumber;
use PHPUnit\Framework\TestCase;
final class BusinessNumberTest extends TestCase {
public function test_prefix_is_uppercased_filtered_and_limited(): void {
self::assertSame('AK', BusinessNumber::normalize_prefix('', 'AK'));
self::assertSame('TX_TEST-9', BusinessNumber::normalize_prefix(' tx_测test-9 ', 'TX'));
self::assertSame('ABCDEFGHIJKL', BusinessNumber::normalize_prefix('abcdefghijklmnop', 'AK'));
self::assertSame('TX', BusinessNumber::normalize_prefix('中文', 'TX'));
public function test_allocate_uses_site_time_and_six_digit_random_suffix(): void {
$time = new DateTimeImmutable('2026-08-25 13:14:15', new DateTimeZone('Asia/Shanghai'));
self::assertSame('20260825131415000007', BusinessNumber::allocate($time, 7));
self::assertSame('20260825131415999999', BusinessNumber::allocate($time, 999999));
}
public function test_candidate_uses_supplied_site_time_and_six_digit_random_suffix(): void {
public function test_allocate_is_prefix_free_and_within_gateway_limit(): void {
$time = new DateTimeImmutable('2026-08-25 13:14:15', new DateTimeZone('Asia/Shanghai'));
self::assertSame('AK20260825131415000007', BusinessNumber::candidate('ak', $time, 7));
self::assertMatchesRegularExpression('/^TX\d{14}\d{6}$/', BusinessNumber::candidate('TX', $time, 999999));
$number = BusinessNumber::allocate($time, 0);
self::assertSame('20260825131415000000', $number);
// 订单号 / 流水号 / 提现单号共用同一长度,须同时满足 VARCHAR(32) 与网关 out_trade_no ≤32。
self::assertSame(20, strlen($number));
self::assertLessThanOrEqual(32, strlen($number));
}
public function test_allocate_clamps_out_of_range_random_suffix(): void {
$time = new DateTimeImmutable('2026-01-01 00:00:00', new DateTimeZone('Asia/Shanghai'));
self::assertSame('20260101000000999999', BusinessNumber::allocate($time, 1000000));
self::assertSame('20260101000000000000', BusinessNumber::allocate($time, -5));
}
}
@@ -14,12 +14,32 @@ test('financial flow and withdrawal numbers use the new-only schema', () => {
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_withdrawal \([\s\S]*?withdrawal_number VARCHAR\(32\) NOT NULL[\s\S]*?UNIQUE KEY uniq_withdrawal_number \(withdrawal_number\)/)
})
test('order settings expose strict flow and withdrawal prefixes', () => {
const schema = read('assets/admin/src/schema/sections/payment/gateway.js')
test('order, flow and withdrawal numbers share one prefix-free allocator', () => {
const jsSchema = read('assets/admin/src/schema/sections/payment/gateway.js')
const phpSchema = read('src/Admin/Schema/SchemaPayment.php')
const provider = read('src/Admin/SchemaProvider.php')
assert.match(schema, /key:\s*'payment_flow_prefix'[^{\n]*sanitize_type:\s*'business_prefix'[^{\n]*default:\s*'AK'/)
assert.match(schema, /key:\s*'payment_withdrawal_prefix'[^{\n]*sanitize_type:\s*'business_prefix'[^{\n]*default:\s*'TX'/)
assert.match(provider, /business_prefix[\s\S]*?BusinessNumber::normalize_prefix/)
const runtime = read('src/Settings/PaymentRuntime.php')
const number = read('src/Finance/BusinessNumber.php')
// 三个前缀配置项及其校验/归一化链路必须彻底消失。
for (const source of [jsSchema, phpSchema]) {
assert.doesNotMatch(source, /payment_order_prefix|payment_flow_prefix|payment_withdrawal_prefix/)
assert.doesNotMatch(source, /business_prefix/)
}
assert.doesNotMatch(provider, /business_prefix|normalize_prefix/)
assert.doesNotMatch(runtime, /payment_order_prefix|payment_flow_prefix|payment_withdrawal_prefix/)
assert.doesNotMatch(runtime, /['"](prefix|flow_prefix|withdrawal_prefix)['"]\s*=>/)
assert.doesNotMatch(number, /normalize_prefix|MAX_PREFIX_LENGTH|candidate\(/)
// 唯一入口:无前缀的统一流水号分配器。
assert.match(number, /public static function allocate\(\?\\?DateTimeInterface/)
assert.match(number, /YmdHis/)
// 订单号、资金流水号、提现单号、卡密渠道交易号全部收敛到该分配器。
assert.match(read('src/Payment/OrderRepositoryCore.php'), /BusinessNumber::allocate\(\)/)
assert.match(read('src/Finance/FinancialFlowService.php'), /BusinessNumber::allocate\(\)/)
assert.match(read('src/Payment/Withdrawal.php'), /BusinessNumber::allocate\(\)/)
assert.match(read('src/Payment/ChargeCard.php'), /BusinessNumber::allocate\(\)/)
})
test('ledger and business state write their flow before commit', () => {
@@ -35,10 +55,14 @@ test('ledger and business state write their flow before commit', () => {
test('native and front-end surfaces expose immutable business numbers', () => {
assert.match(read('src/Admin/Native/PayMenus.php'), /PayFlows::PAGE_SLUG/)
assert.match(read('src/Admin/Native/PayFlows.php'), /流水明细/)
assert.match(read('src/Admin/Native/PayOrders.php'), /支付流水号/)
assert.match(read('src/Admin/Native/PayWithdrawals.php'), /提现单号/)
assert.match(read('src/Payment/UserOrders.php'), /\$view\['flow_number'\]/)
assert.match(read('assets/js/order-center.js'), /\["流水号", order\.flow_number \|\| "—"\]/)
// 后台同时提供业务流水号与账本流水号两个口径
assert.match(read('src/Admin/Native/PayOrders.php'), /账本流水号/)
assert.match(read('src/Admin/Native/PayWithdrawals.php'), /esc_html__\('流水号'/)
assert.doesNotMatch(read('src/Admin/Native/PayWithdrawals.php'), /提现单号/)
// 前台订单详情只对外暴露统一流水号:账本流水号既不下发也不展示
assert.match(read('assets/js/order-center.js'), /\["流水号", order\.order_number\]/)
assert.doesNotMatch(read('assets/js/order-center.js'), /flow_number|账本流水号/)
assert.doesNotMatch(read('src/Payment/UserOrders.php'), /\$view\['flow_number'\]/)
assert.match(read('inc/helpers/user-center-renderers/credits-wallet-ledger.php'), /withdrawal_number/)
})
@@ -40,7 +40,8 @@ test('paid article and sidebar widget use the Zibll purchase-card DOM', () => {
assert.match(pay, /function aksrt_get_paid_box_html/);
assert.match(pay, /pay-box zib-widget paid-box order-type-/);
assert.match(pay, /flex ac jb-green padding-10 em09/);
assert.match(pay, /订单号/);
assert.match(pay, /流水号/);
assert.doesNotMatch(pay, /'订单号'/);
assert.match(pay, /支付时间/);
assert.match(pay, /支付金额/);
assert.match(pay, /hidden-box show/);