前台投稿页改为沉浸式创作工作台:Gutenberg 负责区块解析与序列化, React + TypeScript 负责工作台外壳与发布抽屉,Jio 服务端继续作为权限、 审核与付费的唯一权威。 - 新增 assets/creator-studio 独立构建(wp-scripts,锁定 WP 6.4 时代包), 仅在投稿模板加载,构建缺失或游客访问时回退原有 TinyMCE 表单 - 自动保存(4s 防抖 + rev 冲突 409)、本地快照恢复、发布配置抽屉、 移动端全屏单列与安全区适配 - 图片插入走现有 front-submit 上传管线,保留能力校验与待认领生命周期 - 已发布文章的改稿写入私有版本,审核通过前线上内容/链接/付费条款不变; 原生后台「改稿审核」一次性应用或退回,并校验源文章并发指纹 - 私有附件新表 jio_private_asset + jio_dl_token.asset_id,schema 升到 3.4.0; 文件存放 ABSPATH 之外,未配置私有存储时 fail closed - 下载令牌绑定文章/附件/用户/IP/UA/次数,每次交付复核 Visibility 的 订单、VIP 与配额判定;中断传输归还次数 - 付费字段改为写入前校验,非法价格不再留下半保存文章 - 新增 creator-studio 契约测试;更新受影响的 schema/下载/投稿契约 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
128 lines
6.4 KiB
JavaScript
128 lines
6.4 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* 工单表结构契约:
|
|
* 1) 6 张 jio_ticket* 表全部由 DBMigratorPart2 建出,且被 migrate_ticket_tables() 调用;
|
|
* 2) SCHEMA_VERSION 已升到当前版本(dbDelta 只在版本变化时重跑);
|
|
* 3) jio_order_aftersale 已从 schema 与表名映射中彻底移除;
|
|
* 4) 状态与部门 seeds 幂等,默认状态被标记为不可改删。
|
|
*/
|
|
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const test = require('node:test');
|
|
|
|
const root = path.resolve(__dirname, '../..');
|
|
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
|
|
|
const TABLES = [
|
|
'jio_ticket',
|
|
'jio_ticket_reply',
|
|
'jio_ticket_status',
|
|
'jio_ticket_type',
|
|
'jio_ticket_prereply',
|
|
'jio_ticket_note',
|
|
];
|
|
|
|
test('schema version is bumped so dbDelta re-runs', () => {
|
|
const migrator = read('src/Core/DBMigrator.php');
|
|
assert.match(migrator, /const SCHEMA_VERSION = '3\.4\.0';/, 'SCHEMA_VERSION must be bumped for new tables/columns');
|
|
assert.ok(migrator.includes('use DBMigratorTicket;'), 'DBMigratorTicket trait not composed');
|
|
// 版本注释要说明每次升版做了什么,否则后人看不懂升版原因。
|
|
assert.match(migrator, /3\.0\.0: 售后(order_aftersale)整体替换为工单系统/);
|
|
assert.match(migrator, /3\.1\.0: jio_ticket 新增 urge_count \/ last_urge_time/);
|
|
// 3.3.0 的品牌重命名(aksrt_ → jio_ 表前缀)也必须写进注释。
|
|
assert.match(migrator, /3\.3\.0: 主题品牌由 AKSRT 更名为 Jio/);
|
|
const ticket = read('src/Core/DBMigratorTicket.php');
|
|
assert.match(ticket, /urge_count INT UNSIGNED NOT NULL DEFAULT 0,/);
|
|
assert.match(ticket, /last_urge_time DATETIME DEFAULT NULL,/);
|
|
});
|
|
|
|
test('order_aftersale is gone from schema and the table map', () => {
|
|
const part2 = read('src/Core/DBMigratorPart2.php');
|
|
const part3 = read('src/Core/DBMigratorPart3.php');
|
|
|
|
assert.ok(!part2.includes('jio_order_aftersale'), 'order_aftersale CREATE must be removed');
|
|
assert.ok(!part3.includes("'order_aftersale'"), 'order_aftersale table map entry must be removed');
|
|
assert.ok(!part3.includes('jio_order_aftersale'), 'order_aftersale table name must be removed');
|
|
});
|
|
|
|
test('six ticket tables are declared and the migrator is invoked', () => {
|
|
const part2 = read('src/Core/DBMigratorPart2.php');
|
|
// Part2 只负责调用;DDL 集中在 DBMigratorTicket,避免 run() 继续膨胀。
|
|
assert.ok(part2.includes('self::migrate_ticket_tables($prefix, $charset_collate);'),
|
|
'migrate_ticket_tables() must be called from run()');
|
|
assert.ok(!part2.includes('jio_order_aftersale'), 'Part2 must not carry the old table');
|
|
|
|
const ticket = read('src/Core/DBMigratorTicket.php');
|
|
for (const table of TABLES) {
|
|
assert.ok(ticket.includes(`CREATE TABLE {$prefix}${table} (`), `${table} DDL missing in trait`);
|
|
}
|
|
assert.ok(ticket.includes('self::seed_ticket_statuses($prefix);'), 'status seeds not invoked');
|
|
assert.ok(ticket.includes('self::seed_ticket_types($prefix);'), 'type seeds not invoked');
|
|
});
|
|
|
|
test('ticket table map exposes all six names', () => {
|
|
const part3 = read('src/Core/DBMigratorPart3.php');
|
|
for (const name of ['ticket', 'ticket_reply', 'ticket_status', 'ticket_type', 'ticket_prereply', 'ticket_note']) {
|
|
assert.ok(part3.includes(`'${name}'`), `table map missing ${name}`);
|
|
assert.ok(part3.includes(`'jio_${name}'`), `table map missing jio_${name}`);
|
|
}
|
|
});
|
|
|
|
test('ticket columns cover the state machine and optional order link', () => {
|
|
const ticket = read('src/Core/DBMigratorTicket.php');
|
|
|
|
// 工单号对外唯一;状态是可自定义的 key;订单关联可空(默认空串)。
|
|
assert.match(ticket, /UNIQUE KEY uniq_ticket_num \(ticket_num\)/);
|
|
assert.match(ticket, /status VARCHAR\(24\) NOT NULL DEFAULT 'pending'/);
|
|
assert.match(ticket, /order_number VARCHAR\(32\) NOT NULL DEFAULT ''/);
|
|
assert.match(ticket, /KEY idx_order \(order_number\)/);
|
|
|
|
// 沟通记录独立成行,支持引用回复。
|
|
assert.match(ticket, /author_type VARCHAR\(16\) NOT NULL DEFAULT 'user'/);
|
|
assert.match(ticket, /quote_reply_id BIGINT UNSIGNED NOT NULL DEFAULT 0/);
|
|
|
|
// 状态表带完结/默认两个开关,默认状态不可改删由服务层兜住。
|
|
assert.match(ticket, /status_key VARCHAR\(24\) NOT NULL/);
|
|
assert.match(ticket, /UNIQUE KEY uniq_status_key \(status_key\)/);
|
|
assert.match(ticket, /is_closed TINYINT\(1\) NOT NULL DEFAULT 0/);
|
|
assert.match(ticket, /is_default TINYINT\(1\) NOT NULL DEFAULT 0/);
|
|
});
|
|
|
|
test('status and department seeds are idempotent', () => {
|
|
const ticket = read('src/Core/DBMigratorTicket.php');
|
|
|
|
// 按 key / name 查重后跳过,重复执行不会写重复行。
|
|
assert.match(ticket, /\$exists = \$wpdb->get_col\("SELECT status_key FROM \{\$table\}"\)/);
|
|
assert.match(ticket, /if \(in_array\(\$key, \$exists, true\)\) \{\s*\n\s*continue;/);
|
|
assert.match(ticket, /\$exists = \$wpdb->get_col\("SELECT name FROM \{\$table\}"\)/);
|
|
assert.match(ticket, /if \(in_array\(\$label, \$exists, true\)\) \{\s*\n\s*continue;/);
|
|
|
|
// 五个内置状态与五个部门。
|
|
for (const key of ['pending', 'handling', 'user_replied', 'admin_replied', 'closed']) {
|
|
assert.ok(ticket.includes(`['${key}',`), `status seed ${key} missing`);
|
|
}
|
|
for (const label of ['技术支持', '账号问题', '财务问题', '产品建议', '其他']) {
|
|
assert.ok(ticket.includes(`'${label}'`), `department seed ${label} missing`);
|
|
}
|
|
// 默认状态是「待接单」,且是完结态的补集。
|
|
assert.match(ticket, /\['pending', '待接单', '#f59e0b', 0, 1\]/);
|
|
assert.match(ticket, /\['closed', '已关闭', '#6b7280', 1, 0\]/);
|
|
});
|
|
|
|
test('TicketStatusService seeds mirror the migrator and guard the default row', () => {
|
|
const service = read('src/Ticket/TicketStatusService.php');
|
|
for (const key of ['pending', 'handling', 'user_replied', 'admin_replied', 'closed']) {
|
|
assert.ok(service.includes(`KEY_${key.toUpperCase()}`), `KEY_${key.toUpperCase()} missing`);
|
|
}
|
|
// 状态机只挡确定非法的组合,其余交给「目标状态必须存在」。
|
|
assert.match(service, /if \(\$from === self::KEY_PENDING && \$to === self::KEY_CLOSED\) \{\s*\n\s*return false;/);
|
|
assert.match(service, /if \(self::is_closed\(\$from\)\) \{\s*\n\s*return false;/);
|
|
// 颜色只接受 #rgb / #rrggbb。
|
|
assert.match(service, /'#6b7280'/);
|
|
assert.match(service, /\\Jio\\Core\\Color::sanitize\(\$color\)/);
|
|
assert.doesNotMatch(service, /preg_match\('\/\^#/);
|
|
});
|