# 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=, 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` | 裸 ``,**无 `ref`、无 `:rules`、无 `validate()`** | | `frontend/src/pages/Channel.vue:47-60` | 同上,App Key / 店铺ID / 平台 全裸输入 | | `frontend/src/pages/StorefrontAdmin.vue` | **整页没有 ``**(确认/驳回等操作用 `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` 完全没有 ``,但后端 `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`。