feat(m0): 后端骨架 + 账号体系(M0 验收 36/36 通过)
技术栈(已在 4.1.1 上实测跑通,非纸面选型)
Java 21 + Spring Boot 4.1.1 + MyBatis-Plus 3.5.17(boot4) + PostgreSQL 16.15
+ Redis 7 + Flyway 12.4 + springdoc 3.1.1;前端定 Vue 3 + Element Plus + uni-app
交付内容
M0-01 基础设施 deploy/docker-compose.yml(PG + Redis + MinIO,容器名与端口避让本机既有项目)
M0-02 后端骨架 xt-common + xt-server;统一响应 / 全局异常 / 58 个错误码 /
多租户拦截 / 软删除 / 字段自动填充 / AES-GCM / HMAC 索引 / RSA / JWT
M0-05 租户 开通租户(一次完成建租户 + 发 6 个内置角色 + 建首个管理员)、列表、详情
M0-06 组织 CRUD + 组织树 + ancestor_path 自动维护 + 占用校验
M0-07 PC 登录 RSA 解密 + 图形验证码 + 失败锁定 + 等时比较防账号枚举 + 审计日志
M0-08 登出/当前用户 Token 黑名单(存 SHA-256,TTL 到自然过期)
M0-01 数据库 Flyway 2 个迁移 → 26 张表(20 业务 + 5 标准 + 1 迁移记录)
安全姿态
· 租户拦截器【默认拒绝】:匿名请求触及租户表直接抛 40101,不做「不加条件放行」
· 权限码定义在代码里(PermissionDef / BuiltinRole),不建 sys_permission 表,杜绝前后端漂移
· 匿名接口只靠 @PublicApi 放行,不做路径白名单,避免两套机制各说各话
· 账号不存在时也跑一次 BCrypt(假哈希),拉平响应时间防枚举
· 登录密码 RSA 传输,防止网关/APM 把明文密码记进日志
脚本
scripts/sync-ddl.py 文档 DDL → Flyway 迁移,带 --check 防漂移
scripts/e2e-m0.py M0 验收 36 项,可进 CI
scripts/verify-docs.py 文档一致性(接口数【逐节】核对、错误码、跨文档数字、表格列数)
scripts/dev-login.py RSA 加密登录助手
scripts/dev-clean-e2e.py 清理 E2E 测试数据
文档修正(实测暴露,非人工审查)
· DDL 缺陷:sys_tenant.contact_phone / sys_user.phone / collect_roster.phone
原 varchar(20) 按明文长度建,装不下 AES-GCM 密文(约 52 字符),改 varchar(128)(PRD V2.4)
· 错误码 40007b/40007c 带字母后缀与「code 为数字」的约定冲突,规范化为 40071/40072
· 07-开发实施计划 新增 §9 版本兼容踩坑记录(10 条实录)与 §10 M0 完成情况
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
DDL 同步:docs/db/schema-postgresql.sql → backend/.../db/migration/V1__business_tables.sql
|
||||
|
||||
为什么要有这个脚本
|
||||
------------------
|
||||
文档里的 DDL 是**交付物**(已实测、写进验收标准),Flyway 迁移是**工程产物**。
|
||||
两者必须字字相同,否则"文档说一套、代码跑另一套"——这是最难查的一类事故。
|
||||
|
||||
所以:迁移文件由脚本生成,禁止手改;改 DDL 一律先改文档,再跑本脚本。
|
||||
|
||||
用法
|
||||
----
|
||||
python scripts/sync-ddl.py # 生成/更新迁移文件
|
||||
python scripts/sync-ddl.py --check # 只校验是否同步(CI 用,不同步则退出码 1)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Windows 控制台默认不是 UTF-8
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SOURCE = os.path.join(ROOT, "docs", "db", "schema-postgresql.sql")
|
||||
TARGET = os.path.join(
|
||||
ROOT, "backend", "xt-server", "src", "main", "resources",
|
||||
"db", "migration", "V1__business_tables.sql",
|
||||
)
|
||||
|
||||
HEADER = """\
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
-- V1 · 业务表(20 张)
|
||||
--
|
||||
-- ⚠️ 本文件由脚本生成,请勿手改!
|
||||
-- 来源 :docs/db/schema-postgresql.sql
|
||||
-- 重新生成 :python scripts/sync-ddl.py
|
||||
-- 校验同步 :python scripts/sync-ddl.py --check
|
||||
--
|
||||
-- 源文件已在 PostgreSQL 16.15 实测零错误:
|
||||
-- 20 表 / 177 列注释 / 65 索引 / 20 表注释
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
with io.open(path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
"""统一行尾并去掉首尾空白,用于比对。"""
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||
|
||||
|
||||
def render(source_text: str) -> str:
|
||||
return HEADER + normalize(source_text) + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
check_only = "--check" in sys.argv
|
||||
|
||||
if not os.path.exists(SOURCE):
|
||||
print(f"[X] 找不到源文件:{SOURCE}")
|
||||
return 1
|
||||
|
||||
expected = render(read(SOURCE))
|
||||
|
||||
if check_only:
|
||||
if not os.path.exists(TARGET):
|
||||
print(f"[X] 迁移文件不存在:{TARGET}")
|
||||
return 1
|
||||
actual = io.open(TARGET, encoding="utf-8").read()
|
||||
if normalize(actual) != normalize(expected):
|
||||
print("[X] DDL 已漂移:文档与迁移文件不一致")
|
||||
print(f" 文档 :{SOURCE}")
|
||||
print(f" 迁移 :{TARGET}")
|
||||
print(" 修复 :python scripts/sync-ddl.py")
|
||||
return 1
|
||||
print("[OK] DDL 已同步")
|
||||
return 0
|
||||
|
||||
os.makedirs(os.path.dirname(TARGET), exist_ok=True)
|
||||
with io.open(TARGET, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(expected)
|
||||
|
||||
tables = expected.count("CREATE TABLE ")
|
||||
indexes = expected.count("CREATE INDEX ") + expected.count("CREATE UNIQUE INDEX ")
|
||||
comments = expected.count("COMMENT ON COLUMN ")
|
||||
print(f"[OK] 已生成 {os.path.relpath(TARGET, ROOT)}")
|
||||
print(f" {tables} 表 / {indexes} 索引 / {comments} 列注释 / "
|
||||
f"{len(expected.encode('utf-8'))} 字节")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user