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:
dsh-agent
2026-09-11 10:29:50 +08:00
parent 507bc90ed2
commit 954d626afc
11 changed files with 133 additions and 82 deletions
+7 -4
View File
@@ -79,6 +79,7 @@ server/
local.ts 只读预览启动(不建表、不定时任务) local.ts 只读预览启动(不建表、不定时任务)
bootstrap.ts 启动副作用总入口 bootstrap.ts 启动副作用总入口
db/ mysql / hydrogen / heatmap 连接 db/ mysql / hydrogen / heatmap 连接
db/schema/ 运行时建表/改表的唯一位置(只读模式下整体跳过)
middleware/ authJWT → 注入 user)、read-only middleware/ authJWT → 注入 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 kBgzip 50 kB),来自原型渲染方式(大量内联样式)。 - **前端 `dist` 体积**:氢能看板单个 chunk 约 200 kBgzip 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 缺失拒绝启动已实测 |
+18
View File
@@ -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 -1
View File
@@ -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;
} }
+44
View File
@@ -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;
}
+16
View File
@@ -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)`);
}
+34
View File
@@ -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');
+1 -1
View File
@@ -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();
+5 -44
View File
@@ -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;