Files
ln-bi/src/server/routes/feedback/routes.ts
T
dsh-agent c199f031c5 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 未引用文件。
2026-09-11 10:36:05 +08:00

159 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;