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:
@@ -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