feat(invite-code): admin page rebuilt as server-rendered three-view ops UI

对齐参考主题的邀请码运营页 UI/UX(布局与交互模式为自研实现):
- 三视图切换:列表 / 添加邀请码 / 导出邀请码(page-title-action 按钮)
- 列表:subsubsub 状态筛选(全部/未使用/已使用)、关键字搜索、批量删除、
  可排序表头(邀请码/创建时间/更新时间/状态/标识,orderby 箭头)、
  点击复制邀请码 + 复制成功浮层、标识点击定位、分页条
- 状态列:未使用(蓝)/已使用(红)+使用者链接,复用使用记录表
- 添加视图:添加方式按钮组(系统自动生成/导入邀请码)、生成数量(默认20/上限1000)、
  标识(默认 invita_YmdHis)、使用奖励字段集(经验值/积分/余额/VIP+赠送时长依赖显示)、
  高级选项折叠(自定义位数)、导入区格式说明与数据示例
- 导出视图:状态选择 + 文本文档/Excel表格 双格式 + 文本分隔符
- 传输层从 fetch+JSON 改为 admin_post 表单提交 + 回跳提示;
  管理端 REST 全部移除(保留公开 verify),死代码清理
- 建表新增 updated_at(消费/生成/导入维护),列表'更新时间'列与默认排序依据
- 契约测试 357→358 项固化新架构
This commit is contained in:
2026-09-21 21:31:16 +08:00
parent a5f0484466
commit 8d9cca2fe5
5 changed files with 605 additions and 431 deletions
@@ -19,8 +19,9 @@ test('invite code table has a unique code key and a usage log table', () => {
test('consumption is a single conditional UPDATE, not select-then-update', () => {
const service = read('src', 'Payment', 'InviteCode.php')
// TOCTOU 防线:消费必须是一条带条件的 UPDATE,且以 affected_rows === 1 判定。
assert.match(service, /SET used_count = used_count \+ 1 WHERE code = %s AND status = 'active' AND \(max_uses = 0 OR used_count < max_uses\)/)
// TOCTOU 防线:消费必须是一条带条件的 UPDATE,且以 affected_rows === 1 判定;
// 同语句维护 updated_at(列表"更新时间"列与排序依据)。
assert.match(service, /SET used_count = used_count \+ 1, updated_at = %s WHERE code = %s AND status = 'active' AND \(max_uses = 0 OR used_count < max_uses\)/)
assert.match(service, /\$updated !== 1/)
// 消费成功后写入使用记录(使用者视图)。
assert.match(service, /invite_code_usage/)
@@ -58,10 +59,20 @@ test('invite-code surfaces: form field, admin page, menus and both schemas', ()
assert.match(template, /name="invite_code"/)
assert.match(template, /get_invite_code_registration_settings/)
// 管理页为服务端渲染三视图(列表/添加/导出),通过 page-title-action 切换。
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php')
assert.match(page, /render_invite_codes_page/)
assert.match(page, /\$\{endpoint\}\/generate/)
assert.match(page, /_wpnonce/)
assert.match(page, /page-title-action/)
assert.match(page, /subsubsub/)
assert.match(page, /data-clipboard-text/)
assert.match(page, /orderby-but/)
assert.match(page, /bulk_action/)
assert.match(page, /aksrt_invite_delete/)
assert.match(page, /aksrt_invite_add/)
assert.match(page, /aksrt_invite_export/)
assert.match(page, /paginate_links/)
// 管理页不再依赖 REST 与 wp_rest nonce。
assert.doesNotMatch(page, /wp_rest|\$\{endpoint\}/)
const menus = read('src', 'Admin', 'Native', 'UsersMenus.php')
assert.match(menus, /use UsersMenusInviteCode/)
@@ -79,6 +90,30 @@ test('invite-code surfaces: form field, admin page, menus and both schemas', ()
}
})
test('admin operations run through admin_post handlers with capability and nonce guards', () => {
const service = read('src', 'Payment', 'InviteCode.php')
assert.match(service, /admin_post_aksrt_invite_add/)
assert.match(service, /admin_post_aksrt_invite_delete/)
assert.match(service, /admin_post_aksrt_invite_export/)
assert.match(service, /check_admin_referer\('aksrt_invite_admin', 'aksrt_nonce'\)/)
assert.match(service, /current_user_can\('edit_users'\)/)
// 管理端 REST 已移除:除公开 verify 外不再注册邀请码路由(导出文件名除外)。
assert.doesNotMatch(service, /register_rest_route\('aksrt\/v1', '\/invite-codes/)
assert.match(service, /register_rest_route\('aksrt\/v1', '\/invite-code\/verify'/)
// 列表/导出共用状态过滤;批量删除连使用记录一起清。
assert.match(service, /private function status_where\(string \$status\): array/)
assert.match(service, /'unused' => \['used_count = 0', \[\]\]/)
assert.match(service, /public function delete_codes\(array \$ids\): int/)
assert.match(service, /DELETE FROM \{\$usage_table\} WHERE code_id IN/)
// 列表支持状态/搜索/标识定位/排序/分页,默认按更新时间倒序。
assert.match(service, /\$orderby_map = \['code' => 'code', 'created_at' => 'created_at', 'updated_at' => 'updated_at', 'used_count' => 'used_count', 'remark' => 'remark'\]/)
assert.match(service, /\?\? 'updated_at'/)
assert.match(service, /'remark' => 'remark'/)
// 生成支持自定义位数(高级选项),单次上限 1000。
assert.match(service, /min\(1000, max\(1, \(int\) \(\$args\['count'\] \?\? 1\)\)\)/)
assert.match(service, /min\(50, max\(4, \(int\) \(\$args\['code_length'\] \?\? self::CODE_LENGTH\)\)\)/)
})
test('import accepts Zibll-compatible lines with per-code rewards', () => {
const service = read('src', 'Payment', 'InviteCode.php')
assert.match(service, /public function import_codes\(array \$args\)/)
@@ -90,17 +125,17 @@ test('import accepts Zibll-compatible lines with per-code rewards', () => {
assert.match(service, /array_slice\(preg_split\('\/\\r\\n\|\\r\|\\n\/', \$raw\) \?: \[\], 0, 1000\)/)
assert.match(service, /\$exists !== null/)
assert.match(service, /\$failed_codes\[\] = \$code/)
// REST 写入口 + 后台导入面板。
assert.match(service, /invite-codes\/import/)
// 导入走添加视图表单提交(add_type=import),textarea + 分隔符。
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php')
assert.match(page, /aksrt-inv-import-data/)
assert.match(page, /\$\{endpoint\}\/import/)
assert.match(page, /aksrt-inv-import-division/)
assert.match(page, /name="import_data"/)
assert.match(page, /name="import_division"/)
assert.match(page, /name="add_type"/)
})
test('per-code remark persists through generate/import and both exports round-trip', () => {
const schema = read('src', 'Core', 'DBMigratorPart2.php')
assert.match(schema, /remark\s+VARCHAR\(191\) NOT NULL DEFAULT ''/)
assert.match(schema, /updated_at\s+DATETIME NOT NULL DEFAULT/)
const service = read('src', 'Payment', 'InviteCode.php')
// 生成与导入都落 remark;导入第三段"标识"→备注。
@@ -111,16 +146,16 @@ test('per-code remark persists through generate/import and both exports round-tr
assert.match(service, /private function reward_param_string\(string \$rewards_json\): string/)
assert.match(service, /'balance=' \. /)
assert.match(service, /\$parts === \[\] \? '无'/)
// CSV 含 remark+奖励列;文本导出与导入格式互通。
assert.match(service, /code,batch_id,remark,rewards,max_uses/)
assert.match(service, /invite-codes\/export-text/)
// 文本导出与导入格式互通;CSV(Excel)含中文表头与状态列。
assert.match(service, /\$row\['code'\] \. \$division \. \$this->reward_param_string/)
assert.match(service, /__\('邀请码', 'aksrt'\), __\('使用奖励', 'aksrt'\)/)
assert.match(service, /\$used > 0 \? __\('已使用', 'aksrt'\) : __\('未使用', 'aksrt'\)/)
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php')
assert.match(page, /aksrt-inv-remark/)
assert.match(page, /remark: root\.querySelector\('#aksrt-inv-remark'\)/)
assert.match(page, /invite-codes\/export-text/)
assert.match(page, /item\.remark \|\| '—'/)
// 添加表单标识字段(默认 invita_YmdHis);列表标识列可点击定位。
assert.match(page, /name="auto_remarks"/)
assert.match(page, /invita_' \. current_time\('YmdHis'\)/)
assert.match(page, /add_query_arg\('remark', \$item\['remark'\], \$this_url\)/)
})
test('settings reader exposes normalized invite-code registration mode', () => {