diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b82c73a..fc9cf02 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -79,6 +79,7 @@ server/ local.ts 只读预览启动(不建表、不定时任务) bootstrap.ts 启动副作用总入口 db/ mysql / hydrogen / heatmap 连接 + db/schema/ 运行时建表/改表的唯一位置(只读模式下整体跳过) middleware/ auth(JWT → 注入 user)、read-only auth/ 登录换票、固定密码、权限过滤与脱敏 routes/ 按业务域的 HTTP 路由 @@ -119,9 +120,9 @@ cors → read-only → /api/auth(公开) → authMiddleware → 各业务域 - **后端路由尚未全部拆成 routes / service / repository**。`routes/vehicles/` 已有 `repository.ts` + `model.ts` 的雏形,其余域仍是单文件路由;拆分时不要改变 SQL 与参数顺序。 -- **运行时建表仍分散在业务代码里**:`ele`、`feedback`、`mileage` 在首次调用时执行 - `CREATE TABLE`,而 `read-only` 中间件只按 HTTP 方法拦截,所以只读预览下 GET 仍可能触发建表。 - 应收敛到显式的迁移函数并只由 `bootstrap.ts` 调用。 +- **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护, + 已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用—— + 要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。 - **`src/lib/cn.ts` 刻意不引入 `tailwind-merge`**:全仓只有 2 处调用,为此新增一个 传递依赖不划算。若将来条件类覆盖变多,再换 `clsx` + `tailwind-merge`。 - **前端 `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(` | | 高德 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` | | 角色常量与模块可见性 | `src/shared/auth/roles.ts` | `app/modules.test.ts` | +| 环境变量读取与校验 | `src/server/config.ts` | `auth/password.test.ts` 覆盖密码模式;JWT 缺失拒绝启动已实测 | diff --git a/src/architecture.test.ts b/src/architecture.test.ts index 3e0cab5..7a05d07 100644 --- a/src/architecture.test.ts +++ b/src/architecture.test.ts @@ -145,3 +145,21 @@ test("类型豁免清单只减不增:@ts-nocheck 仅限已登记的 8113 原 const actual = allFiles.filter((f) => fileContains(f, /^\/\/\s*@ts-nocheck/m)).map(rel).sort(); 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"); +}); diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 003180f..b13244e 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -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'; /** 启动只应在服务进程中运行的数据库准备和定时任务。 */ diff --git a/src/server/routes/ele/migration.ts b/src/server/db/schema/ele.ts similarity index 90% rename from src/server/routes/ele/migration.ts rename to src/server/db/schema/ele.ts index 37f5667..4fedd04 100644 --- a/src/server/routes/ele/migration.ts +++ b/src/server/db/schema/ele.ts @@ -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 = ` 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; export async function ensureChargeRecordTable(): Promise { if (ensured) return; + if (!ddlAllowed()) return logDdlSkipped('bi_ele_charge_record'); await pool.query(CREATE_TABLE_SQL); ensured = true; } diff --git a/src/server/db/schema/feedback.ts b/src/server/db/schema/feedback.ts new file mode 100644 index 0000000..335428b --- /dev/null +++ b/src/server/db/schema/feedback.ts @@ -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 { + 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; +} diff --git a/src/server/db/schema/guard.ts b/src/server/db/schema/guard.ts new file mode 100644 index 0000000..90169e1 --- /dev/null +++ b/src/server/db/schema/guard.ts @@ -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)`); +} diff --git a/src/server/db/schema/mileage-report.ts b/src/server/db/schema/mileage-report.ts new file mode 100644 index 0000000..bce2a59 --- /dev/null +++ b/src/server/db/schema/mileage-report.ts @@ -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 { + if (ensured) return; + if (!ddlAllowed()) return logDdlSkipped('tab_mileage_daily_report'); + await pool.query(CREATE_TABLE_SQL); + ensured = true; +} diff --git a/src/server/routes/scheduling/db-schema.ts b/src/server/db/schema/scheduling.ts similarity index 88% rename from src/server/routes/scheduling/db-schema.ts rename to src/server/db/schema/scheduling.ts index a4dc641..b16f3ba 100644 --- a/src/server/routes/scheduling/db-schema.ts +++ b/src/server/db/schema/scheduling.ts @@ -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 = ` 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 { + if (!ddlAllowed()) return logDdlSkipped('scheduling tables'); try { await pool.query(CREATE_NOTIFICATIONS_TABLE); console.log('[scheduling] notifications table ready'); diff --git a/src/server/routes/ele/index.ts b/src/server/routes/ele/index.ts index 535613d..45fc186 100644 --- a/src/server/routes/ele/index.ts +++ b/src/server/routes/ele/index.ts @@ -4,7 +4,7 @@ import * as XLSX from 'xlsx'; import pool from '../../db/mysql.js'; import type { AuthUser } 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(); diff --git a/src/server/routes/feedback/index.ts b/src/server/routes/feedback/index.ts index a7e35fe..dd2f454 100644 --- a/src/server/routes/feedback/index.ts +++ b/src/server/routes/feedback/index.ts @@ -1,6 +1,7 @@ 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'; @@ -10,46 +11,6 @@ const app = new Hono(); const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB 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 { - 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_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)`; app.post('/submit', async (c) => { - await ensureTable(); + await ensureFeedbackTable(); const body = await c.req.json().catch(() => ({})) as { type?: string; module?: string | null; content?: string; contact?: string | null; userAgent?: string; screenshots?: string[]; @@ -116,7 +77,7 @@ app.post('/upload', async (c) => { // GET /api/feedback/mine — 当前用户的反馈历史 app.get('/mine', async (c) => { - await ensureTable(); + 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( @@ -133,7 +94,7 @@ app.get('/mine', async (c) => { // GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限) app.get('/list', async (c) => { - await ensureTable(); + 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); @@ -160,7 +121,7 @@ app.get('/list', async (c) => { // PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限) app.patch('/:id', async (c) => { - await ensureTable(); + 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); diff --git a/src/server/routes/mileage/daily-report-store.ts b/src/server/routes/mileage/daily-report-store.ts index e0d3310..a06b62b 100644 --- a/src/server/routes/mileage/daily-report-store.ts +++ b/src/server/routes/mileage/daily-report-store.ts @@ -1,5 +1,6 @@ import type { ResultSetHeader, RowDataPacket } from 'mysql2'; import pool from '../../db/mysql.js'; +import { ensureDailyReportTable, DAILY_REPORT_TABLE as TABLE } from '../../db/schema/mileage-report.js'; import type { DailyMileageReport, MileageReportHistoryItem, @@ -7,36 +8,6 @@ import type { MileageReportSource, } 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 { - if (ensured) return; - await pool.query(CREATE_TABLE_SQL); - ensured = true; -} - function parsePayload(value: unknown): DailyMileageReport | null { if (!value) return null; if (typeof value === 'object') return value as DailyMileageReport;