48 KiB
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.logg8070.logg8080.logg8090.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(当前权限类零测试覆盖)
必须覆盖的用例:
- 无 membership + 有效 JWT → 403,body
code == "tenant_membership_required" - 有 A 租户 membership、请求头写 B 租户 → 403
is_active=False的 membership → 403- 未知租户 code → 400,body 含
tenant - 超级用户无 membership → 放行(依赖 (a) 的策略)
- API Key 绑定租户:不带头或带同租户 → 放行;显式带其他租户 → 403(
permissions.py:33-41已有实现,补测试) - demo token 进入 demo 租户只读可读 → 200
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/—— 没有 registerapps/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 声称"最小可售闭环达成" |
实际链路:官网 → 「注册」→ 建租户 → 用系统。 第二步是空的。 客户点进来只能看演示账套,无法开始用。所谓"最小可售闭环"没有达成。
做法
- 后端
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 位
- 入参:
- 前端
Login.vue加"免费注册"链接 → 新建Register.vue:公司名 / 用户名 / 密码 / 确认密码,提交后存 token + tenant 并跳 dashboard。 - 官网
apps/website/templates/website/pricing.html的免费版 CTA 指向/register。 - 配额: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);check0 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 字段外无任何读取处 |
文档宣传的"限流"是纸面的,第三方可无限打 |
做法:
- 给
TokenObtainPairView和storefront.login加失败计数 + 锁定:按(用户名+IP)记 Redis/缓存,连续 N 次失败锁 M 分钟;或直接上django-ratelimit(@ratelimit(key='ip', rate='10/m'))。 - 把
APIKey.rate_limit接进认证类(apps/openapi/auth.py的authenticate()):按 key 记分钟窗口计数,超限抛Throttled;响应带Retry-After。 - 顺手给
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 单测)。
做法:
- 抽出
frontend/src/utils/validate.js:required、positiveAmount、nonEmptyArray、shopId、appKey等,与transaction.js同风格(纯函数 + Node 原生node --test单测)。 Vouchers.vue:表单加ref+:rules;提交前await formRef.value.validate();分录借贷不平衡在前端先拦(后端apps/core/exceptions.py已有统一异常映射,前端要能读code)。Channel.vue:平台/店铺ID 必填校验;App Key 留空是合法语义(走 Mock),不能当错误拦。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 表无限增长(演示库已有数百条,生产会更快)。
做法:
- 新增 management command
apps/core/management/commands/purge_audit_logs.py:- 参数
--days N(保留窗口)、--dry-run(只报数不删)、--batch SIZE(分批 delete,避免长事务锁表) - 默认
--days 180,必须显式传--yes才真删(防手滑)
- 参数
- 归档(可选加分):删除前先
--export path.jsonl落一份 NDJSON,便于合规审计。 - 定时任务:
apps/billing/tasks.py/apps/notify/tasks.py已有任务队列范式(Dramatiq),照抄加一个每日 purge。 - 保留策略写进
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 不补完业务跑不完整——开了销售单挂了应收,客户还钱却没地方登记。
硬性规矩(沿用本项目既有惯例)
- 不写纸面功能:文档/话术只提已跑通、有测试的特性(
docs/差距分析与改造清单.md问题 7 的教训)。税率/多币种是前车之鉴,§附录 D 的软删除/多组织是新的三个。 - 每批次产出
PROGRESS_*.md:变更文件清单 + 踩坑记录 + 实跑的最终测试数字。 - 测试数字必须实跑,且必须写明跑法(
tests/还是全量,口径差 14 个用例,见 §附录 E)。 - 守门测试必须先证明它能失败(迭代 1 的教训:pytest 版迁移检查曾假通过两次)。
- 前端校验不是防线,后端必须独立校验同类约束(迭代 2 的教训)。
- 会触发 DB 异常的测试必须用
@pytest.mark.django_db(transaction=True)(本次新教训,见 P0-4 附带教训)。 - 能力不可用就换载体,不换目标:PG 起不来就写"环境不可用 + 命令",不伪造结果。
- 报 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。