refactor(stage8): 建表语句集中到 server/db/schema,并修复只读模式的漏写
问题
- 4 处运行时 DDL(scheduling / ele / feedback / mileage 日报)分散在路由与 store 里;
- 只读保护只按 HTTP 方法拦截,而建表由 GET 处理器触发,
于是 DB_READ_ONLY=1 的"只读预览"仍可能执行 CREATE TABLE / ALTER TABLE(已记录在案的漏洞)。
改动
- 新增 src/server/db/schema/{guard,scheduling,ele,feedback,mileage-report}.ts:
建表/改表的唯一位置;guard 提供 ddlAllowed(),DB_READ_ONLY=1 时每个 ensure* 直接跳过并打印一行日志。
- daily-report-store 的表名改为从 schema 模块导出,避免两处各写一份表名。
- 移除 feedback/index.ts 与 ele/migration.ts 中的内联 DDL。
验证
- 实测:DB_READ_ONLY=1 下依次调用 4 个 ensure*,全部跳过且未触碰数据库(无连接错误/挂起)。
- 架构测试新增 2 条:DDL 只允许出现在 server/db/schema;每个 schema 模块必须检查 ddlAllowed()。
lint / test(139) / build 全绿。
This commit is contained in:
@@ -79,6 +79,7 @@ server/
|
|||||||
local.ts 只读预览启动(不建表、不定时任务)
|
local.ts 只读预览启动(不建表、不定时任务)
|
||||||
bootstrap.ts 启动副作用总入口
|
bootstrap.ts 启动副作用总入口
|
||||||
db/ mysql / hydrogen / heatmap 连接
|
db/ mysql / hydrogen / heatmap 连接
|
||||||
|
db/schema/ 运行时建表/改表的唯一位置(只读模式下整体跳过)
|
||||||
middleware/ auth(JWT → 注入 user)、read-only
|
middleware/ auth(JWT → 注入 user)、read-only
|
||||||
auth/ 登录换票、固定密码、权限过滤与脱敏
|
auth/ 登录换票、固定密码、权限过滤与脱敏
|
||||||
routes/ 按业务域的 HTTP 路由
|
routes/ 按业务域的 HTTP 路由
|
||||||
@@ -119,9 +120,9 @@ cors → read-only → /api/auth(公开) → authMiddleware → 各业务域
|
|||||||
|
|
||||||
- **后端路由尚未全部拆成 routes / service / repository**。`routes/vehicles/` 已有
|
- **后端路由尚未全部拆成 routes / service / repository**。`routes/vehicles/` 已有
|
||||||
`repository.ts` + `model.ts` 的雏形,其余域仍是单文件路由;拆分时不要改变 SQL 与参数顺序。
|
`repository.ts` + `model.ts` 的雏形,其余域仍是单文件路由;拆分时不要改变 SQL 与参数顺序。
|
||||||
- **运行时建表仍分散在业务代码里**:`ele`、`feedback`、`mileage` 在首次调用时执行
|
- **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护,
|
||||||
`CREATE TABLE`,而 `read-only` 中间件只按 HTTP 方法拦截,所以只读预览下 GET 仍可能触发建表。
|
已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用——
|
||||||
应收敛到显式的迁移函数并只由 `bootstrap.ts` 调用。
|
要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。
|
||||||
- **`src/lib/cn.ts` 刻意不引入 `tailwind-merge`**:全仓只有 2 处调用,为此新增一个
|
- **`src/lib/cn.ts` 刻意不引入 `tailwind-merge`**:全仓只有 2 处调用,为此新增一个
|
||||||
传递依赖不划算。若将来条件类覆盖变多,再换 `clsx` + `tailwind-merge`。
|
传递依赖不划算。若将来条件类覆盖变多,再换 `clsx` + `tailwind-merge`。
|
||||||
- **前端 `dist` 体积**:氢能看板单个 chunk 约 200 kB(gzip 50 kB),来自原型渲染方式(大量内联样式)。
|
- **前端 `dist` 体积**:氢能看板单个 chunk 约 200 kB(gzip 50 kB),来自原型渲染方式(大量内联样式)。
|
||||||
@@ -132,7 +133,9 @@ cors → read-only → /api/auth(公开) → authMiddleware → 各业务域
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Excel 导出(拼装与写出) | `src/shared/xlsx.ts` | 架构测试:`modules/**` 不得再出现 `book_new` / `book_append_sheet` / `writeFile(` |
|
| Excel 导出(拼装与写出) | `src/shared/xlsx.ts` | 架构测试:`modules/**` 不得再出现 `book_new` / `book_append_sheet` / `writeFile(` |
|
||||||
| 高德 JSAPI 加载与底图 | `src/shared/amap.ts` | 架构测试:只有它可 import `@amap/amap-jsapi-loader` |
|
| 高德 JSAPI 加载与底图 | `src/shared/amap.ts` | 架构测试:只有它可 import `@amap/amap-jsapi-loader` |
|
||||||
| 自然日区间(近 N 天/快捷区间) | `src/shared/date-range.ts`、`modules/energy/daily-range/model.ts` | `date-range` 无独立测试;改动请补 |
|
| 运行时 DDL(建表/改表) | `src/server/db/schema/*` | 架构测试:其他 server 文件不得出现 `CREATE TABLE` / `ALTER TABLE`;每个 schema 模块必须检查 `ddlAllowed()` |
|
||||||
|
| 自然日区间(近 N 天/快捷区间) | `src/shared/date-range.ts`、`modules/energy/daily-range/model.ts` | `date-range` 暂无独立测试;改动请补 |
|
||||||
| 氢能数值格式化 | `modules/energy/hydrogen/model/display-format.ts` | `display-format.test.ts` |
|
| 氢能数值格式化 | `modules/energy/hydrogen/model/display-format.ts` | `display-format.test.ts` |
|
||||||
| 角色常量与模块可见性 | `src/shared/auth/roles.ts` | `app/modules.test.ts` |
|
| 角色常量与模块可见性 | `src/shared/auth/roles.ts` | `app/modules.test.ts` |
|
||||||
|
| 环境变量读取与校验 | `src/server/config.ts` | `auth/password.test.ts` 覆盖密码模式;JWT 缺失拒绝启动已实测 |
|
||||||
|
|
||||||
|
|||||||
@@ -145,3 +145,21 @@ test("类型豁免清单只减不增:@ts-nocheck 仅限已登记的 8113 原
|
|||||||
const actual = allFiles.filter((f) => fileContains(f, /^\/\/\s*@ts-nocheck/m)).map(rel).sort();
|
const actual = allFiles.filter((f) => fileContains(f, /^\/\/\s*@ts-nocheck/m)).map(rel).sort();
|
||||||
assert.deepEqual(actual, [...allowed].sort(), "新增 @ts-nocheck 会让类型门禁失效,请改为修正类型");
|
assert.deepEqual(actual, [...allowed].sort(), "新增 @ts-nocheck 会让类型门禁失效,请改为修正类型");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("运行时 DDL 只允许出现在 server/db/schema", () => {
|
||||||
|
// 建表原先散落在路由与 store 里,且由 GET 触发,使"只读预览"仍可能写库。
|
||||||
|
const offenders = allFiles
|
||||||
|
.filter((f) => rel(f).startsWith("server/") && !rel(f).startsWith("server/db/schema/"))
|
||||||
|
.filter((f) => !rel(f).endsWith(".test.ts"))
|
||||||
|
.filter((f) => /CREATE TABLE|ALTER TABLE/.test(readFileSync(f, "utf8")))
|
||||||
|
.map(rel);
|
||||||
|
assert.deepEqual(offenders, [], "请把建表/改表语句集中到 src/server/db/schema/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("运行时建表必须尊重只读模式", () => {
|
||||||
|
const schemaFiles = allFiles.filter((f) => rel(f).startsWith("server/db/schema/") && rel(f) !== "server/db/schema/guard.ts");
|
||||||
|
const offenders = schemaFiles
|
||||||
|
.filter((f) => !readFileSync(f, "utf8").includes("ddlAllowed()"))
|
||||||
|
.map(rel);
|
||||||
|
assert.deepEqual(offenders, [], "每个 schema 模块都要在 DB_READ_ONLY=1 时跳过 DDL");
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
|
import { ensureSchedulingTables } from './db/schema/scheduling.js';
|
||||||
import { startMileageBackgroundJobs } from './routes/mileage/index.js';
|
import { startMileageBackgroundJobs } from './routes/mileage/index.js';
|
||||||
|
|
||||||
/** 启动只应在服务进程中运行的数据库准备和定时任务。 */
|
/** 启动只应在服务进程中运行的数据库准备和定时任务。 */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pool from '../../db/mysql.js';
|
import pool from '../mysql.js';
|
||||||
|
import { ddlAllowed, logDdlSkipped } from './guard.js';
|
||||||
|
|
||||||
const CREATE_TABLE_SQL = `
|
const CREATE_TABLE_SQL = `
|
||||||
CREATE TABLE IF NOT EXISTS bi_ele_charge_record (
|
CREATE TABLE IF NOT EXISTS bi_ele_charge_record (
|
||||||
@@ -44,6 +45,7 @@ CREATE TABLE IF NOT EXISTS bi_ele_charge_record (
|
|||||||
let ensured = false;
|
let ensured = false;
|
||||||
export async function ensureChargeRecordTable(): Promise<void> {
|
export async function ensureChargeRecordTable(): Promise<void> {
|
||||||
if (ensured) return;
|
if (ensured) return;
|
||||||
|
if (!ddlAllowed()) return logDdlSkipped('bi_ele_charge_record');
|
||||||
await pool.query(CREATE_TABLE_SQL);
|
await pool.query(CREATE_TABLE_SQL);
|
||||||
ensured = true;
|
ensured = true;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import pool from '../../db/mysql.js';
|
||||||
|
import { ddlAllowed, logDdlSkipped } from './guard.js';
|
||||||
|
|
||||||
|
const CREATE_TABLE_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS bi_user_feedback (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
type ENUM('dimension','bug','ux','other') NOT NULL DEFAULT 'other',
|
||||||
|
module VARCHAR(64) NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
contact VARCHAR(200) NULL,
|
||||||
|
screenshots JSON NULL,
|
||||||
|
user_id VARCHAR(64) NULL,
|
||||||
|
user_name VARCHAR(128) NULL,
|
||||||
|
user_agent VARCHAR(512) NULL,
|
||||||
|
status ENUM('open','in_progress','done','rejected') NOT NULL DEFAULT 'open',
|
||||||
|
reply_content TEXT NULL,
|
||||||
|
reply_user VARCHAR(128) NULL,
|
||||||
|
reply_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
KEY idx_created_at (created_at),
|
||||||
|
KEY idx_type (type),
|
||||||
|
KEY idx_status (status),
|
||||||
|
KEY idx_user_id (user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`;
|
||||||
|
|
||||||
|
let ensured = false;
|
||||||
|
|
||||||
|
export async function ensureFeedbackTable(): Promise<void> {
|
||||||
|
if (ensured) return;
|
||||||
|
if (!ddlAllowed()) return logDdlSkipped('bi_user_feedback');
|
||||||
|
await pool.query(CREATE_TABLE_SQL);
|
||||||
|
// 兼容旧表:补齐缺失列
|
||||||
|
for (const alter of [
|
||||||
|
`ALTER TABLE bi_user_feedback ADD COLUMN screenshots JSON NULL AFTER contact`,
|
||||||
|
`ALTER TABLE bi_user_feedback ADD COLUMN reply_content TEXT NULL AFTER status`,
|
||||||
|
`ALTER TABLE bi_user_feedback ADD COLUMN reply_user VARCHAR(128) NULL AFTER reply_content`,
|
||||||
|
`ALTER TABLE bi_user_feedback ADD COLUMN reply_at DATETIME NULL AFTER reply_user`,
|
||||||
|
`ALTER TABLE bi_user_feedback ADD INDEX idx_user_id (user_id)`,
|
||||||
|
]) {
|
||||||
|
try { await pool.query(alter); } catch { /* 已存在则忽略 */ }
|
||||||
|
}
|
||||||
|
ensured = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { isDbReadOnly } from '../../config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行时建表守卫。
|
||||||
|
*
|
||||||
|
* 只读预览(DB_READ_ONLY=1)下不允许任何 DDL。此前只读保护只按 HTTP 方法拦截,
|
||||||
|
* 而建表实际由 GET 处理器触发,于是"只读"预览仍可能执行 CREATE TABLE。
|
||||||
|
*/
|
||||||
|
export function ddlAllowed(): boolean {
|
||||||
|
return !isDbReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一的跳过日志,避免每个 schema 模块各写一份。 */
|
||||||
|
export function logDdlSkipped(name: string): void {
|
||||||
|
console.log(`[schema] ${name} skipped (DB_READ_ONLY=1)`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import pool from '../mysql.js';
|
||||||
|
import { ddlAllowed, logDdlSkipped } from './guard.js';
|
||||||
|
|
||||||
|
/** 归档快照表:跨 schema,需与主库账号权限一致。 */
|
||||||
|
export const DAILY_REPORT_TABLE = 'lingniu_prod.tab_mileage_daily_report';
|
||||||
|
|
||||||
|
const CREATE_TABLE_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS ${DAILY_REPORT_TABLE} (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
report_date DATE NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'ARCHIVED',
|
||||||
|
source VARCHAR(20) NOT NULL,
|
||||||
|
vehicle_count INT NOT NULL DEFAULT 0,
|
||||||
|
operating_count INT NOT NULL DEFAULT 0,
|
||||||
|
inventory_count INT NOT NULL DEFAULT 0,
|
||||||
|
daily_mileage DECIMAL(14,2) NOT NULL DEFAULT 0,
|
||||||
|
day_over_day_rate DECIMAL(8,2) NULL,
|
||||||
|
report_payload JSON NOT NULL,
|
||||||
|
generated_at DATETIME NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_report_date (report_date),
|
||||||
|
KEY idx_generated_at (generated_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='里程每日汇报归档快照'
|
||||||
|
`;
|
||||||
|
|
||||||
|
let ensured = false;
|
||||||
|
|
||||||
|
export async function ensureDailyReportTable(): Promise<void> {
|
||||||
|
if (ensured) return;
|
||||||
|
if (!ddlAllowed()) return logDdlSkipped('tab_mileage_daily_report');
|
||||||
|
await pool.query(CREATE_TABLE_SQL);
|
||||||
|
ensured = true;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import pool from '../../db/mysql.js';
|
import pool from '../mysql.js';
|
||||||
|
import { ddlAllowed, logDdlSkipped } from './guard.js';
|
||||||
|
|
||||||
const CREATE_NOTIFICATIONS_TABLE = `
|
const CREATE_NOTIFICATIONS_TABLE = `
|
||||||
CREATE TABLE IF NOT EXISTS tab_scheduling_notifications (
|
CREATE TABLE IF NOT EXISTS tab_scheduling_notifications (
|
||||||
@@ -24,6 +25,7 @@ CREATE TABLE IF NOT EXISTS tab_scheduling_notifications (
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export async function ensureSchedulingTables(): Promise<void> {
|
export async function ensureSchedulingTables(): Promise<void> {
|
||||||
|
if (!ddlAllowed()) return logDdlSkipped('scheduling tables');
|
||||||
try {
|
try {
|
||||||
await pool.query(CREATE_NOTIFICATIONS_TABLE);
|
await pool.query(CREATE_NOTIFICATIONS_TABLE);
|
||||||
console.log('[scheduling] notifications table ready');
|
console.log('[scheduling] notifications table ready');
|
||||||
@@ -4,7 +4,7 @@ import * as XLSX from 'xlsx';
|
|||||||
import pool from '../../db/mysql.js';
|
import pool from '../../db/mysql.js';
|
||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
import { canAccessEnergy } from '../../auth/types.js';
|
import { canAccessEnergy } from '../../auth/types.js';
|
||||||
import { ensureChargeRecordTable } from './migration.js';
|
import { ensureChargeRecordTable } from '../../db/schema/ele.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
|
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
|
||||||
import pool from '../../db/mysql.js';
|
import pool from '../../db/mysql.js';
|
||||||
|
import { ensureFeedbackTable } from '../../db/schema/feedback.js';
|
||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
import { canManageFeedback } from '../../auth/types.js';
|
import { canManageFeedback } from '../../auth/types.js';
|
||||||
import { uploadFeedbackImage } from './oss.js';
|
import { uploadFeedbackImage } from './oss.js';
|
||||||
@@ -10,46 +11,6 @@ const app = new Hono();
|
|||||||
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||||
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
||||||
|
|
||||||
const CREATE_TABLE_SQL = `
|
|
||||||
CREATE TABLE IF NOT EXISTS bi_user_feedback (
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
type ENUM('dimension','bug','ux','other') NOT NULL DEFAULT 'other',
|
|
||||||
module VARCHAR(64) NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
contact VARCHAR(200) NULL,
|
|
||||||
screenshots JSON NULL,
|
|
||||||
user_id VARCHAR(64) NULL,
|
|
||||||
user_name VARCHAR(128) NULL,
|
|
||||||
user_agent VARCHAR(512) NULL,
|
|
||||||
status ENUM('open','in_progress','done','rejected') NOT NULL DEFAULT 'open',
|
|
||||||
reply_content TEXT NULL,
|
|
||||||
reply_user VARCHAR(128) NULL,
|
|
||||||
reply_at DATETIME NULL,
|
|
||||||
created_at DATETIME NOT NULL,
|
|
||||||
KEY idx_created_at (created_at),
|
|
||||||
KEY idx_type (type),
|
|
||||||
KEY idx_status (status),
|
|
||||||
KEY idx_user_id (user_id)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
||||||
`;
|
|
||||||
|
|
||||||
let ensured = false;
|
|
||||||
async function ensureTable(): Promise<void> {
|
|
||||||
if (ensured) return;
|
|
||||||
await pool.query(CREATE_TABLE_SQL);
|
|
||||||
// 兼容旧表:补齐缺失列
|
|
||||||
for (const alter of [
|
|
||||||
`ALTER TABLE bi_user_feedback ADD COLUMN screenshots JSON NULL AFTER contact`,
|
|
||||||
`ALTER TABLE bi_user_feedback ADD COLUMN reply_content TEXT NULL AFTER status`,
|
|
||||||
`ALTER TABLE bi_user_feedback ADD COLUMN reply_user VARCHAR(128) NULL AFTER reply_content`,
|
|
||||||
`ALTER TABLE bi_user_feedback ADD COLUMN reply_at DATETIME NULL AFTER reply_user`,
|
|
||||||
`ALTER TABLE bi_user_feedback ADD INDEX idx_user_id (user_id)`,
|
|
||||||
]) {
|
|
||||||
try { await pool.query(alter); } catch { /* 已存在则忽略 */ }
|
|
||||||
}
|
|
||||||
ensured = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'rejected']);
|
const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'rejected']);
|
||||||
|
|
||||||
const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
|
const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
|
||||||
@@ -58,7 +19,7 @@ const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
|
|||||||
const CST_NOW = `DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR)`;
|
const CST_NOW = `DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR)`;
|
||||||
|
|
||||||
app.post('/submit', async (c) => {
|
app.post('/submit', async (c) => {
|
||||||
await ensureTable();
|
await ensureFeedbackTable();
|
||||||
const body = await c.req.json().catch(() => ({})) as {
|
const body = await c.req.json().catch(() => ({})) as {
|
||||||
type?: string; module?: string | null; content?: string;
|
type?: string; module?: string | null; content?: string;
|
||||||
contact?: string | null; userAgent?: string; screenshots?: string[];
|
contact?: string | null; userAgent?: string; screenshots?: string[];
|
||||||
@@ -116,7 +77,7 @@ app.post('/upload', async (c) => {
|
|||||||
|
|
||||||
// GET /api/feedback/mine — 当前用户的反馈历史
|
// GET /api/feedback/mine — 当前用户的反馈历史
|
||||||
app.get('/mine', async (c) => {
|
app.get('/mine', async (c) => {
|
||||||
await ensureTable();
|
await ensureFeedbackTable();
|
||||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||||
if (!user?.userId) return c.json({ items: [] });
|
if (!user?.userId) return c.json({ items: [] });
|
||||||
const [rows] = await pool.query<RowDataPacket[]>(
|
const [rows] = await pool.query<RowDataPacket[]>(
|
||||||
@@ -133,7 +94,7 @@ app.get('/mine', async (c) => {
|
|||||||
|
|
||||||
// GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
// GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
||||||
app.get('/list', async (c) => {
|
app.get('/list', async (c) => {
|
||||||
await ensureTable();
|
await ensureFeedbackTable();
|
||||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||||
if (!canManageFeedback(user?.roles)) {
|
if (!canManageFeedback(user?.roles)) {
|
||||||
return c.json({ ok: false, message: '无权限' }, 403);
|
return c.json({ ok: false, message: '无权限' }, 403);
|
||||||
@@ -160,7 +121,7 @@ app.get('/list', async (c) => {
|
|||||||
|
|
||||||
// PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
// PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
||||||
app.patch('/:id', async (c) => {
|
app.patch('/:id', async (c) => {
|
||||||
await ensureTable();
|
await ensureFeedbackTable();
|
||||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||||
if (!canManageFeedback(user?.roles)) {
|
if (!canManageFeedback(user?.roles)) {
|
||||||
return c.json({ ok: false, message: '无权限' }, 403);
|
return c.json({ ok: false, message: '无权限' }, 403);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
|
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
|
||||||
import pool from '../../db/mysql.js';
|
import pool from '../../db/mysql.js';
|
||||||
|
import { ensureDailyReportTable, DAILY_REPORT_TABLE as TABLE } from '../../db/schema/mileage-report.js';
|
||||||
import type {
|
import type {
|
||||||
DailyMileageReport,
|
DailyMileageReport,
|
||||||
MileageReportHistoryItem,
|
MileageReportHistoryItem,
|
||||||
@@ -7,36 +8,6 @@ import type {
|
|||||||
MileageReportSource,
|
MileageReportSource,
|
||||||
} from '../../../shared/mileage/daily-report.js';
|
} from '../../../shared/mileage/daily-report.js';
|
||||||
|
|
||||||
const TABLE = 'lingniu_prod.tab_mileage_daily_report';
|
|
||||||
|
|
||||||
const CREATE_TABLE_SQL = `
|
|
||||||
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
|
||||||
report_date DATE NOT NULL,
|
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'ARCHIVED',
|
|
||||||
source VARCHAR(20) NOT NULL,
|
|
||||||
vehicle_count INT NOT NULL DEFAULT 0,
|
|
||||||
operating_count INT NOT NULL DEFAULT 0,
|
|
||||||
inventory_count INT NOT NULL DEFAULT 0,
|
|
||||||
daily_mileage DECIMAL(14,2) NOT NULL DEFAULT 0,
|
|
||||||
day_over_day_rate DECIMAL(8,2) NULL,
|
|
||||||
report_payload JSON NOT NULL,
|
|
||||||
generated_at DATETIME NOT NULL,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY uk_report_date (report_date),
|
|
||||||
KEY idx_generated_at (generated_at)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='里程每日汇报归档快照'
|
|
||||||
`;
|
|
||||||
|
|
||||||
let ensured = false;
|
|
||||||
|
|
||||||
export async function ensureDailyReportTable(): Promise<void> {
|
|
||||||
if (ensured) return;
|
|
||||||
await pool.query(CREATE_TABLE_SQL);
|
|
||||||
ensured = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePayload(value: unknown): DailyMileageReport | null {
|
function parsePayload(value: unknown): DailyMileageReport | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
if (typeof value === 'object') return value as DailyMileageReport;
|
if (typeof value === 'object') return value as DailyMileageReport;
|
||||||
|
|||||||
Reference in New Issue
Block a user