refactor(stage10): ele / feedback 拆出 repository + model,并补契约测试

背景
- 这两个域把 SQL 与处理器放在同一文件,是全仓仅有的两个零接口测试的后端域。
  直接拆会改动 SQL 与参数顺序,而硬约束要求不得变更统计口径 —— 因此按
  "可注入化 -> 补契约测试 -> 再提取" 的顺序做。

改动
- ele:拆为 routes.ts(校验与组装)/ repository.ts(全部 SQL)/ model.ts(xlsx 解析、
  取值清洗、筛选片段、插入值组装等纯逻辑),并改为 registerEleRoutes(app, deps) 可注入。
- feedback:拆为 routes.ts / repository.ts,同样可注入;建表与截图上传也作为依赖注入。
- 两域各补测试:ele 7 个接口契约 + 6 个模型用例,feedback 9 个接口契约。
  mock pool 逐条断言 SQL 文本与参数顺序(含分页、状态白名单、批量插入的 30 列顺序)。
- 架构测试新增一条:已完整分层的三个域(vehicles / ele / feedback)必须有 repository.ts
  且 routes.ts 不得出现 SQL。

等价性验证(关键)
- 把改造前的实现从 git 取出,与改造后的实现跑同一批请求(含真实 xlsx 解析路径),
  对比每一步落库 SQL、参数、HTTP 状态与响应体:
    feedback:6 个场景,SQL + 参数 + 状态完全一致(差异仅 DDL 已移交 db/schema 层)
    ele     :5 个场景,SQL + 参数 + 状态 + 响应体完全一致(仅随机 batchId/时间戳做掩码)
- 期间的修正:曾把 /mine 与 /list 的列集合统一,二者实际不同(管理列表多 user_id/user_name),
  已按原样保留;测试同时锁定了这一差异。

lint / test(161) / build 全绿,可达性 0 未引用文件。
This commit is contained in:
dsh-agent
2026-09-11 10:36:05 +08:00
parent 795fb207cb
commit c199f031c5
10 changed files with 1252 additions and 482 deletions
+104
View File
@@ -0,0 +1,104 @@
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
/**
* 反馈表的全部 SQL 出口。
*
* SQL 与参数顺序在此固定:路由只做校验与组装,不再拼 SQL。
* 接口由 mock pool 的契约测试锁定(见 routes.test.ts)。
*/
/** 只需要 query 能力的最小依赖,便于在测试中注入 mock。 */
export interface Database {
query<T = any>(sql: string, values?: unknown[]): Promise<[T, ...any[]]>;
}
// 写入时间戳一律用东八区 CST,避免依赖 MySQL/容器时区设置。
const CST_NOW = `DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR)`;
// 两个列表接口的列集合刻意不同:管理列表额外返回 user_id / user_name。
const USER_LIST_COLUMNS = `id, type, module, content, contact, screenshots, status,
reply_content, reply_user, reply_at, created_at`;
const ADMIN_LIST_COLUMNS = `id, type, module, content, contact, screenshots, user_id, user_name, status,
reply_content, reply_user, reply_at, created_at`;
export interface NewFeedback {
type: string;
module: string | null;
content: string;
contact: string | null;
screenshotsJson: string;
userId: string | null;
userName: string | null;
userAgent: string | null;
}
export async function insertFeedback(db: Database, row: NewFeedback): Promise<number> {
const [result] = await db.query<ResultSetHeader>(
`INSERT INTO bi_user_feedback (type, module, content, contact, screenshots, user_id, user_name, user_agent, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ${CST_NOW})`,
[row.type, row.module, row.content, row.contact, row.screenshotsJson, row.userId, row.userName, row.userAgent],
);
return result.insertId;
}
/** 当前用户的反馈历史(最多 100 条)。 */
export async function listFeedbackForUser(db: Database, userId: string): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT ${USER_LIST_COLUMNS}
FROM bi_user_feedback
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 100`,
[userId],
);
return rows;
}
/** 管理列表:可选状态过滤 + 条数上限。 */
export async function listFeedbackForAdmin(
db: Database,
options: { status: string | null; limit: number },
): Promise<RowDataPacket[]> {
const where: string[] = ['1=1'];
const params: (string | number)[] = [];
if (options.status) {
where.push('status = ?');
params.push(options.status);
}
const [rows] = await db.query<RowDataPacket[]>(
`SELECT ${ADMIN_LIST_COLUMNS}
FROM bi_user_feedback
WHERE ${where.join(' AND ')}
ORDER BY created_at DESC
LIMIT ?`,
[...params, options.limit],
);
return rows;
}
export interface FeedbackUpdate {
status?: string;
/** 传 undefined 表示不更新回复;传 null/'' 表示清空。 */
reply?: string | null;
replyUser?: string | null;
}
/** 管理更新。返回 false 表示没有任何可更新字段。 */
export async function updateFeedback(db: Database, id: number, update: FeedbackUpdate): Promise<boolean> {
const fields: string[] = [];
const params: (string | number | null)[] = [];
if (update.status !== undefined) {
fields.push('status = ?');
params.push(update.status);
}
if (update.reply !== undefined) {
fields.push('reply_content = ?', 'reply_user = ?', `reply_at = ${CST_NOW}`);
params.push(update.reply || null, update.replyUser ?? null);
}
if (fields.length === 0) return false;
await db.query(
`UPDATE bi_user_feedback SET ${fields.join(', ')} WHERE id = ?`,
[...params, id],
);
return true;
}