背景
- 这两个域把 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 未引用文件。
159 lines
5.8 KiB
TypeScript
159 lines
5.8 KiB
TypeScript
import { Hono } from 'hono';
|
||
import pool from '../../db/mysql.js';
|
||
import { ensureFeedbackTable } from '../../db/schema/feedback.js';
|
||
import type { AuthUser } from '../../auth/types.js';
|
||
import { canManageFeedback } from '../../auth/types.js';
|
||
import { uploadFeedbackImage } from './oss.js';
|
||
import {
|
||
insertFeedback,
|
||
listFeedbackForAdmin,
|
||
listFeedbackForUser,
|
||
updateFeedback,
|
||
type Database,
|
||
} from './repository.js';
|
||
|
||
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
||
|
||
const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'rejected']);
|
||
|
||
const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
|
||
|
||
export interface FeedbackDependencies {
|
||
db: Database;
|
||
/** 建表(只读模式下由 schema 层自行跳过)。测试注入空实现。 */
|
||
ensureTable: () => Promise<void>;
|
||
/** 截图上传实现,测试可注入。 */
|
||
uploadImage?: typeof uploadFeedbackImage;
|
||
}
|
||
|
||
function currentUser(c: { get?: (k: string) => unknown }): AuthUser | undefined {
|
||
return c.get?.('user') as AuthUser | undefined;
|
||
}
|
||
|
||
export function registerFeedbackRoutes(app: Hono, deps: FeedbackDependencies): void {
|
||
const { db, ensureTable } = deps;
|
||
const uploadImage = deps.uploadImage ?? uploadFeedbackImage;
|
||
|
||
// POST /api/feedback/submit — 提交反馈
|
||
app.post('/submit', async (c) => {
|
||
await ensureTable();
|
||
const body = await c.req.json().catch(() => ({})) as {
|
||
type?: string; module?: string | null; content?: string;
|
||
contact?: string | null; userAgent?: string; screenshots?: string[];
|
||
};
|
||
const type = (body.type || '').trim();
|
||
const content = (body.content || '').trim();
|
||
if (!VALID_TYPES.has(type)) {
|
||
return c.json({ ok: false, message: '类型不合法' }, 400);
|
||
}
|
||
if (!content || content.length > 2000) {
|
||
return c.json({ ok: false, message: '内容长度需在 1-2000 字之间' }, 400);
|
||
}
|
||
|
||
const user = currentUser(c);
|
||
const moduleVal = (body.module || '').slice(0, 64) || null;
|
||
const contact = (body.contact || '').slice(0, 200) || null;
|
||
const userAgent = (body.userAgent || '').slice(0, 512) || null;
|
||
const screenshots = Array.isArray(body.screenshots)
|
||
? body.screenshots.filter(s => typeof s === 'string' && /^https?:\/\//.test(s)).slice(0, 6)
|
||
: [];
|
||
|
||
const id = await insertFeedback(db, {
|
||
type,
|
||
module: moduleVal,
|
||
content,
|
||
contact,
|
||
screenshotsJson: JSON.stringify(screenshots),
|
||
userId: user?.userId || null,
|
||
userName: user?.userName || null,
|
||
userAgent,
|
||
});
|
||
return c.json({ ok: true, id });
|
||
});
|
||
|
||
// POST /api/feedback/upload — 单张截图上传(multipart/form-data, field=file)
|
||
app.post('/upload', async (c) => {
|
||
const form = await c.req.formData();
|
||
const file = form.get('file');
|
||
if (!(file instanceof File)) {
|
||
return c.json({ ok: false, message: '未上传文件' }, 400);
|
||
}
|
||
const mime = file.type || 'image/png';
|
||
if (!ALLOWED_MIME.has(mime)) {
|
||
return c.json({ ok: false, message: `不支持的文件类型:${mime}` }, 400);
|
||
}
|
||
if (file.size > MAX_IMAGE_SIZE) {
|
||
return c.json({ ok: false, message: `图片过大(${(file.size / 1024 / 1024).toFixed(1)}MB)`}, 400);
|
||
}
|
||
const buf = Buffer.from(await file.arrayBuffer());
|
||
try {
|
||
const url = await uploadImage(file.name || 'screenshot.png', buf, mime);
|
||
return c.json({ ok: true, url });
|
||
} catch (e) {
|
||
console.error('feedback upload error:', e);
|
||
return c.json({ ok: false, message: e instanceof Error ? e.message : '上传失败' }, 500);
|
||
}
|
||
});
|
||
|
||
// GET /api/feedback/mine — 当前用户的反馈历史
|
||
app.get('/mine', async (c) => {
|
||
await ensureTable();
|
||
const user = currentUser(c);
|
||
if (!user?.userId) return c.json({ items: [] });
|
||
return c.json({ items: await listFeedbackForUser(db, user.userId) });
|
||
});
|
||
|
||
// GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
||
app.get('/list', async (c) => {
|
||
await ensureTable();
|
||
const user = currentUser(c);
|
||
if (!canManageFeedback(user?.roles)) {
|
||
return c.json({ ok: false, message: '无权限' }, 403);
|
||
}
|
||
const limit = Math.min(500, Math.max(1, Number(c.req.query('limit')) || 100));
|
||
const rawStatus = c.req.query('status') || '';
|
||
const status = VALID_STATUS.has(rawStatus) ? rawStatus : null;
|
||
return c.json({ items: await listFeedbackForAdmin(db, { status, limit }) });
|
||
});
|
||
|
||
// PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
||
app.patch('/:id', async (c) => {
|
||
await ensureTable();
|
||
const user = currentUser(c);
|
||
if (!canManageFeedback(user?.roles)) {
|
||
return c.json({ ok: false, message: '无权限' }, 403);
|
||
}
|
||
const id = Number(c.req.param('id'));
|
||
if (!Number.isFinite(id) || id <= 0) return c.json({ ok: false, message: 'id 不合法' }, 400);
|
||
const body = await c.req.json().catch(() => ({})) as { status?: string; reply?: string };
|
||
|
||
let status: string | undefined;
|
||
if (body.status) {
|
||
if (!VALID_STATUS.has(body.status)) return c.json({ ok: false, message: '状态不合法' }, 400);
|
||
status = body.status;
|
||
}
|
||
const reply = typeof body.reply === 'string'
|
||
? body.reply.trim().slice(0, 2000)
|
||
: undefined;
|
||
|
||
const updated = await updateFeedback(db, id, {
|
||
status,
|
||
reply,
|
||
replyUser: user?.userName || user?.userId || null,
|
||
});
|
||
if (!updated) return c.json({ ok: false, message: '没有可更新的字段' }, 400);
|
||
return c.json({ ok: true });
|
||
});
|
||
}
|
||
|
||
/** 生产用路由器:绑定真实连接池与建表函数。 */
|
||
export function createFeedbackRouter(): Hono {
|
||
const app = new Hono();
|
||
registerFeedbackRoutes(app, { db: pool, ensureTable: ensureFeedbackTable });
|
||
return app;
|
||
}
|
||
|
||
const app = createFeedbackRouter();
|
||
export default app;
|