Files
dealerhub/NEXT_PLAN.md
T

823 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/回滚。先建基线:
```bash
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 查询**之前**先判断租户是否存在:
```python
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"):
```python
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 行)。
```python
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 就调接口):
```python
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 一遍还有没有漏网的:
```bash
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 验收
```bash
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(**已实测复现;严重度经二次验证后下调**)
### 已复现的核心问题
新写临时测试打真实接口:
```python
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`)。
```python
# 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 缺的那一半信息:
```python
# 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`)
执行:
```bash
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),让非法值如实呈现,由显式校验函数拦截。
验收:
```bash
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):
```python
# 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`:
```python
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)。
做法:
```bash
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 本次核查用到的命令(复现基线)
```bash
# 测试现状(口径见 §附录 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`。