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 只读预览启动(不建表、不定时任务)
|
||||
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 缺失拒绝启动已实测 |
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
/** 启动只应在服务进程中运行的数据库准备和定时任务。 */
|
||||
|
||||
@@ -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<void> {
|
||||
if (ensured) return;
|
||||
if (!ddlAllowed()) return logDdlSkipped('bi_ele_charge_record');
|
||||
await pool.query(CREATE_TABLE_SQL);
|
||||
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 = `
|
||||
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> {
|
||||
if (!ddlAllowed()) return logDdlSkipped('scheduling tables');
|
||||
try {
|
||||
await pool.query(CREATE_NOTIFICATIONS_TABLE);
|
||||
console.log('[scheduling] notifications table ready');
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<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_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<RowDataPacket[]>(
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user