823 lines
48 KiB
Markdown
823 lines
48 KiB
Markdown
# 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`。
|