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,106 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
开发辅助:RSA 加密登录并打印 token。
|
||||
|
||||
为什么需要它
|
||||
------------
|
||||
登录接口的密码字段是 RSA 加密的(防止明文密码进网关日志),
|
||||
所以用 curl / Postman 直接调会很别扭——得先算出一段密文。
|
||||
这个脚本把它变成一行命令。
|
||||
|
||||
用法
|
||||
----
|
||||
python scripts/dev-login.py # 默认 admin / Admin@123
|
||||
python scripts/dev-login.py platform Platform@123
|
||||
python scripts/dev-login.py admin Admin@123 --base http://localhost:8080
|
||||
|
||||
依赖:cryptography (pip install cryptography)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFAULT_PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
|
||||
|
||||
|
||||
def rsa_encrypt(plain: str, pub_key_path: str) -> str:
|
||||
try:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
except ImportError:
|
||||
sys.exit("缺少依赖:pip install cryptography")
|
||||
|
||||
with open(pub_key_path, "rb") as f:
|
||||
public_key = serialization.load_pem_public_key(f.read())
|
||||
|
||||
# 必须与服务端 RsaCipher 的 "RSA/ECB/PKCS1Padding" 一致。
|
||||
# 换成 OAEP 会表现为「密码永远错」,两边都改才能换。
|
||||
cipher = public_key.encrypt(plain.encode("utf-8"), padding.PKCS1v15())
|
||||
return base64.b64encode(cipher).decode("ascii")
|
||||
|
||||
|
||||
def post_json(url: str, payload: dict, timeout: int = 20) -> tuple[int, dict]:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url, data=data,
|
||||
headers={"Content-Type": "application/json; charset=utf-8"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.status, json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8")
|
||||
try:
|
||||
return e.code, json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return e.code, {"raw": body}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="RSA 加密登录,打印 token")
|
||||
ap.add_argument("username", nargs="?", default="admin")
|
||||
ap.add_argument("password", nargs="?", default="Admin@123")
|
||||
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
|
||||
ap.add_argument("--tenant-code", default=None, help="账号在多个租户下重名时必填")
|
||||
ap.add_argument("--pub-key", default=DEFAULT_PUB_KEY)
|
||||
ap.add_argument("--json", action="store_true", help="打印完整响应而非仅 token")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.exists(args.pub_key):
|
||||
sys.exit(f"找不到公钥:{args.pub_key}")
|
||||
|
||||
payload = {
|
||||
"username": args.username,
|
||||
"password": rsa_encrypt(args.password, args.pub_key),
|
||||
}
|
||||
if args.tenant_code:
|
||||
payload["tenantCode"] = args.tenant_code
|
||||
|
||||
status, body = post_json(f"{args.base}/api/v1/auth/login", payload)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(body, ensure_ascii=False, indent=2))
|
||||
elif body.get("code") == 0:
|
||||
d = body["data"]
|
||||
print(d["token"])
|
||||
else:
|
||||
print(f"[失败] HTTP {status} code={body.get('code')} msg={body.get('msg')}",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0 if body.get("code") == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user