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 均已无残留。
This commit is contained in:
2026-09-18 23:17:36 +08:00
parent c06aadb9a9
commit ca9dc7794f
7 changed files with 205 additions and 27 deletions
@@ -104,3 +104,65 @@ test('user withdrawal history uses one paginated ten-item endpoint', () => {
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/)
}
})