Files
dealerhub/NEXT_PLAN.md

48 KiB
Raw Permalink Blame History

dealerhub · 未完成工作清单(交接计划)

生成时间:2026-09-11 晚 生成方式:对工作区做实地核查(读代码 + 跑测试),不是复述文档。 用途:交给下一个模型/工程师按序执行。每项都写明落点文件、具体做法、验收命令。 当前项目不是 git 仓库,动手前先看 §0。


现状快照(本次实测,非文档转述)

项 实测结果
技术栈 Django 5.2 + DRF + adrf + Granian + Vue3(Element Plus + Vite)
工作目录 C:\Users\12914\Desktop\gj\dealerhub(Windows / Git Bash,非 git 仓库)
后端测试 tests/ = 468 用例,全量 = 482(含 apps/core/tests 14 个);5 失败 / 4 跳过
失败位置 test_audit_api.py::test_requires_tenant、test_demo.py ×3、test_printing.py::test_render_tenant_isolation(全是 membership 改造引起)
静态检查 manage.py check 0 issues;makemigrations --check No changes ✅
前端 npm run build ✅ 12.66s;npm test ✅ 3/3 passed(仅 src/utils/transaction.js)
性能 11 个端点 p95 全部达标(§附录 F),当前无性能瓶颈
当前断点 最后 70 分钟在做租户 membership 授权改造,改到一半、测试是红的

结论:P0 是"把上一轮改到一半的授权改造收口",不是新功能。

本次核查共发现 5 个 P0 + 10 项前端缺口 + 双向文档失真

编号 问题 性质
P0-1 membership 改造改到一半,5 个测试红 阻断
P0-2 文档/测试数字过期 收尾
P0-3 注册闭环不存在,但系统处处承诺注册 商业化断点
P0-4 重复编码返回 500 + 泄露 exc_type,28 个模型全中 产品级 bug
P0-5 登录无防爆破;API Key rate_limit 是死字段 安全缺口
§附录 A 收款/付款/核销等 10 项后端能力前端零接入 业务跑不完整
P1-4 生产 logger 仍 DEBUG + ALLOWED_HOSTS 默认 * 部署前必修
§附录 D 软删除 / 多组织 —— "✅"是纸面的 文档失真(高估)
§附录 H 「待落地」清单里 5 项其实已做完 文档失真(低估)
§附录 I 8 项怀疑已实测排除 别再重查

给下一个模型的话:不要相信 README.md 和 PROGRESS_*.md 的任何 ✅ / ⏳。 本项目文档与代码有双向偏差:既把没做的标成做完(§附录 D),也把做完的标成待做(§附录 H)。本文所有结论都标了"实测"或"读代码",可直接采信;但动手前仍建议复跑 §附录 C 的命令确认环境一致。

最省事的起手式:先读 §附录 I(已排除的怀疑),能少走我走过的 8 条弯路。


§0 动手前必做(5 分钟,避免改崩了没法回退)

项目没有版本控制,任何改动都无法 diff/回滚。先建基线:

cd "C:/Users/12914/Desktop/gj/dealerhub"
git init
cat > .gitignore <<'EOF'
__pycache__/
*.pyc
node_modules/
dist/
db.sqlite3
*.log
.pytest_cache/
media/
staticfiles/
.env
EOF
git add -A && git commit -m "baseline: 批次A-D 成果 + membership 半成品(测试红)"

顺手清理仓库噪音(可选,别和改造混在一个 commit):

  • backend/g8060.log g8070.log g8080.log g8090.log —— 遗留 dev server 日志
  • 根目录 frontend_dist.zip(441KB)—— 已被 Docker 多阶段构建取代,确认无引用后删

P0-1 收口租户 membership 授权改造(唯一阻断项,先做这个)

问题定位(已核实)

新增的三个东西造成 5 个测试失败:

文件 内容
backend/apps/core/models.py:29 TenantMembership 模型(user × tenant + role + is_active)
backend/apps/core/migrations/0002_tenantmembership.py 建表 + RunPython 回填(超管=全租户,普通用户=default)
backend/apps/core/permissions.py TenantMembershipPermission,无 membership 一律 403 tenant_membership_required
backend/config/settings/base.py:143-146 把该权限类加进 DEFAULT_PERMISSION_CLASSES

关键坑:config/settings/test.py 里 MIGRATION_MODULES = DisableMigrations(),测试库禁用迁移 → 0002 里的 RunPython 回填在测试中根本不执行。 所以任何"自己 create_user 但没建 membership"的测试,现在全变 403。

逐步做法

(a) 权限语义分层:未知租户 → 400,已知租户无权 → 403

现在 apps/core/permissions.py:has_permission() 对未知租户也返回 403,但视图层 apps/core/viewset.py:get_tenant() 对未知租户返回 None,视图会抛 ValidationError({"tenant": "无法识别租户"}) → 400。两层语义打架。

改 TenantMembershipPermission.has_permission(),在 membership 查询之前先判断租户是否存在:

from .models import Tenant, TenantMembership

exists = Tenant.objects.filter(code=tenant_code, is_active=True).exists()
if not exists:
    return True   # 交给视图返回 400「无法识别租户」,不要用 403 掩盖参数错误
return TenantMembership.objects.filter(...).exists()

同时补一条:超级用户放行(与 seed_initial_data.py:49-53 的注释契约一致——"existing superusers are operators for every seeded tenant"):

if user.is_superuser:
    return True

注意 is_active=False 的租户:保持"视为不存在"→ 走 400 分支,或明确返回 403,二者选一并写进测试。建议 400(不泄露租户存在性)。

(b) seed_demo 补 membership

tests/test_demo.py 的 demo_db fixture 就是 call_command("seed_demo"),所以修 seed 即可让 2 个 demo 测试变绿。

落点:backend/apps/core/management/commands/seed_demo.py 的 _user()(约 123 行)。

from apps.core.models import TenantMembership
...
TenantMembership.objects.get_or_create(
    user=user, tenant=tenant,
    defaults={"role": "owner", "is_active": True},
)

(c) demo/enter 幂等兜底

backend/apps/core/demo.py 的 _prepare() 内,取到 (tenant, user) 后确保 membership 存在(防止有人手工建 demo 租户没跑 seed 就调接口):

def _prepare():
    ...
    if tenant and user:
        TenantMembership.objects.get_or_create(
            user=user, tenant=tenant, defaults={"role": "owner", "is_active": True})
    return tenant, user

(d) 修 3 个测试侧的用户创建(测试意图 ≠ 授权测试)

测试 现状 改法
test_demo.py::test_demo_middleware_does_not_affect_other_tenants 建 normal 用户无 membership 给 normal 建 TenantMembership(user, tenant);本测试验的是"演示只读中间件不误伤其他租户",不是授权
test_printing.py::test_render_tenant_isolation 建 bob2 无 membership,期望 404 现在拿 403 给 bob2 建 other_tenant 的 membership → 请求过了授权层,对象查询自然 404,保住"跨租户对象不泄露存在性=404"这个安全属性
test_audit_api.py::test_requires_tenant 期望未知租户 400 靠 (a) 修好,无需改测试

顺手 grep 一遍还有没有漏网的:

cd backend && grep -rn "create_user(" tests/ | grep -v conftest

已知 tests/test_exception_handler.py(auditor / ctx_user)和 tests/test_notify.py(3 处)也用自建用户——当前是绿的,但 (a)(b) 改完必须重跑确认没被带崩。

(e) 新增 tests/test_membership.py(当前权限类零测试覆盖)

必须覆盖的用例:

  1. 无 membership + 有效 JWT → 403,body code == "tenant_membership_required"
  2. 有 A 租户 membership、请求头写 B 租户 → 403
  3. is_active=False 的 membership → 403
  4. 未知租户 code → 400,body 含 tenant
  5. 超级用户无 membership → 放行(依赖 (a) 的策略)
  6. API Key 绑定租户:不带头或带同租户 → 放行;显式带其他租户 → 403(permissions.py:33-41 已有实现,补测试)
  7. demo token 进入 demo 租户只读可读 → 200
  8. seed_initial_data 幂等:重复执行不重复建 membership,普通用户只得 default、超管得全部

P0-1 验收

cd "C:/Users/12914/Desktop/gj/dealerhub/backend"
python -m pytest tests/ -p no:cacheprovider          # 必须 0 failed
python manage.py check                                # 0 issues
python manage.py makemigrations --check --dry-run     # no changes

P0-2 记录基线并更新文档

改造完必须落文档,否则下一轮又会"以为做完其实没做"(这个项目已经发生过:PROGRESS_AGI_ITERATION_5.md 说"下一轮最高价值项 = membership",然后改到一半断了)。

  • 新建 PROGRESS_AGI_ITERATION_6.md:写清 membership 改了什么、5 个失败怎么修的、每个语义决策(400 vs 403、超管直通、demo 兜底)的理由、最终测试数字。
  • 更新 README.md:102 的测试计数(现在是 "460 passed / 4 skipped",已过期)。
  • 更新 PROGRESS_AGI_ITERATION_5.md:58 的"下一轮最高价值项"第 1 条为已完成。

P0-3 注册/建租户闭环缺失(商业化断点,文档漏报)

问题(已核实:全仓 grep 零命中)

系统没有任何注册接口,也没有注册页面:

  • apps/billing/urls.py 只有 plans/ subscription/ subscribe/ quota/ —— 没有 register
  • apps/core/urls.py 没有注册路由
  • frontend/src/pages/Login.vue 只有"登录"和"🎬 免注册体验演示账套"两个按钮,没有注册入口
  • 全仓 grep def register|def signup|RegisterView|create_tenant|建账 → 零命中

但系统到处在承诺注册:

位置 原文
apps/core/middleware.py:109 演示只读 403 提示"注册免费账号即可创建你自己的账套"
frontend/src/api/client.js 配额超限弹窗引导升级
apps/website/ 官网价格页 有 free 套餐、公开标价
PROGRESS_BATCH_D.md 批次 C 声称"最小可售闭环达成"

实际链路:官网 → 「注册」→ 建租户 → 用系统。 第二步是空的。 客户点进来只能看演示账套,无法开始用。所谓"最小可售闭环"没有达成。

做法

  1. 后端 POST /api/v1/auth/register/(公开端点,加进 TenantMiddleware 的跳过白名单):
    • 入参:username / password / company_name /(可选)phone
    • 事务内:Tenant.objects.create(code=<slug>, name=company_name) → User.objects.create_user → TenantMembership(role="owner") → billing.subscribe(tenant, "free")
    • 租户 code 生成:公司名转 slug,冲突加数字后缀;必须校验 slug 合法(Tenant.code 是 SlugField)
    • 返回 JWT(直接登录,别让用户注册完再登一次)
    • 防滥用:同 IP 限速(django-ratelimit 或复用手写计数器)、用户名唯一校验、密码强度最低 8 位
  2. 前端 Login.vue 加"免费注册"链接 → 新建 Register.vue:公司名 / 用户名 / 密码 / 确认密码,提交后存 token + tenant 并跳 dashboard。
  3. 官网 apps/website/templates/website/pricing.html 的免费版 CTA 指向 /register。
  4. 配额:free 套餐限制见 apps/billing/models.py:143 的 seed_plans(),确认新租户拿到的是 free 且限制生效(pro/basic 要付费才给)。

验收 tests/test_register.py

  • 注册成功 → 租户已建 + membership role=owner + free 订阅 + 能拿 JWT 调业务 API
  • 重复用户名 → 400;非法公司名 → 400;弱密码 → 400
  • 注册的租户与已有租户数据隔离(互相看不到对方的商品/客户)
  • 未登录可访问(AllowAny);check 0 issues

P0-4 重复编码返回 500(已实测复现;严重度经二次验证后下调)

已复现的核心问题

新写临时测试打真实接口:

r1 = c.post("/api/v1/catalog/products/", {"code": "DUP01", "name": "商品A"}, format="json")  # 201
r2 = c.post("/api/v1/catalog/products/", {"code": "DUP01", "name": "商品B"}, format="json")  # 500 ← 应该是 400

输出:

second: 500 {"code":"server_error","detail":"服务端处理异常,请稍后重试或联系管理员","exc_type":"IntegrityError"}
sqlite3.IntegrityError: UNIQUE constraint failed: catalog_product.tenant_id, catalog_product.code

product / customer 均复现(supplier 复现过一次,属误报,见下方"重要更正")。

根因(已用 Django shell 证实,非推测)

运行 ProductSerializer().validators → 返回空列表。

DRF 的 ModelSerializer.get_unique_together_validators() 有一条硬性前提:只有 unique_together 里的字段全部出现在 Meta.fields 中时,才会生成 UniqueTogetherValidator。

本项目的 unique_together 几乎都是 ("tenant", "code") 或 ("tenant", "bill_no"),而 tenant 被刻意排除在 Meta.fields 之外(由 BaseTenantViewSet.acreate() 注入 validated["tenant"] = tenant)。

# apps/catalog/serializers.py
class ProductSerializer(AsyncModelSerializer):
    class Meta:
        model = Product
        fields = ["id", "code", "name", ...]      # ← 没有 "tenant"

→ 校验器被静默丢弃 → 唯一性只在数据库层兜底 → IntegrityError → 500。

AsyncModelSerializer(apps/core/serializers.py)没有责任,它只是给 ModelSerializer 加了 asave/acreate,继承链正常。别去改它——问题在字段排除策略,不在基类。

影响范围(实测统计:28 个模型全中)

用 Django shell 遍历所有带 unique_together 的模型的 serializer,结果:

App 受影响模型
catalog Category, Brand, Unit, Product(4)
partner PriceLevel, Customer, Supplier(3)
inventory Warehouse, Stock, StockBatch(3)
finance Receivable, Payable, Receipt, Payment, Account, Period, Voucher(7)
purchase PurchaseOrder, PurchaseBill(2)
sales SalesOrder, SalesBill(2)
report / channel / notify / printing ReportDefinition, ChannelAccount, ChannelOrder, AlertRule, PrintTemplate(5)
core Org(无 serializer,跳过)

28 个模型的 serializer 校验器全部为 MISSING。

真实影响集中在主数据:商品 / 客户 / 供应商 / 仓库 / 分类 / 品牌 / 单位 / 价格等级 —— 这些是用户每天手输编码的高频操作,重复录一个编码就吃 500。 单据类(SalesBill/PurchaseBill/Voucher 等)走 next_bill_no + create_with_unique_bill_no 重试,正常路径不易触发,但手工建单仍会中招。

做法(推荐:一处修复覆盖 28 个模型)

不要逐个 serializer 补 validators(28 处,必然漏)。用项目自己的架构收敛到一处:

在 BaseTenantViewSet.acreate() 里做租户感知的唯一性预检——因为这里 tenant 已确定,正好补上 DRF 缺的那一半信息:

# apps/core/viewset.py  BaseTenantViewSet.acreate() 内,serializer.is_valid() 之后:
def _check_unique(serializer, tenant):
    """DRF 因 tenant 不在 Meta.fields 而丢弃了 unique_together 校验,
    这里补齐:只校验除 tenant 外的字段组合。"""
    model = self.model
    for fields in model._meta.unique_together:
        rest = [f for f in fields if f != "tenant"]
        if not rest:
            continue
        lookup = {"tenant": tenant}
        for f in rest:
            val = serializer.validated_data.get(f)
            if val is None:
                continue
            lookup[f] = getattr(val, "pk", val)   # 处理 FK 传入对象的情况
        if len(lookup) != len(fields):        # 有字段没提供 → 跳过
            continue
        if model.objects.filter(**lookup).exists():
            human = " / ".join(rest)
            raise ValidationError({rest[0]: f"{human} 已存在,请更换"})

要点:

  • 递归处理 unique_together 为 tuple-of-tuples 的情况(_meta.unique_together 可能是嵌套的)
  • 软删除模型要排除 is_deleted=True 的记录(否则"已删"的编码会永久占用)
  • 这是预检 + 数据库约束双保险:预检负责友好报错,DB 约束负责并发兜底
  • 并发下预检可能漏(两个请求同时查到"不存在")→ 给 acreate 的 serializer.save() 包一层 try/except IntegrityError → 转 400。两层都要有。

收尾:apps/core/exceptions.py 兜底分支去掉 exc_type 回传(信息泄露),只记日志。

验收 tests/test_duplicate_code.py(必须加 transaction=True,见下方陷阱)

对 product / customer / supplier / warehouse / category / brand / unit / price_level 逐个断言:

  • 重复编码 → 400,body 带字段级错误(如 {"code": ["code 已存在,请更换"]})
  • 不能是 500
  • 响应体不含 exc_type / 堆栈
  • 跨租户同编码 → 允许(唯一性是 tenant 内,别改成全局唯一)
  • 软删除后可重新使用同一编码(若产品语义如此,需与产品确认;否则写明"编码永久占用")

⚠️ P0-4 附带的重要教训:非 transactional 测试会假性连坐

核查 P0-4 时我一度观察到"一次重复编码后,后续无关请求全部 500(TransactionManagementError)",看着像自杀式 DoS,差点当成 P0 报上去。三组对照实验后证明是测试环境假象:

实验条件 重复编码后,后续请求 结论
默认 @pytest.mark.django_db(测试自身持有外层 atomic) 全 500 TransactionManagementError ❌ 测试假象
@pytest.mark.django_db(transaction=True)(无外层 atomic,等同生产) 200 / 201 正常 ✅ 生产无恙
+ @override_settings(ATOMIC_REQUESTS=True) 200 / 201 正常 ✅ 开了也无恙

结论:生产不会级联,不要当 P0 修。

但这个陷阱本身要记住(这也是我一度误判 supplier "首次即 500" 的原因——它被同一测试里前一个冲突污染了,单独跑是 201):

在非 transactional 的测试里触发唯一约束冲突(或任何数据库层异常),会把测试自己那层 atomic 弄脏,导致同一测试内后续所有 ORM 操作以 TransactionManagementError 失败。看起来像被测代码坏了,其实是测试写法问题。

写任何会触发 DB 异常的回归测试,一律用 @pytest.mark.django_db(transaction=True)。

这条同样适用于:唯一约束、外键违约、check 约束、IntegrityError 相关的全部测试。


P0-5 登录无防爆破 + API Key 限流是死字段(安全缺口,实测确认)

问题 证据 影响
登录无任何限流 grep throttle|ratelimit|限流 在 config/settings/*.py、apps/*/auth.py、apps/openapi/*.py 零命中 /api/v1/auth/token/ 与 /api/v1/storefront/login/ 可无限撞库
API Key rate_limit 从未执行 字段定义在 apps/openapi/models.py:21(默认 120/分钟),全仓搜 rate_limit 除定义和 serializer 字段外无任何读取处 文档宣传的"限流"是纸面的,第三方可无限打

做法:

  1. 给 TokenObtainPairView 和 storefront.login 加失败计数 + 锁定:按 (用户名+IP) 记 Redis/缓存,连续 N 次失败锁 M 分钟;或直接上 django-ratelimit(@ratelimit(key='ip', rate='10/m'))。
  2. 把 APIKey.rate_limit 接进认证类(apps/openapi/auth.py 的 authenticate()):按 key 记分钟窗口计数,超限抛 Throttled;响应带 Retry-After。
  3. 顺手给 rate_limit 加个最小下界(防止被设成 0 或负数)。

验收 tests/test_security_ratelimit.py:连续失败被锁;正常用户不受影响;API Key 超限 429;窗口过后恢复。


P1-1 PostgreSQL 并发用例复跑(授权改造后必做)

为什么现在做:权限类给每个请求多了一次 membership 查询,并发用例是最可能被影响的;而且 tests/test_concurrency.py 有 4 个用例在 SQLite 下永远跳过,等于长期没人跑。

已有设施(迭代 3 搭好,别重造):

  • backend/scripts/setup_pg.py —— 一键建库 + 迁移,幂等,自带密码探测
  • backend/config/settings/pgtest.py —— PG 测试配置
  • backend/conftest.py:pytest_collection_modifyitems —— 非 PG 后端自动跳过 @pytest.mark.postgres
  • 迭代 3 记录本机 PG 在 5433 端口(PROGRESS_AGI_ITERATION_3.md)

执行:

cd "C:/Users/12914/Desktop/gj/dealerhub/backend"
python scripts/setup_pg.py
DJANGO_SETTINGS_MODULE=config.settings.pgtest python -m pytest tests/ -m postgres -p no:cacheprovider -v
# 再跑一遍全量,确认 PG 下没有别的方言差异
DJANGO_SETTINGS_MODULE=config.settings.pgtest python -m pytest tests/ -p no:cacheprovider

验收:-m postgres 用例全过(含并发首次入库、并发出库不超卖、并发单号不撞号);全量 PG 下 0 failed。 若 PostgreSQL 起不来:不要伪造结果,在报告里写明"环境不可用,跳过",并把命令留在这里。


P1-2 前端表单校验审计收尾(Vouchers / Channel / StorefrontAdmin)

已核实的缺口(本次 grep 实测):

页面 现状
frontend/src/pages/Vouchers.vue:63-71 裸 <el-form>,无 ref、无 :rules、无 validate()
frontend/src/pages/Channel.vue:47-60 同上,App Key / 店铺ID / 平台 全裸输入
frontend/src/pages/StorefrontAdmin.vue 整页没有 <el-form>(确认/驳回等操作用 ElMessageBox.confirm 硬弹)

方法(迭代 2 已验证有效,必须照做): 用 Playwright 打开页面,实际填入非法值,确认三件事同时成立:① 被拦 ② 标红 ③ 有可读提示。 不许只看代码里"有 rules"就判定合格。

参考实现:frontend/src/pages/SalesBills.vue(已改好,751 行,有 isValidQty / isValidPrice 与 .price-invalid 标红)+ frontend/src/utils/transaction.js(纯函数校验,配 Node 单测)。

做法:

  1. 抽出 frontend/src/utils/validate.js:required、positiveAmount、nonEmptyArray、shopId、appKey 等,与 transaction.js 同风格(纯函数 + Node 原生 node --test 单测)。
  2. Vouchers.vue:表单加 ref + :rules;提交前 await formRef.value.validate();分录借贷不平衡在前端先拦(后端 apps/core/exceptions.py 已有统一异常映射,前端要能读 code)。
  3. Channel.vue:平台/店铺ID 必填校验;App Key 留空是合法语义(走 Mock),不能当错误拦。
  4. StorefrontAdmin.vue:至少给"确认订单"加二次确认摘要(订单号 + 金额,已有)+ 失败可恢复提示。

不要踩的坑(迭代 2 记录):Element Plus el-input-number 的 :min 是自动纠正不是校验——填 0 会被静默改成 0.0001。所以 :min 要允许非法值(设 0),让非法值如实呈现,由显式校验函数拦截。

验收:

cd frontend && npm run build && npm test

外加 Playwright 逐页填非法值截图(存 frontend/tests/ 或 .playwright-mcp/)。


P1-3 审计日志保留 / 归档策略

已核实:完全没有。 grep retention|归档|archive|purge 在 apps/core/audit.py、apps/core/audit_views.py 里零命中,AuditLog 表无限增长(演示库已有数百条,生产会更快)。

做法:

  1. 新增 management command apps/core/management/commands/purge_audit_logs.py:
    • 参数 --days N(保留窗口)、--dry-run(只报数不删)、--batch SIZE(分批 delete,避免长事务锁表)
    • 默认 --days 180,必须显式传 --yes 才真删(防手滑)
  2. 归档(可选加分):删除前先 --export path.jsonl 落一份 NDJSON,便于合规审计。
  3. 定时任务:apps/billing/tasks.py / apps/notify/tasks.py 已有任务队列范式(Dramatiq),照抄加一个每日 purge。
  4. 保留策略写进 README.md(客户会问"日志存多久")。

验收 tests/test_audit_retention.py:

  • 超窗日志被删、窗内保留
  • --dry-run 一条不删
  • 不传 --yes 不删
  • 分批删除在大量数据下不超时/不锁表
  • 审计查询 API 删旧数据后仍正常(分页 count 正确)

P1-4 生产配置两处硬伤(已实测确认)

(a) dealerhub logger 在生产仍是 DEBUG 级别 🔴

用 prod settings 实跑验证:

dealerhub              level=DEBUG     ← 应该是 INFO
django                 level=INFO
django.db.backends     level=WARNING

根因:config/settings/base.py:187 已定义 dealerhub logger 为 level=DEBUG;prod.py 用 LOGGING["loggers"].setdefault("dealerhub", ...) 试图覆盖——但 setdefault 对已存在的 key 不生效,所以覆盖失败。

生产后果:dealerhub 命名空间下的每一条 DEBUG 日志都会写进日志文件,磁盘暴涨、I/O 压力、敏感信息(查询参数、业务数据)落盘。

修法(不要用 setdefault):

# prod.py
LOGGING["loggers"]["dealerhub"] = {
    "handlers": ["console"], "level": "INFO", "propagate": False,
}

并对 django 等也用直接赋值,避免同一个坑。

验收:DJANGO_SETTINGS_MODULE=config.settings.prod python -c "...print(settings.LOGGING['loggers']['dealerhub']['level'])" → 输出 INFO。

(b) 生产 ALLOWED_HOSTS 默认 ["*"] 🟠

config/settings/prod.py:

ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["*"])

生产环境未显式配 DJANGO_ALLOWED_HOSTS 时会接受任意 Host 头,配合 SECURE_PROXY_SSL_HEADER 可被用于 Host 头注入 / 缓存投毒 / 密码重置链接劫持。

修法:默认值改为空列表,启动时若为空则 raise ImproperlyConfigured,强制部署者显式声明域名。别给"忘了配也能跑"的机会。

(c) 加分项:生产安全配置整体是好的

顺带确认,prod.py 的这些是对的,别去动:DEBUG=False、SESSION_COOKIE_SECURE、CSRF_COOKIE_SECURE、HSTS 30 天 + include_subdomains + preload、SECURE_CONTENT_TYPE_NOSNIFF、SECURE_REFERRER_POLICY=same-origin、X_FRAME_OPTIONS=DENY、PG 连接池(min 2 / max 10 / timeout 10)。


P2-1 Channel / StorefrontAdmin 分页补齐

StorefrontAdmin.vue 完全没有 <el-pagination>,但后端 apps/core/viewset.py:StandardAsyncPagination 默认 PAGE_SIZE=20 —— 也就是说订单超过 20 条时前端只能看到前 20 条,且没有任何提示。Vouchers.vue:52-57 已有分页可作为模板。

做法:照抄 Vouchers.vue 的分页写法,接后端返回的 count。

验收:造 25 条以上数据,翻页可见第 21 条。


P2-2 部署 readiness gate + 上线 192.168.5.7(依赖外部环境)

迭代 5 已把 backend/infra/docker/Dockerfile.backend 改成 Node+Python 多阶段构建,docker-compose.yml 构建上下文也修了,但从未真正跑过一次镜像构建(当时的记录:需要 Docker Desktop)。

做法:

cd "C:/Users/12914/Desktop/gj/dealerhub"
DJANGO_SECRET_KEY='x' POSTGRES_PASSWORD='x' \
  docker compose -f backend/infra/docker/docker-compose.yml config     # 先验插值
DJANGO_SECRET_KEY='x' POSTGRES_PASSWORD='x' \
  docker compose -f backend/infra/docker/docker-compose.yml build backend
docker compose -f backend/infra/docker/docker-compose.yml up -d
# 冒烟:ping / 登录 / 开单 三接口

再加一个 readiness gate 清单(可写成 docs/DEPLOY_CHECKLIST.md):迁移已应用、collectstatic 已跑、DJANGO_DEBUG=False、PG 已切(SQLite 不适合生产并发)、HTTPS、日志轮转、备份策略。

这是本清单唯一会写到生产环境动作的项。 需要 Docker / 服务器凭证,环境不具备就停在"写好清单 + 本地 config 校验",不要伪造部署成功。


P2-3 D2 业务员移动开单 H5(计划内、未启动)

PROGRESS_BATCH_D.md:236 明确 D2 未开始,且工作量小(D1 的 apps/storefront H5 外壳 + SalesBills.vue 的取价/单位/抹零组件都在)。

做法:复用 frontend/src/pages/Storefront.vue(348 行,已有 H5 布局 + src/api/storefront.js),换角色为"业务员":外勤开单(调 A1 的取价/单位/抹零)、查欠款、收款登记。

验收:手机浏览器全流程走通(Chrome DevTools 移动模拟即可)。


P3-1 D3 行业专版(按客户驱动,不要提前做)

PROGRESS_BATCH_D.md:238 的定调是对的:拿下哪个行业客户做哪个。

  • 3C → 序列号 SN(inventory.Serial + Product.is_serial_managed,代码里目前完全没有,实测 grep 零命中)
  • 服装 → 辅助属性(色/码)
  • 建材 → 套装拆子件

现在做就是猜需求。


§附录 A 后端已有、前端零接入的能力(本次逐路由核对结果)

核对方法:backend/config/urls.py + 各 apps/*/urls.py 的全部路由 ↔ frontend/src/pages/* 里实际出现的 API 路径。

# 后端能力 路由 前端现状 影响
1 收款单 + 核销 POST /finance/receipts/、/{id}/allocate/ 无任何页面 🔴 客户还钱无处登记,应收永远挂账
2 付款单 + 核销 POST /finance/payments/、/{id}/allocate/ 无任何页面 🔴 欠供应商的钱无处登记
3 核销记录 GET /finance/allocations/ 无页面 查不到"这笔款核了哪几张单"
4 库存流水 GET /inventory/movements/ Stocks.vue 只读库存余额 出入库历史不可见,对账无从下手
5 预警规则管理 GET/POST/PATCH /notify/rules/、init-default/ Notify.vue 只有"立即执行扫描" A4 计划项未完成:规则改不了,只能吃默认
6 打印模板管理 GET/POST/PATCH /printing/templates/ 无页面 用户改不了模板,{{#if}} 能力白做
7 自定义报表 GET /report/definitions/、/{id}/run/、init-system/ 无页面 报表元数据白做
8 库存状态大盘 GET /report/dashboard/inventory-status/ Dashboard 未调用 有接口没卡片
9 科目管理 / 一键初始化 GET /finance/accounts/、init-chart/ 无页面 新租户建账科目靠命令行 init_chart_of_accounts
10 价格等级 / 联系人 GET /partner/price-levels/、/contacts/ 只在 Customers.vue 里读下拉 改不了等级折扣,取价四级优先级形同摆设

这份表说明:批次 A/B/C/D 的"✅ 完成"多半是"后端完成",前端交付被系统性低估。下一轮做前端时要按这张表逐项验收,不要信 PROGRESS_*.md 的勾。

建议优先级:#1 #2(收款/付款)是唯一影响"业务能不能跑完"的,放第一批;#4 #5 影响日常使用,第二批;#6–#10 是体验补全。


§附录 B 其它零散发现(顺手可修)

项 位置 说明
官网 pricing 模板路径 apps/website/templates/website/(base/home/industry/pricing.html) CTA 要接到 P0-3 的注册页
密码哈希强度 config/settings/test.py 用 MD5PasswordHasher 仅测试环境,生产确认走默认 PBKDF2(base.py 未覆盖,OK,但要在 deploy checklist 里显式确认)
异常类名泄露 apps/core/exceptions.py 兜底分支回传 exc_type 见 P0-4:把内部异常类名给了客户端,顺手去掉
登录/商城主密码无失败计数 apps/storefront/views.py:71-109 见 P0-5
SECRET_KEY config/settings/base.py:23 经 env() 读环境变量 好;但 .env 里 dev-insecure-change-me-IN-PROD-PLEASE 必须确保不进生产镜像(Dockerfile 的 COPY 范围要复查)
前端 token 刷新 frontend/src/api/client.js 检测到 401 直接踢回登录页;SIMPLE_JWT.ACCESS_TOKEN_LIFETIME=1h,用户每小时被踢一次。应加 refresh token 静默续期(后端 /auth/token/refresh/ 已存在)
Django 日志噪音 base.py:187 dealerhub logger level=DEBUG 生产会刷屏,prod.py 里应调 INFO
manage.py makemigrations --check 本次实测 ✅ No changes detected membership 迁移生成正确,不是问题
manage.py check 本次实测 ✅ 0 issues 静态配置干净

§附录 C 本次核查用到的命令(复现基线)

# 测试现状(口径见 §附录 E)
cd backend && python -m pytest tests/ -p no:cacheprovider -q    # 468 用例,5 红 4 跳过
python -m pytest -p no:cacheprovider -q                         # 482 用例(含 core/tests)

# 静态检查(均通过)
python manage.py check
python manage.py makemigrations --check --dry-run

# 路由 ↔ 前端覆盖核对
cat config/urls.py; cat apps/*/urls.py
grep -rhoE "api\.(get|post|put|patch|delete)\(\s*[\`'\"][^\`'\"]+" ../frontend/src/ \
  | sed -E "s/.*[\`'\"]//" | sort -u

# 找未接能力的证据
grep -rn "receipt\|payment\|allocation\|movement\|price-levels" ../frontend/src/pages/
grep -rn "TODO\|FIXME" apps/ config/ --include=*.py
grep -rn "def register\|def signup\|create_tenant" apps/ config/ --include=*.py

# P0-4 根因验证:确认 DRF 因 tenant 不在 fields 而丢弃唯一性校验
python -c "
import django, os
os.environ.setdefault('DJANGO_SETTINGS_MODULE','config.settings.test'); django.setup()
from apps.catalog.serializers import ProductSerializer
print('validators:', ProductSerializer().validators)          # → [] 空!
print('unique_together:', ProductSerializer.Meta.model._meta.unique_together)
print('tenant in fields:', 'tenant' in ProductSerializer.Meta.fields)   # → False
"

# 文档宣称核对(软删除 / 多组织 / 钩子)
grep -rn "is_deleted=True" apps/ --include=*.py | grep -v __pycache__   # 只有 1 处
grep -rn "org=\|Org.objects" apps/ --include=*.py | grep -v migrations  # 只有 seed
grep -rn "HookRegistry" apps/ config/ --include=*.py                    # 零命中

# 性能基线(11 端点全部达标)
python -c "
import json; d=json.load(open('perf_baseline.json'))
for r in d['results']:
    print('OVER' if r['p95_ms']>r['threshold_ms'] else 'ok', r['endpoint'], r['p95_ms'], '/', r['threshold_ms'])
"

§附录 D 文档宣称 vs 代码实际(逐项实测核对)

项目 README.md / PLAN.md 有一批"✅ 已落地"的宣称,我逐个用 grep + 实跑核对,有三项是纸面的:

宣称(原文) 实际核实结果 判定
README.md:132 ✅ 软删除(is_deleted) 半成品:DELETE /catalog/products/{id}/ 实测把记录物理删除(Product.objects.filter(pk=pid).exists() → False)。全仓只有 1 处写 is_deleted=True(storefront/views.py:410),且没有任何 destroy() 覆写。is_deleted 的 84 处引用全是读过滤(active() manager + 查询里 is_deleted=False) ⚠️ 删得掉但没有"软"
README.md:130 ✅ 多组织预留(Org 模型 + org_path) 空壳:Org 只在 seed_demo / seed_initial_data 里建了一条"总部",业务代码零引用(org= 只在 base_models.py 的 manager 里出现) ⚠️ 无实际使用
README.md:143 ⏳ 插件钩子(HookRegistry) 全仓 HookRegistry / hook_registry 零命中,根本没建 ❌ 不存在(还好标的是 ⏳)
README.md:129 ✅ 多租户单数据库 真实,tenant_id 全表隔离 + TenantMembershipPermission ✅
README.md:133 ✅ 扩展 JSON(ext_data) 字段存在,50 处引用 ✅
README.md:134 ✅ 来源渠道(source_channel) 字段存在,50 处引用 ✅

处理建议:

  • 软删除:要么兑现(在 BaseTenantViewSet 覆写 adestroy() 改 is_deleted=True,并给所有 unique_together 加软删除感知——否则删掉的编码会永久占用,见 P0-4 注意事项),要么从 README 删掉这条。别留着"✅"误导人。
  • 多组织:Org 是 PLAN 里"分销 ERP 多机构独立核算"的地基,当前无业务引用。保留但把 README 的 ✅ 改成 ⏳ 预留,别声称"已落地"。
  • 这类核对必须持续做——本项目已多次出现"文档说完成、代码没做"(P0-3 注册、附录 A 十项前端、本节三项)。

§附录 E 测试口径陷阱(会导致所有人算出不同数字)

实测数据

跑法 用例总数
pytest tests/(README / 历轮 PROGRESS 都用这个) 468
pytest apps/core/tests 14
pytest(全量,不指定路径) 482
当前失败 5(全在 tests/)
当前跳过 4(PG 专用,tests/test_concurrency.py)

陷阱:apps/core/tests/test_workflow.py 的 14 个用例在 pytest tests/ 下不被收集(因为显式指定了 tests/ 目录)。所以:

  • 历轮 PROGRESS 里"460 passed"这类数字,口径是 tests/,漏了 core 的 14 个
  • 任何"总用例数"必须写明跑法,否则对不上

建议:在 pytest.ini 里加 testpaths = tests apps,让默认 pytest 覆盖全部,然后把 README 的数字统一成全量口径(482)。这样以后没人会漏跑。

顺带:目前 5 个失败全部来自 membership 改造(P0-1),修完应为 477 passed / 4 skipped(482 - 5 = 477)。这个数字要用实跑确认,别照抄。


§附录 F 性能基线现状(本次实测:全部达标,不是瓶颈)

backend/perf_baseline.json 记录了 11 个端点的 p50/p95 与阈值,逐项核对后无一超标:

端点 p50 p95 阈值 判定
dashboard_summary 16.6 18.5 300 ✅
sales_rank 7.5 7.9 300 ✅
product_list (50条) 43.3 54.6 200 ✅
sales_bill_list (50条) 30.5 50.2 250 ✅
stock_list 12.5 13.4 200 ✅
batch_list 11.0 12.7 200 ✅
receivable_aging 11.5 12.4 400 ✅
notify_list 9.4 10.3 200 ✅
risk_ranking 109.7 117.3 800 ✅(但最慢)
billing_subscription 9.8 10.9 200 ✅
audit_logs 10.5 31.6 300 ✅

结论:

  • 别做无谓的性能优化。当前所有端点都有 3–8 倍余量。
  • risk_ranking 是唯一值得关注的(110ms,第二慢的 product_list 才 43ms)。迭代 4 已记过"遍历客户算分,可加短 TTL 缓存"——数据量涨到千级客户时会先崩。但这是 P2,现在别动。
  • 这份基线是旧数据(base: http://127.0.0.1:9710,用的是 demo 租户),改造后要重跑刷新。

§附录 G 数据与遗留清理

项 位置 处理
4 个 dev server 日志 backend/g8060.log(2KB) g8070.log(400KB) g8080.log(2.5KB) g8090.log(98KB) 加入 .gitignore;g8070 是 P0-4 现场,修完可删
g8080.log 报错 RuntimeError: os error 10013 端口占用,非代码缺陷,别去"修"
根目录 frontend_dist.zip(441KB) gj/frontend_dist.zip Docker 多阶段构建已取代它,确认无引用后删
backend/db.sqlite3 本地开发库 进 .gitignore(已在 §0 模板里)
演示数据 seed_demo 造的 22 商品/15 客户/90 单据 留在库里可复用,别当垃圾清掉——它是 Playwright 验收和 AI 演示的基础

§附录 H README「待落地」清单反向失真(8 项里 5 项其实已做完)

README 同时存在两个方向的失真:已落地 段有纸面功能(§附录 D),待落地(阶段 3) 段又有已完成项。逐项实测:

README 标注 实测结果 判定
⏳ 多币种/多税率 tax_rate 命中 11 个文件(catalog/models.py:132、sales/models.py:141、purchase/models.py:143),tests/test_tax.py 25 个用例 ✅ 已做完(多币种已弃,税率已实现)
⏳ 电商平台对接(ChannelAdapter) apps/channel/adapters.py 有完整抽象:ChannelAdapter(ABC) + DouyinChannelAdapter + Alibaba1688Adapter + MockChannelAdapter,配 test_channel_adapters.py ✅ 已做完
⏳ 报表元数据 ReportDefinition 命中 5 个文件,含 run_report / init-system action ✅ 已做完
⏳ AI Agent 接入点 apps/ai/ 共 1164 行,三个能力(risk/orders/ask)全套 + 3 个测试文件 ✅ 已做完
⏳ 开放 API + Webhook APIKey 命中 6 个文件;WebhookViewSet 存在且实测需鉴权(未登录 401) ✅ 已做完
⏳ 工作流引擎(StateMachine 服务化) apps/core/workflow.py 存在,但全仓只有它自己的测试文件引用,业务代码零接入 ⚠️ 半成品(写了没用)
⏳ 财务模块对接接口(LedgerAdapter) 全仓 0 命中 ❌ 确实未做
⏳ 插件钩子(HookRegistry) 全仓 0 命中 ❌ 确实未做

处理建议:README 的这两段整体重写,以实跑 + grep 为准。对外(官网/话术)尤其危险——把做完的说成没做会削弱卖点,把没做的说成做完会砸口碑。

StateMachine 的处置:要么接进单据状态流转(create_sales_bill → confirm_sales_bill 等目前是散写的状态赋值),要么删掉。留着"写了但业务不用"的框架是负资产——下个模型会以为它在生效。


§附录 I 已排除的怀疑(别再重复调查这些)

核查中我怀疑并实测排除的问题,记录下来避免下个模型重走:

曾经的怀疑 实测结论 证据
Webhook 接口可匿名伪造订单事件 安全 实测未登录 POST /channel/webhooks/inbound/ → 401,功能不创建任何事件
商城 token 可跨租户读数据 安全 拿 A 租户商城 token + B 租户 X-Tenant-Id 请求 → 404;catalog_for(account.customer) 按账号自身租户隔离
重复编码会级联污染后续请求 生产不会 见 P0-4 附带教训的三组对照实验
AsyncModelSerializer 基类有缺陷 无责任 它只加了 asave/acreate,继承链正常;P0-4 根因是 tenant 不在 Meta.fields
supplier 首次创建就 500 误报 被同一测试内前一个唯一约束冲突污染,隔离跑是 201
g8080.log 的报错是代码缺陷 环境问题 os error 10013 = 端口占用
前端构建可能已损坏 正常 npm run build ✅ 12.66s;npm test ✅ 3/3 passed
性能可能有瓶颈 全部达标 11 端点 p95 均低于阈值 3–8 倍,见 §附录 F

执行顺序(一张图)

§0 git init 基线(项目无版本控制,先做)
 │
 ├─ 第 1 批(阻断项,必须连续做完)
 │    P0-1 修 membership(5 个红测试)
 │      └─→ P0-2 落文档 / 更新测试数字
 │            └─→ P0-4 重复编码 500(28 模型,一处修)
 │                  └─→ P0-5 登录限流 + API Key 限流
 │                        └─→ P0-3 补注册闭环(商业化断点)
 │
 ├─ 第 2 批(业务闭环)
 │    P1-1 PG 并发复跑
 │      └─→ 附录A #1#2 收款/付款/核销页 ← 业务最后一块
 │            └─→ P1-2 前端表单校验审计
 │                  └─→ 附录A #4#5 库存流水 / 预警规则
 │
 ├─ 第 3 批(体验与运维)
 │    P1-3 审计日志保留
 │      └─→ P2-1 分页补齐
 │            └─→ P2-2 部署(需要外部环境 + 凭证)
 │
 └─ 第 4 批(按需)
      P2-3 移动开单 → P3-1 行业专版(按客户驱动)

四条硬边界:

  • P0-1 不修完,后面全部没意义——测试是红的,任何新改动都无法判断是否引入回归。
  • P0-4 是产品级 bug——用户重复录编码得到"服务端异常",还泄露内部异常类名。(不会级联污染,见 P0-4 的"重要更正")
  • P0-5 是安全缺口——登录可无限撞库,API Key 限流是假的。
  • P0-3 不补完,"最小可售闭环"是假的——注册动作不存在,付费客户进不来。

附录 A 的 #1#2 不补完业务跑不完整——开了销售单挂了应收,客户还钱却没地方登记。


硬性规矩(沿用本项目既有惯例)

  1. 不写纸面功能:文档/话术只提已跑通、有测试的特性(docs/差距分析与改造清单.md 问题 7 的教训)。税率/多币种是前车之鉴,§附录 D 的软删除/多组织是新的三个。
  2. 每批次产出 PROGRESS_*.md:变更文件清单 + 踩坑记录 + 实跑的最终测试数字。
  3. 测试数字必须实跑,且必须写明跑法(tests/ 还是全量,口径差 14 个用例,见 §附录 E)。
  4. 守门测试必须先证明它能失败(迭代 1 的教训:pytest 版迁移检查曾假通过两次)。
  5. 前端校验不是防线,后端必须独立校验同类约束(迭代 2 的教训)。
  6. 会触发 DB 异常的测试必须用 @pytest.mark.django_db(transaction=True)(本次新教训,见 P0-4 附带教训)。
  7. 能力不可用就换载体,不换目标:PG 起不来就写"环境不可用 + 命令",不伪造结果。
  8. 报 bug 前做对照实验:我这次差点把一个测试环境假象(级联 500)当 P0 报上去。单次观察 ≠ 结论,换条件复现再下判断。

已知残留(不急,但别被它们绊倒)

  • tests/test_audit_api.py、test_printing.py 里用 APIClient.credentials() 设的是持久 header;单次请求传 HTTP_X_TENANT_ID= 覆盖不了它,必须新建 client。这一点测试注释里写了,改测试时别踩。
  • config/settings/test.py 禁用了迁移,所以任何依赖 RunPython 回填的逻辑在测试里都不生效。数据回填类改动要么由 seed 命令兜底,要么单独写"开迁移跑"的集成测试。
  • backend/apps/core/migrations/0002_tenantmembership.py 里 from app... 无 import,实际调用的是 settings.AUTH_USER_MODEL.split('.') 拼出的 app 名,能跑但可读性差,顺手可清理。
  • frontend/src/pages/FinanceReports.vue 已接好账龄 + 对账单 + 分享(533 行,本次实测),不要重复实现。
  • apps/billing 配额已接进 catalog/views.py、ai/views.py(实测有 check_and_count 调用),也是"已完成"状态。
  • PROGRESS_BATCH_D.md 的 D 状态是乐观的:D4 税率(sales/models.py:141、purchase/models.py:143 确有 tax_rate)和 D5 打印是真做了,但"最小可售闭环"因为缺注册(P0-3)实际没闭环。核对文档勾选前先看 §附录 A。
  • 本次实测 python manage.py check = 0 issues、makemigrations --check = No changes detected,这两项是干净的,别去修不存在的问题。
  • 遗留 4 个 dev server 日志:backend/g8060.log(2KB)、g8070.log(400KB)、g8080.log(2.5KB)、g8090.log(98KB)。其中 g8070 就是 P0-4 那个 500 的历史现场(IntegrityError: UNIQUE constraint failed: partner_customer.tenant_id, partner_customer.code),修完 P0-4 可删。
  • g8080.log 的 RuntimeError: os error 10013 是端口被占用,不是代码缺陷,删日志即可,别去"修"。
  • FK 自带索引:TenantScopedModel.tenant 是外键,Django 自动建索引。所以"tenant 过滤慢"在数据量不大时不是瓶颈,不要凭"多租户就该加索引"盲目加——先 EXPLAIN ANALYZE 再说话(审查模型索引时同理)。
  • apps/catalog/serializers.py / apps/partner/serializers.py 目前只有 Meta.fields,没有任何 validators——这是 P0-4 的根因所在,读这两个文件时注意 AsyncModelSerializer 是否绕过了 DRF 自动生成的 UniqueTogetherValidator。