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:
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { Hono } from "hono";
|
||||
import type { AuthUser } from "../../auth/types.js";
|
||||
import { registerFeedbackRoutes, type FeedbackDependencies } from "./routes.js";
|
||||
import type { Database } from "./repository.js";
|
||||
|
||||
interface Call {
|
||||
sql: string;
|
||||
params: unknown[];
|
||||
}
|
||||
|
||||
/** 规范化空格,便于对 SQL 文本做稳定断言(不改变语义)。 */
|
||||
const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim();
|
||||
|
||||
function createMockDb(resultSets: unknown[] = []): { db: Database; calls: Call[] } {
|
||||
const calls: Call[] = [];
|
||||
const db: Database = {
|
||||
async query(sql: string, params?: unknown[]) {
|
||||
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
|
||||
return [resultSets.shift() ?? [], []] as never;
|
||||
},
|
||||
};
|
||||
return { db, calls };
|
||||
}
|
||||
|
||||
const ADMIN: AuthUser = {
|
||||
userId: "u-admin",
|
||||
userName: "管理员",
|
||||
loginName: "admin",
|
||||
depCode: "",
|
||||
depName: "",
|
||||
permissionLevel: "full",
|
||||
roles: ["BI-ADMIN-FEEDBACK"],
|
||||
};
|
||||
|
||||
const NORMAL: AuthUser = {
|
||||
userId: "u-1",
|
||||
userName: "普通用户",
|
||||
loginName: "user1",
|
||||
depCode: "",
|
||||
depName: "",
|
||||
permissionLevel: "personal",
|
||||
roles: [],
|
||||
};
|
||||
|
||||
function makeApp(user: AuthUser | undefined, deps: FeedbackDependencies): Hono<{ Variables: { user: AuthUser } }> {
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
app.use("*", async (c, next) => {
|
||||
if (user) c.set("user", user);
|
||||
await next();
|
||||
});
|
||||
registerFeedbackRoutes(app as never, deps);
|
||||
return app;
|
||||
}
|
||||
|
||||
test("提交反馈:INSERT 语句与参数顺序保持不变", async () => {
|
||||
const { db, calls } = createMockDb([{ insertId: 42 }]);
|
||||
const app = makeApp(NORMAL, { db, ensureTable: async () => {} });
|
||||
|
||||
const res = await app.request("/submit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "bug",
|
||||
module: "mileage",
|
||||
content: " 车牌筛选失效 ",
|
||||
contact: "13800000000",
|
||||
screenshots: ["https://oss.example.com/a.png", "javascript:alert(1)", "not-a-url"],
|
||||
userAgent: "jest",
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(await res.json(), { ok: true, id: 42 });
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(
|
||||
calls[0].sql,
|
||||
"INSERT INTO bi_user_feedback (type, module, content, contact, screenshots, user_id, user_name, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR))",
|
||||
);
|
||||
assert.deepEqual(calls[0].params, [
|
||||
"bug",
|
||||
"mileage",
|
||||
"车牌筛选失效",
|
||||
"13800000000",
|
||||
JSON.stringify(["https://oss.example.com/a.png"]),
|
||||
"u-1",
|
||||
"普通用户",
|
||||
"jest",
|
||||
]);
|
||||
});
|
||||
|
||||
test("提交反馈:类型与内容长度校验拦截", async () => {
|
||||
const { db, calls } = createMockDb();
|
||||
const app = makeApp(NORMAL, { db, ensureTable: async () => {} });
|
||||
|
||||
const badType = await app.request("/submit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "nope", content: "x" }),
|
||||
});
|
||||
assert.equal(badType.status, 400);
|
||||
|
||||
const empty = await app.request("/submit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "bug", content: " " }),
|
||||
});
|
||||
assert.equal(empty.status, 400);
|
||||
assert.equal(calls.length, 0, "校验失败不应触达数据库");
|
||||
});
|
||||
|
||||
test("我的反馈:列集合不含 user_id/user_name,按当前用户过滤", async () => {
|
||||
const { db, calls } = createMockDb([[]]);
|
||||
const app = makeApp(NORMAL, { db, ensureTable: async () => {} });
|
||||
|
||||
const res = await app.request("/mine");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(
|
||||
calls[0].sql,
|
||||
"SELECT id, type, module, content, contact, screenshots, status, reply_content, reply_user, reply_at, created_at FROM bi_user_feedback WHERE user_id = ? ORDER BY created_at DESC LIMIT 100",
|
||||
);
|
||||
assert.deepEqual(calls[0].params, ["u-1"]);
|
||||
});
|
||||
|
||||
test("我的反馈:无登录用户时直接返回空列表且不查库", async () => {
|
||||
const { db, calls } = createMockDb();
|
||||
const app = makeApp(undefined, { db, ensureTable: async () => {} });
|
||||
|
||||
const res = await app.request("/mine");
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(await res.json(), { items: [] });
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("管理列表:无管理角色返回 403 且不查库", async () => {
|
||||
const { db, calls } = createMockDb();
|
||||
const app = makeApp(NORMAL, { db, ensureTable: async () => {} });
|
||||
|
||||
const res = await app.request("/list");
|
||||
assert.equal(res.status, 403);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("管理列表:列集合含 user_id/user_name,状态白名单与条数上限生效", async () => {
|
||||
const { db, calls } = createMockDb([[]]);
|
||||
const app = makeApp(ADMIN, { db, ensureTable: async () => {} });
|
||||
|
||||
await app.request("/list?status=done&limit=9999");
|
||||
assert.equal(
|
||||
calls[0].sql,
|
||||
"SELECT id, type, module, content, contact, screenshots, user_id, user_name, status, reply_content, reply_user, reply_at, created_at FROM bi_user_feedback WHERE 1=1 AND status = ? ORDER BY created_at DESC LIMIT ?",
|
||||
);
|
||||
assert.deepEqual(calls[0].params, ["done", 500], "limit 上限为 500");
|
||||
|
||||
calls.length = 0;
|
||||
await app.request("/list?status=not-a-status&limit=abc");
|
||||
assert.equal(
|
||||
calls[0].sql,
|
||||
"SELECT id, type, module, content, contact, screenshots, user_id, user_name, status, reply_content, reply_user, reply_at, created_at FROM bi_user_feedback WHERE 1=1 ORDER BY created_at DESC LIMIT ?",
|
||||
);
|
||||
assert.deepEqual(calls[0].params, [100], "非法状态被丢弃,limit 回退默认值");
|
||||
});
|
||||
|
||||
test("管理更新:状态与回复的字段顺序与参数顺序保持不变", async () => {
|
||||
const { db, calls } = createMockDb([{ affectedRows: 1 }]);
|
||||
const app = makeApp(ADMIN, { db, ensureTable: async () => {} });
|
||||
|
||||
const res = await app.request("/7", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "done", reply: " 已修复 " }),
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(
|
||||
calls[0].sql,
|
||||
"UPDATE bi_user_feedback SET status = ?, reply_content = ?, reply_user = ?, reply_at = DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR) WHERE id = ?",
|
||||
);
|
||||
assert.deepEqual(calls[0].params, ["done", "已修复", "管理员", 7]);
|
||||
});
|
||||
|
||||
test("管理更新:无可用字段或非法 id 返回 400", async () => {
|
||||
const { db, calls } = createMockDb();
|
||||
const app = makeApp(ADMIN, { db, ensureTable: async () => {} });
|
||||
|
||||
const none = await app.request("/7", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
assert.equal(none.status, 400);
|
||||
|
||||
const badId = await app.request("/0", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "done" }),
|
||||
});
|
||||
assert.equal(badId.status, 400);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("分层:路由文件不再直接书写 SQL", () => {
|
||||
const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "routes.ts"), "utf8");
|
||||
const offenders = source.match(/\b(SELECT|INSERT INTO|UPDATE |DELETE FROM)\b/g) ?? [];
|
||||
assert.deepEqual(offenders, [], "SQL 必须留在 repository.ts,路由只做校验与组装");
|
||||
});
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
|
||||
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';
|
||||
|
||||
const app = new Hono();
|
||||
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']);
|
||||
@@ -15,137 +19,140 @@ const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'rejected']);
|
||||
|
||||
const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
|
||||
|
||||
// 写入时间戳一律用东八区 CST,避免依赖 MySQL/容器时区设置
|
||||
const CST_NOW = `DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR)`;
|
||||
export interface FeedbackDependencies {
|
||||
db: Database;
|
||||
/** 建表(只读模式下由 schema 层自行跳过)。测试注入空实现。 */
|
||||
ensureTable: () => Promise<void>;
|
||||
/** 截图上传实现,测试可注入。 */
|
||||
uploadImage?: typeof uploadFeedbackImage;
|
||||
}
|
||||
|
||||
app.post('/submit', async (c) => {
|
||||
await ensureFeedbackTable();
|
||||
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);
|
||||
}
|
||||
function currentUser(c: { get?: (k: string) => unknown }): AuthUser | undefined {
|
||||
return c.get?.('user') as AuthUser | undefined;
|
||||
}
|
||||
|
||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||
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)
|
||||
: [];
|
||||
export function registerFeedbackRoutes(app: Hono, deps: FeedbackDependencies): void {
|
||||
const { db, ensureTable } = deps;
|
||||
const uploadImage = deps.uploadImage ?? uploadFeedbackImage;
|
||||
|
||||
const [r] = await pool.query<ResultSetHeader>(
|
||||
`INSERT INTO bi_user_feedback (type, module, content, contact, screenshots, user_id, user_name, user_agent, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ${CST_NOW})`,
|
||||
[type, moduleVal, content, contact, JSON.stringify(screenshots), user?.userId || null, user?.userName || null, userAgent],
|
||||
);
|
||||
return c.json({ ok: true, id: r.insertId });
|
||||
});
|
||||
// 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);
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// 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 uploadFeedbackImage(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);
|
||||
}
|
||||
});
|
||||
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)
|
||||
: [];
|
||||
|
||||
// GET /api/feedback/mine — 当前用户的反馈历史
|
||||
app.get('/mine', async (c) => {
|
||||
await ensureFeedbackTable();
|
||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||
if (!user?.userId) return c.json({ items: [] });
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT id, type, module, content, contact, screenshots, status,
|
||||
reply_content, reply_user, reply_at, created_at
|
||||
FROM bi_user_feedback
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100`,
|
||||
[user.userId],
|
||||
);
|
||||
return c.json({ items: rows });
|
||||
});
|
||||
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 });
|
||||
});
|
||||
|
||||
// GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
||||
app.get('/list', async (c) => {
|
||||
await ensureFeedbackTable();
|
||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||
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 status = c.req.query('status') || '';
|
||||
const where: string[] = ['1=1'];
|
||||
const params: (string | number)[] = [];
|
||||
if (VALID_STATUS.has(status)) {
|
||||
where.push('status = ?');
|
||||
params.push(status);
|
||||
}
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT id, type, module, content, contact, screenshots, user_id, user_name, status,
|
||||
reply_content, reply_user, reply_at, created_at
|
||||
FROM bi_user_feedback
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`,
|
||||
[...params, limit],
|
||||
);
|
||||
return c.json({ items: rows });
|
||||
});
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
||||
app.patch('/:id', async (c) => {
|
||||
await ensureFeedbackTable();
|
||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||
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 };
|
||||
const fields: string[] = [];
|
||||
const params: (string | number | null)[] = [];
|
||||
if (body.status) {
|
||||
if (!VALID_STATUS.has(body.status)) return c.json({ ok: false, message: '状态不合法' }, 400);
|
||||
fields.push('status = ?');
|
||||
params.push(body.status);
|
||||
}
|
||||
if (typeof body.reply === 'string') {
|
||||
const reply = body.reply.trim().slice(0, 2000);
|
||||
fields.push('reply_content = ?', 'reply_user = ?', `reply_at = ${CST_NOW}`);
|
||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||
params.push(reply || null, user?.userName || user?.userId || null);
|
||||
}
|
||||
if (fields.length === 0) return c.json({ ok: false, message: '没有可更新的字段' }, 400);
|
||||
params.push(id);
|
||||
await pool.query(`UPDATE bi_user_feedback SET ${fields.join(', ')} WHERE id = ?`, params);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
// 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;
|
||||
|
||||
Reference in New Issue
Block a user