Files
wordpress-theme-aksrt/tests/widget-system/financial-flow-contract.test.cjs
T
kate522 ca9dc7794f refactor(pay): 流水号扩至 22 字符,移除资金流水归档方案与删单守卫
四件事合在一笔,因为它们落在同一批文件里——OrderRepositoryCore 与流水契约测试
同时承载号码规则与订单清理逻辑,硬拆会产生引用不通的中间提交。

1) 号码熵。{YmdHis}{6 位随机} 在同一秒内只有 10^6 的取值空间,高并发下单会因
   随机后缀碰撞触发 uniq_order_number / uniq_flow_number 冲突(每秒 3 单约
   2.6 天撞一次,每秒 10 单约每天 4 次)。后缀扩到 8 位后空间升至 10^8
   (+6.6 bit),撞号频率下降两个数量级。
   选 8 位纯数字而非等长 base36:保持定长纯数字形态,号码仍可被运营从截图或
   邮件整段复制、电话口述;且表默认排序规则是 utf8mb4_*_ci(大小写不敏感),
   混入大小写字母会让唯一索引把 aB 与 Ab 判为重复行,反而凭空制造冲突。22 字符
   仍满足 VARCHAR(32) 与网关 out_trade_no ≤ 32,拼上重试后缀 P999 后为 26 字符。

2) 自带订单号形态校验。create() 此前对调用方传入的 order_number 不做检查,传数组
   会因 (string) 转换把字面量 "Array" 写进列。现收紧为「全数字、20 或 22 位」,
   不合规即弃用并回落到分配器(WP_DEBUG 下留痕)。放行 20 位是为了兼容扩位前
   分配的历史号码——该方法原有的注释就声明服务于导入、补单与幂等重放。

3) 流水表补 KEY idx_flow_created (created_at)。后台「资金流水」页默认视图是
   WHERE 1=1 ORDER BY created_at DESC, id DESC LIMIT n 加同条件 COUNT(*),界面
   上的日期区间筛选也只有 created_at 可用。此前没有任何以 created_at 为前导列的
   索引(idx_flow_event_time 的前导列是 event_type,筛选时可空),于是每次翻页
   都是全表扫描 + filesort,且与表大小无关地重复付出。

4) 移除归档方案与删单守卫。归档表 + 每日清理 cron 需要配套的归档浏览入口才闭环,
   未闭环时运营会误以为记录丢失;而第 3 条的索引已覆盖它要解决的主要问题,且
   不必在财务表上执行 DELETE。一并撤回删单 / 清理订单时对财务记录的拦截。

- BusinessNumber:后缀位数与上界收敛为 SUFFIX_DIGITS / SUFFIX_MAX,去掉三处
  重复的魔法数字;类文档写明选型依据,避免日后被改回字母方案
- OrderRepositoryCore:新增 ORDER_NUMBER_PATTERN 与 is_scalar 守卫;
  clear_closed_orders 与 delete_order 恢复单路签名(去掉 $force 与
  $include_orders_with_records)
- DBMigratorPart2:流水表新增 idx_flow_created,并删除两张归档表 DDL
- PayOrders:清理与删除订单回到单路处理,移除 orders_clear_blocked /
  order_delete_blocked 两条提示
- 删除 FinancialFlowRetention 类与 functions.php 中的引导注册
- 测试:BusinessNumberTest 期望值改 22 字符、越界用例改 10^8、补纯数字与 P999
  余量断言;流水契约测试并入索引断言、归档移除的反向断言、号码重试与形态断言

按用户要求不写双路逻辑与数据迁移。新索引依赖 dbDelta 在下次迁移时补到既有表;
若此前已跑过迁移,两张归档空表需手工 DROP(未写迁移,故不自动处理)。

验证:PHPUnit 44 项 / 106 断言全部通过;node --test(已追踪用例)333 项 /
333 通过,含本地专用用例共 370 项全部通过;php -l 全部改动的 PHP 文件通过;
残留引用扫描确认 FinancialFlowRetention、两张归档表名、has_financial_records、
count_closed_orders_with_records、financial_records_exists_sql、两条 blocked
提示、$force 与 $include_orders_with_records 均已无残留。
2026-09-18 23:17:36 +08:00

169 lines
10 KiB
JavaScript

const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const root = path.resolve(__dirname, '../..')
const read = file => fs.readFileSync(path.join(root, file), 'utf8')
test('financial flow and withdrawal numbers use the new-only schema', () => {
const schema = read('src/Core/DBMigratorPart2.php')
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_financial_flow \([\s\S]*?UNIQUE KEY uniq_flow_number \(flow_number\)/)
assert.match(schema, /UNIQUE KEY uniq_flow_operation \(operation_key\)/)
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_financial_flow_entry \([\s\S]*?UNIQUE KEY uniq_flow_line \(flow_id, line_no\)/)
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, 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')
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', () => {
const ledger = read('src/Ledger/LedgerService.php')
const order = read('src/Payment/OrderRepositoryCore.php')
const withdrawal = read('src/Payment/Withdrawal.php')
assert.ok(ledger.indexOf('record_ledger(') < ledger.indexOf("query('COMMIT')"))
assert.match(order, /record_order_payment\(\$paid_order, true\)/)
assert.match(order, /record_order_refund\(\$refunded_order, true\)/)
assert.match(withdrawal, /record_withdrawal_payout\([\s\S]*?true[\s\S]*?query\('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'), /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/)
})
test('balance history renders withdrawal numbers from the committed flow', () => {
const query = read('src/Ledger/LedgerQuery.php')
const profile = read('inc/helpers/user-center-renderers/credits-wallet-ledger.php')
const wallet = read('inc/helpers/user-center-renderers/credits-wallet.php') + read('inc/helpers/user-center-renderers/credits-wallet-ledger.php')
assert.match(query, /LEFT JOIN \{\$flow_table\} f ON f\.ledger_transaction_id = t\.id/)
assert.match(query, /'withdrawal_number'\s*=>\s*\(string\)/)
for (const source of [profile, wallet]) {
assert.match(source, /\['withdraw_hold', 'withdraw_refund'\]/)
assert.match(source, /\$row\['withdrawal_number'\]/)
}
})
test('user withdrawal history uses one paginated ten-item endpoint', () => {
const controller = read('src/Payment/CommissionLedger.php')
const renderer = read('inc/helpers/user-center-renderers/credits-wallet.php')
const script = read('assets/js/credits.js')
const styles = read('assets/css/routes/user-center/credits.css')
assert.match(controller, /\$withdrawal_args\['per_page'\]\['default'\]\s*=\s*10/)
assert.match(controller, /\$withdrawal_args\['per_page'\]\['maximum'\]\s*=\s*10/)
assert.match(controller, /'total'\s*=>\s*\$total/)
assert.match(controller, /'max_pages'\s*=>\s*\$max_pages/)
assert.match(controller, /'user_id'\s*=>\s*\$user_id/)
assert.doesNotMatch(renderer, /get_user_withdrawals\(/)
assert.doesNotMatch(renderer, /withdraw_history/)
assert.match(renderer, /data-aksrt-withdraw-history-list/)
assert.match(script, /\/earnings\/withdrawals\?page=/)
assert.match(script, /&per_page=10/)
assert.match(script, /data-aksrt-withdraw-history-page/)
assert.match(script, /实际到账:/)
assert.match(script, /元)/)
assert.match(script, /appendText\(row, text\(item && item\.created_at\)/)
assert.doesNotMatch(script, /item\.note/)
assert.doesNotMatch(styles, /uc-withdraw-history__note/)
assert.match(styles, /\.uc-withdraw-history-modal__dialog\s*\{[\s\S]*?max-width: min\(860px/)
assert.match(styles, /\.uc-withdraw-history__item\s*\{[\s\S]*?display: flex/)
assert.match(styles, /\.uc-withdraw-history-modal__dialog\s*\{[\s\S]*?overflow: hidden/)
})
test('flow table is indexed for the admin listing and date range filters', () => {
const schema = read('src/Core/DBMigratorPart2.php')
// 后台默认视图是 WHERE 1=1 ORDER BY created_at DESC, id DESC LIMIT n,外加同条件的
// COUNT(*),界面上的日期区间筛选也只有 created_at 可用;created_at 必须是索引前导列,
// 否则每次翻页都是全表扫描 + filesort。
assert.match(schema, /KEY idx_flow_created \(created_at\),/)
// 复合索引前导列是 event_type(筛选时可空),替代不了上面那条。
assert.match(schema, /KEY idx_flow_event_time \(event_type, created_at\),/)
})
test('flow retention and archive were deliberately removed', () => {
// 归档方案(归档表 + 每日清理 cron)已移除:一条 created_at 索引覆盖了它要解决的问题,
// 而它会在财务表上执行 DELETE,并需要额外的归档浏览入口才闭环。
assert.doesNotMatch(read('src/Core/DBMigratorPart2.php'), /financial_flow_archive/)
assert.doesNotMatch(read('src/Core/DBMigratorPart3.php'), /financial_flow_archive/)
assert.doesNotMatch(read('functions.php'), /FinancialFlowRetention/)
assert.equal(fs.existsSync(path.join(root, 'src/Finance/FinancialFlowRetention.php')), false)
})
test('admin flow search uses an equality lookup for full identifiers', () => {
const query = read('src/Finance/FinancialFlowQuery.php')
assert.match(query, /private function keyword_hits_identifier\(string \$keyword\): bool/)
assert.match(query, /if \(\$this->keyword_hits_identifier\(\$keyword\)\)/)
// 精确分支走等值(各列有索引),模糊分支保留 LIKE 兜底,前缀搜索行为不变。
assert.match(query, /f\.flow_number = %s OR f\.operation_key = %s/)
assert.match(query, /f\.flow_number LIKE %s OR f\.operation_key LIKE %s/)
})
test('order creation retries only on order-number unique key collisions', () => {
const repository = read('src/Payment/OrderRepositoryCore.php')
assert.match(repository, /private const ORDER_NUMBER_MAX_ATTEMPTS = \d+;/)
assert.match(repository, /for \(\$attempt = 1; \$attempt <= \$max_attempts; \$attempt\+\+\)/)
// 只对订单号唯一键冲突重试;request_key 等冲突属于调用方幂等语义,必须原样失败。
assert.match(repository, /stripos\(\(string\) \$wpdb->last_error, 'uniq_order_number'\)/)
// 调用方显式传入的合规订单号不得换号重试。
assert.match(repository, /\$max_attempts = \$caller_supplied \? 1 : self::ORDER_NUMBER_MAX_ATTEMPTS;/)
})
test('caller-supplied order numbers are constrained to allocator shapes', () => {
const repository = read('src/Payment/OrderRepositoryCore.php')
// 22 位是当前规则,20 位兼容扩位前的历史号码(导入旧数据 / 旧单重放)。
// 用精确字符串断言:正则字面量里转义 \d 与花括号极易写错,会静默失配。
assert.ok(repository.includes("ORDER_NUMBER_PATTERN = '/^\\d{20}$|^\\d{22}$/';"))
// 非标量(数组 / 对象)不得进入 (string) 转换,否则会写入 "Array"。
assert.match(repository, /is_scalar\(\$data\['order_number'\]\)/)
// 不合规时必须回落到分配器,而不是原样写库。
assert.match(repository, /\$order_number = \$caller_supplied \? \$declared_number : \$this->allocate_order_number\(\);/)
// 弃用不受控号码时在 WP_DEBUG 下留痕。
assert.match(repository, /error_log\(sprintf\('\[AKSRT Order\] 弃用不合规的自带订单号:%s'/)
})
test('order cleanup no longer guards on financial records', () => {
const repository = read('src/Payment/OrderRepositoryCore.php')
const payOrders = read('src/Admin/Native/PayOrders.php')
assert.match(repository, /public function clear_closed_orders\(int \$days = 14\): int/)
assert.match(repository, /public function delete_order\(string \$order_number\): bool/)
for (const source of [repository, payOrders]) {
assert.doesNotMatch(source, /has_financial_records|count_closed_orders_with_records|financial_records_exists_sql/)
assert.doesNotMatch(source, /orders_clear_blocked|order_delete_blocked/)
}
})