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
+61 -9
View File
@@ -9,6 +9,27 @@ defined('ABSPATH') || exit;
trait OrderRepositoryCore {
/**
* 建单时对「订单号唯一键冲突」的重试次数。
*
* 统一流水号规则为 {YmdHis}{8 位随机数},同一秒内的取值空间为 10^8,
* 高并发下单仍可能因随机后缀碰撞触发 uniq_order_number 冲突。资金流水侧
* (FinancialFlowService::record)已有等价的 5 次重试,订单侧此前缺失,
* 碰撞时会直接把错误抛给用户,故在此对齐。
*/
private const ORDER_NUMBER_MAX_ATTEMPTS = 5;
/**
* 调用方自带订单号的合法形态:全数字,14 位时间前缀 + 定长数字后缀。
*
* 号码只有一种来源(本类的 allocate_order_number()),所以自带号必须落在
* 分配器能产出的范围内:22 位是当前规则(见 BusinessNumber),20 位是扩位
* 之前的历史规则——导入旧数据、旧单重放仍可能带它进来,故一并放行。
* 带前缀、UUID、非数字、超长等一律视为不受控输入:弃用并回落到分配器,
* 避免把异常长度写进 VARCHAR(32) 列后再去污染检索与对账。
*/
private const ORDER_NUMBER_PATTERN = '/^\d{20}$|^\d{22}$/';
private function table(): string {
@@ -27,7 +48,18 @@ trait OrderRepositoryCore {
public function create(array $data): string {
global $wpdb;
$order_number = $data['order_number'] ?? $this->allocate_order_number();
// 自带订单号先过形态校验:只接受分配器能产出的「全数字、20 或 22 位」。
// 不合规的一律弃用并回落到分配器(WP_DEBUG 下留痕),既不把非法长度写进库,
// 也不因此抛错中断调用方。
$declared_number = isset($data['order_number']) && is_scalar($data['order_number'])
? trim((string) $data['order_number'])
: '';
$caller_supplied = $declared_number !== ''
&& (bool) preg_match(self::ORDER_NUMBER_PATTERN, $declared_number);
if ($declared_number !== '' && !$caller_supplied && defined('WP_DEBUG') && WP_DEBUG) {
error_log(sprintf('[AKSRT Order] 弃用不合规的自带订单号:%s', $declared_number));
}
$order_number = $caller_supplied ? $declared_number : $this->allocate_order_number();
// Preserve JSON pay meta (VIP/content/coupon). Only strip tags for plain text notes.
$note_raw = (string) ($data['note'] ?? '');
@@ -84,10 +116,27 @@ trait OrderRepositoryCore {
$formats[] = '%s';
}
$wpdb->insert($this->table(), $row, $formats);
// 只有本方法自己分配的号码才允许换号重试。调用方显式传入的合规订单号
// (导入、补单、幂等重放)必须原样使用,冲突就应失败,不得悄悄换号。
$max_attempts = $caller_supplied ? 1 : self::ORDER_NUMBER_MAX_ATTEMPTS;
if ($wpdb->insert_id) {
return $order_number;
for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
$row['order_number'] = $order_number;
$wpdb->insert($this->table(), $row, $formats);
if ($wpdb->insert_id) {
return $order_number;
}
// 只对订单号唯一键冲突换号重试;request_key 冲突等属于调用方的
// 幂等语义,换号重试会掩盖重复提交,必须原样失败。
if ($attempt >= $max_attempts
|| stripos((string) $wpdb->last_error, 'uniq_order_number') === false
) {
return '';
}
$order_number = $this->allocate_order_number();
}
return '';
@@ -586,11 +635,14 @@ trait OrderRepositoryCore {
$days = max(1, min(3650, $days));
$table = $this->table();
$cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
$sql = "DELETE FROM {$table}
WHERE status = 'closed'
AND cancelled_at IS NOT NULL
AND cancelled_at < %s";
$deleted = $wpdb->query($wpdb->prepare($sql, $cutoff));
$deleted = $wpdb->query($wpdb->prepare(
"DELETE FROM {$table}
WHERE status = 'closed'
AND cancelled_at IS NOT NULL
AND cancelled_at < %s",
$cutoff
));
return $deleted === false ? 0 : (int) $deleted;
}