refactor(stage7): 后端目录分层、架构守护测试与文档
后端目录 - db/:mysql / hydrogen / heatmap 连接与氢能只读 SQL 守卫收拢到一处。 - middleware/:auth(JWT → 注入 user)与 read-only 归位。 - 相关测试随文件移动(middleware/read-only.test.ts、db/hydrogen-read-only.test.ts)。 import 改写由一次性 codemod 完成,未改变任何逻辑。 架构守护 - 新增 src/architecture.test.ts,断言 6 条分层铁律: server 不依赖 modules、前端不依赖 server、shared 为叶子层、 无 vendor 引用、model.ts 不依赖 react、@ts-nocheck 仅限已登记的原型快照。 豁免清单只减不增。 测试基建 - npm test 的 glob 同时匹配 .test.ts 与 .test.tsx(此前 .tsx 测试会被静默漏掉)。 文档 - 新增根 README.md:入口、快速开始、命令、目录、部署注意事项 (JWT_SECRET 必须注入且 >=32 字符,否则拒绝启动)。 - 新增 docs/ARCHITECTURE.md:依赖方向、6 条硬规则、业务域形状、 中间件顺序、新增模块步骤,以及"已知未尽事项"的诚实清单。 lint / test(134) / build 全绿。
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
/**
|
||||
* 架构守护测试。
|
||||
*
|
||||
* 这些不是代码风格偏好,而是这个仓库赖以保持"能读懂"的结构约束。
|
||||
* 一旦被打破(尤其是 client/server 互相引用、shared 反向依赖上层),
|
||||
* 依赖图会重新变成无法追踪的网,所以用断言固化。
|
||||
*/
|
||||
|
||||
const srcDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, out);
|
||||
else if (/\.(ts|tsx|css)$/.test(entry.name)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const allFiles = walk(srcDir);
|
||||
const rel = (file: string) => path.relative(srcDir, file);
|
||||
|
||||
/** 取出文件里所有 import 说明符(含副作用 import / 动态 import / @import)。 */
|
||||
function specifiers(file: string): string[] {
|
||||
const source = readFileSync(file, "utf8");
|
||||
const found: string[] = [];
|
||||
const patterns = [
|
||||
/(?:^|[^\w$])(?:import|export)\s+[\s\S]*?\sfrom\s*['"]([^'"]+)['"]/g,
|
||||
/(?:^|[^\w$])import\s*['"]([^'"]+)['"]/g,
|
||||
/(?:^|[^\w$])import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
||||
/@import\s+['"]([^'"]+)['"]/g,
|
||||
];
|
||||
for (const re of patterns) {
|
||||
for (const match of source.matchAll(re)) found.push(match[1]);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** 把相对 import 解析为 src 下的路径(用于判断它指向哪个分区)。 */
|
||||
function targetPath(file: string, spec: string): string | null {
|
||||
if (!spec.startsWith(".")) return null;
|
||||
return path.relative(srcDir, path.resolve(path.dirname(file), spec));
|
||||
}
|
||||
|
||||
function filesUnder(prefix: string): string[] {
|
||||
return allFiles.filter((f) => rel(f).startsWith(prefix));
|
||||
}
|
||||
|
||||
function fileContains(file: string, re: RegExp): boolean {
|
||||
return re.test(readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
test("分层铁律:server 不得依赖前端 modules", () => {
|
||||
const violations: string[] = [];
|
||||
for (const file of filesUnder("server/")) {
|
||||
for (const spec of specifiers(file)) {
|
||||
const target = targetPath(file, spec);
|
||||
if (target && target.startsWith("modules/")) violations.push(`${rel(file)} -> ${spec}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(violations, [], "server 必须独立于前端:请把共享内容下沉到 src/shared");
|
||||
});
|
||||
|
||||
test("分层铁律:前端不得依赖 server", () => {
|
||||
const violations: string[] = [];
|
||||
const clientDirs = ["modules/", "components/", "auth/", "app/"];
|
||||
for (const dir of clientDirs) {
|
||||
for (const file of filesUnder(dir)) {
|
||||
for (const spec of specifiers(file)) {
|
||||
const target = targetPath(file, spec);
|
||||
if (target && target.startsWith("server/")) violations.push(`${rel(file)} -> ${spec}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(violations, [], "前端不得直接引用服务端模块");
|
||||
});
|
||||
|
||||
test("分层铁律:shared 是最底层,不得反向依赖上层", () => {
|
||||
const violations: string[] = [];
|
||||
for (const file of filesUnder("shared/")) {
|
||||
for (const spec of specifiers(file)) {
|
||||
const target = targetPath(file, spec);
|
||||
if (target && (target.startsWith("modules/") || target.startsWith("server/") || target.startsWith("components/"))) {
|
||||
violations.push(`${rel(file)} -> ${spec}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(violations, [], "src/shared 必须保持为可被前后端共同依赖的叶子层");
|
||||
});
|
||||
|
||||
test("原型代码不再散落在 vendor 目录", () => {
|
||||
const violations: string[] = [];
|
||||
for (const file of allFiles) {
|
||||
for (const spec of specifiers(file)) {
|
||||
if (spec.includes("vendor/")) violations.push(`${rel(file)} -> ${spec}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(violations, [], "生产代码应位于 src/modules 下的业务域目录,而不是 src/vendor");
|
||||
});
|
||||
|
||||
test("纯模型文件不得依赖 React(保证可被 node:test 直接测)", () => {
|
||||
const violations: string[] = [];
|
||||
for (const file of allFiles.filter((f) => /(^|\/)model\.tsx?$/.test(rel(f)))) {
|
||||
for (const spec of specifiers(file)) {
|
||||
if (spec === "react" || spec.startsWith("react/")) violations.push(`${rel(file)} -> ${spec}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(violations, [], "model.ts 应只包含可在 Node 中直接执行的纯函数");
|
||||
});
|
||||
|
||||
test("类型豁免清单只减不增:@ts-nocheck 仅限已登记的 8113 原型快照", () => {
|
||||
// 这三个文件是逐字节保留的验收原型(UI/CSS 冻结),显式登记为唯一豁免。
|
||||
const allowed = [
|
||||
"modules/energy/hydrogen/board/EnergyBiBoardApp.tsx",
|
||||
"modules/energy/hydrogen/station-daily/StationDailyApp.tsx",
|
||||
"modules/energy/hydrogen/station-daily/StationDailyDetailView.tsx",
|
||||
];
|
||||
const actual = allFiles.filter((f) => fileContains(f, /^\/\/\s*@ts-nocheck/m)).map(rel).sort();
|
||||
assert.deepEqual(actual, [...allowed].sort(), "新增 @ts-nocheck 会让类型门禁失效,请改为修正类型");
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { MobileListFullscreenButton } from '../common/MobileListFullscreenButton
|
||||
import {
|
||||
SPOT_PAY_METHOD_LABEL,
|
||||
type StationCashIntakeDay,
|
||||
} from '../common/energy-spot-cash-intake';
|
||||
} from '../common/energy-spot-cash-intake/index';
|
||||
import { fetchHydrogenStationBoard } from '../../api';
|
||||
import { fetchAllH2BiDrillRecords } from '../api';
|
||||
import { PrototypeDrillModal } from '../drill/prototype-real-drills';
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import { Hono } from 'hono';
|
||||
import { readOnlyMiddleware } from './read-only-middleware.js';
|
||||
import { readOnlyMiddleware } from './middleware/read-only.js';
|
||||
import { cors } from 'hono/cors';
|
||||
import authRouter from './auth/login.js';
|
||||
import { authMiddleware } from './auth/middleware.js';
|
||||
import { authMiddleware } from './middleware/auth.js';
|
||||
import eleRouter from './routes/ele/index.js';
|
||||
import energyRouter from './routes/energy/index.js';
|
||||
import feedbackRouter from './routes/feedback/index.js';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import pool from '../db.js';
|
||||
import pool from '../db/mysql.js';
|
||||
import type { AuthUser, JwtPayload, PermissionLevel } from './types.js';
|
||||
import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES } from './types.js';
|
||||
import { authMode, verifyAuthToken } from './config.js';
|
||||
|
||||
@@ -3,8 +3,8 @@ import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { passwordRouter } from './password.js';
|
||||
import { authMiddleware } from './middleware.js';
|
||||
import { readOnlyMiddleware } from '../read-only-middleware.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { readOnlyMiddleware } from '../middleware/read-only.js';
|
||||
import { verifyAuthToken } from './config.js';
|
||||
|
||||
test('固定密码模式默认关闭、失败限流、只读权限及换密失效', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Context, Next } from 'hono';
|
||||
import type { AuthUser } from './types.js';
|
||||
import { authMode, verifyAuthToken } from './config.js';
|
||||
import type { AuthUser } from '../auth/types.js';
|
||||
import { authMode, verifyAuthToken } from '../auth/config.js';
|
||||
|
||||
|
||||
// 临时:跳过所有认证(保留完整逻辑便于快速恢复)
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import { readOnlyMiddleware } from './read-only-middleware.js';
|
||||
import { readOnlyMiddleware } from './read-only.js';
|
||||
|
||||
test('read-only preview blocks write handlers but permits reads; normal mode is unchanged', async () => {
|
||||
const previous = process.env.DB_READ_ONLY;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { RowDataPacket, ResultSetHeader } from 'mysql2';
|
||||
import * as XLSX from 'xlsx';
|
||||
import pool from '../../db.js';
|
||||
import pool from '../../db/mysql.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import { canAccessEnergy } from '../../auth/types.js';
|
||||
import { ensureChargeRecordTable } from './migration.js';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import pool from '../../db.js';
|
||||
import pool from '../../db/mysql.js';
|
||||
|
||||
const CREATE_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS bi_ele_charge_record (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Hono } from 'hono';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import type pool from '../../db.js';
|
||||
import type pool from '../../db/mysql.js';
|
||||
import type { cached } from './cache.js';
|
||||
import {
|
||||
dateRangeClause,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Hono } from 'hono';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import type pool from '../../db.js';
|
||||
import type pool from '../../db/mysql.js';
|
||||
import type { cached } from './cache.js';
|
||||
|
||||
export interface EtcOverviewDependencies {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Hono } from "hono";
|
||||
import type { RowDataPacket } from "mysql2";
|
||||
import type hydrogenPool from "../../hydrogen-db.js";
|
||||
import type hydrogenPool from "../../db/hydrogen.js";
|
||||
import {
|
||||
HYDROGEN_FUEL_ONLY_WHERE,
|
||||
HYDROGEN_FUEL_ONLY_WHERE_B,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Hono } from 'hono';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import type hydrogenPool from '../../hydrogen-db.js';
|
||||
import type hydrogenPool from '../../db/hydrogen.js';
|
||||
import type { cached } from './cache.js';
|
||||
import { HYDROGEN_BASE_WHERE, HYDROGEN_BASE_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_TABLE } from './constants.js';
|
||||
import { dateRangeClause, enumerateDateRange, resolveDateRange } from './query-model.js';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import { canAccessEnergy } from '../../auth/types.js';
|
||||
import pool from '../../db.js';
|
||||
import hydrogenPool from '../../hydrogen-db.js';
|
||||
import pool from '../../db/mysql.js';
|
||||
import hydrogenPool from '../../db/hydrogen.js';
|
||||
import { cached } from './cache.js';
|
||||
import {
|
||||
registerElectricMonthlyRoute,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
|
||||
import pool from '../../db.js';
|
||||
import pool from '../../db/mysql.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import { canManageFeedback } from '../../auth/types.js';
|
||||
import { uploadFeedbackImage } from './oss.js';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import pool from '../db.js';
|
||||
import pool from '../db/mysql.js';
|
||||
import type { AuthUser } from '../auth/types.js';
|
||||
import { canAccessEnergy } from '../auth/types.js';
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import pool from '../../db.js';
|
||||
import pool from '../../db/mysql.js';
|
||||
import { fetchVehicleInfoMap } from './vehicle-info.js';
|
||||
import { fetchOneOsDailyMileage, fetchOneOsMileageDates } from './oneos-api.js';
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import pool from '../../db.js';
|
||||
import pool from '../../db/mysql.js';
|
||||
import type {
|
||||
DailyMileageReport,
|
||||
MileageReportBreakdown,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
|
||||
import pool from '../../db.js';
|
||||
import pool from '../../db/mysql.js';
|
||||
import type {
|
||||
DailyMileageReport,
|
||||
MileageReportHistoryItem,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../../db.js';
|
||||
import pool from '../../db/mysql.js';
|
||||
import { getCache } from './cache.js';
|
||||
import { fetchOneOsDailyMileage } from './oneos-api.js';
|
||||
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../../db.js';
|
||||
import pool from '../../db/mysql.js';
|
||||
import { fetchOneOsMileageDates } from './oneos-api.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import pool from '../../db.js';
|
||||
import pool from '../../db/mysql.js';
|
||||
import type { VehicleInfoRow } from './types.js';
|
||||
|
||||
/** 车辆关联信息 SQL(客户名、部门、经理、租赁状态、主体、项目、品牌) */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import pool from '../../db.js';
|
||||
import pool from '../../db/mysql.js';
|
||||
|
||||
const CREATE_NOTIFICATIONS_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS tab_scheduling_notifications (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../../db.js';
|
||||
import pool from '../../db/mysql.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import type {
|
||||
NotifyRequest,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../../db.js';
|
||||
import pool from '../../db/mysql.js';
|
||||
import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
|
||||
import { fetchOneOsMileageDates } from '../mileage/oneos-api.js';
|
||||
import { mapRegion } from '../vehicles.js';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../db.js';
|
||||
import heatmapPool from '../heatmap-db.js';
|
||||
import pool from '../db/mysql.js';
|
||||
import heatmapPool from '../db/heatmap.js';
|
||||
import { getCache } from './mileage/cache.js';
|
||||
import {
|
||||
buildVehicleGrid,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import pool from '../../db.js';
|
||||
import pool from '../../db/mysql.js';
|
||||
import type { Vehicle, VehicleRow } from '../../types.js';
|
||||
import { transformRow } from './model.js';
|
||||
import type { WeeklyTruckIds } from './types.js';
|
||||
|
||||
Reference in New Issue
Block a user