diff --git a/CHANGELOG b/CHANGELOG index 34ce0b3..091f594 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ # CHANGELOG +- [2026-09-26 08:00] TEST+FIX: v2.34 第二轮深度审计——越权/引用完整性/公开面/持久化 16/16 PASS。新增 scripts/audit_deep.py(直插双公司数据验证 company_id 隔离/跨公司按 ID 直访/登记收款越权/公开接口范围/重启后数据存活)。复查修复 4 处真实缺陷: ①删除有下游单据的客户/合同无校验(会产生孤儿合同/订单, 收款统计 join 缺口)→ 引用完整性拒绝(客户: 合同/量尺/跟进; 合同: 订单/有效收款) ②Service 层误用 self.db(实际会话在 self.repo.db)→AttributeError 500 ③删除路由未捕获 ValueError(保护性失败变 500) ④已完工订单可删(生产留档要求禁删); 公开分享页无头验证渲染正确(标题/明细/合计, 0 JS 异常); 冒烟断言防残留加固(跟进列表 >=1); 深度审计+冒烟 52+16 项全 PASS (Files: src/huamenchuang/services/customer_service.py, src/huamenchuang/services/contract_service.py, src/huamenchuang/services/order_service.py, src/huamenchuang/api/customers.py, src/huamenchuang/api/contracts.py, src/huamenchuang/api/orders.py, src/huamenchuang/scripts/audit_deep.py, src/huamenchuang/scripts/smoke_all.py) + - [2026-09-25 23:30] TEST+FIX: v2.33 26 项功能落地性复查——综合冒烟 52/52 PASS + 23 页巡检 0 异常。新增 scripts/smoke_all.py(26 项逐项接口冒烟: 每项验证创建/查询/联动/校验拦截全链路)+frontend/scripts/verify-pages.mjs(23 页无头巡检收集 JS 异常)。复查发现并修复 6 处: ①order_format 校验缺 barcode 类型(条码块模板无法保存) ②workshop/scan 缺 selectinload(MissingGreenlet 500) ③pickout 仅匹配单库存项(改跨批次累计扣减) ④照片模式洞口位置未传 AI(rect 相对坐标→英文方位描述进提示词, 引导装窗位置; 附 mime 嗅探修正 jpeg/png) ⑤打印条码 svg ref 脱离 DOM 残留(isConnected 过滤) ⑥AI 推荐只打分前 80 套(改全量 254 防分类偏倚); 冒烟残留数据已清理 (Files: src/huamenchuang/api/ai_render.py, src/huamenchuang/services/ai_prompt.py, src/huamenchuang/api/order_format.py, src/huamenchuang/api/workshop.py, src/huamenchuang/api/warehouse.py, src/huamenchuang/api/recommend.py, frontend/src/views/AIRenderView.vue, frontend/src/views/PrinterView.vue, src/huamenchuang/scripts/smoke_all.py, frontend/scripts/verify-pages.mjs) - [2026-09-25 22:40] FEAT: v2.32 P1 批次4 CRM+仓库域 5 项。①客户跟进: CustomerFollow 模型+/customers/{id}/follows CRUD+客户页跟进对话框(timeline 历史+新增/下次日期); ②业务员体系: Salesman 模型+/api/salesmen CRUD+statistics 业绩(名下客户/合同额/预估提成)+客户页业务员 Tab+客户表单业务员选择; ③余料管理: surplus-in(读合同方案按最小长度入库 note=surplus)+surplus 列表+下料方案页余料入库按钮+仓库页余料筛选; ④领料: pickout(按领料清单扣库存+流水, 缺料报缺口)+下料方案页领料出库按钮; ⑤采购单: PurchaseOrder 模型(CG+日期+流水)+POST 自动读缺料清单/手动明细+receive 入库联动(库存+StockMove)+仓库页采购单 Tab(状态/入库); 实测: 跟进/业绩/余料 2 根入库/领料 5 根出库/采购 CG001 入库联动全通; 254 套全量校验 0 失败; vue-tsc 0 错+构建通过 (Files: src/huamenchuang/models/business.py, src/huamenchuang/api/customers.py, src/huamenchuang/api/salesmen.py, src/huamenchuang/api/purchases.py, src/huamenchuang/api/warehouse.py, src/huamenchuang/api/router.py, frontend/src/views/CustomersView.vue, frontend/src/views/CutSolutionView.vue, frontend/src/views/WarehouseView.vue, doc/API.md) diff --git a/shots/verify-share-page.png b/shots/verify-share-page.png new file mode 100644 index 0000000..9ee3b28 Binary files /dev/null and b/shots/verify-share-page.png differ diff --git a/src/huamenchuang/api/contracts.py b/src/huamenchuang/api/contracts.py index bcab1d0..faac193 100644 --- a/src/huamenchuang/api/contracts.py +++ b/src/huamenchuang/api/contracts.py @@ -122,9 +122,11 @@ async def convert_contract(body: ConvertBody, user: CurrentUser, db: DbSession): @router.delete("/{contract_id}") async def delete_contract(contract_id: int, user: CurrentUser, db: DbSession): - """删除合同。""" + """删除合同(有订单/有效收款时拒绝)。""" try: await ContractService(db).delete(user, contract_id) except KeyError as e: return fail(str(e)) + except ValueError as e: + return fail(str(e)) return ok(message="删除成功") diff --git a/src/huamenchuang/api/customers.py b/src/huamenchuang/api/customers.py index 7d968c8..0fc4e63 100644 --- a/src/huamenchuang/api/customers.py +++ b/src/huamenchuang/api/customers.py @@ -62,11 +62,13 @@ async def update_customer(customer_id: int, body: CustomerBody, user: CurrentUse @router.delete("/{customer_id}") async def delete_customer(customer_id: int, user: CurrentUser, db: DbSession): - """删除客户。""" + """删除客户(有合同/量尺/跟进时拒绝)。""" try: await CustomerService(db).delete(user, customer_id) except KeyError as e: return fail(str(e)) + except ValueError as e: + return fail(str(e)) return ok(message="删除成功") diff --git a/src/huamenchuang/api/orders.py b/src/huamenchuang/api/orders.py index de4f2c9..236557b 100644 --- a/src/huamenchuang/api/orders.py +++ b/src/huamenchuang/api/orders.py @@ -133,11 +133,13 @@ async def update_order(order_id: int, body: OrderUpdate, user: CurrentUser, db: @router.delete("/{order_id}") async def delete_order(order_id: int, user: CurrentUser, db: DbSession): - """删除订单(软动作:记日志后物理删除)。""" + """删除订单(已完工订单拒绝)。""" try: await OrderService(db).delete_order(user, order_id) except KeyError as e: return fail(str(e)) + except ValueError as e: + return fail(str(e)) return ok(message="删除成功") diff --git a/src/huamenchuang/scripts/audit_deep.py b/src/huamenchuang/scripts/audit_deep.py new file mode 100644 index 0000000..0db8da4 --- /dev/null +++ b/src/huamenchuang/scripts/audit_deep.py @@ -0,0 +1,134 @@ +"""深度审计:越权隔离 / 引用完整性 / 公开接口范围 / 持久化(输出 PASS/FAIL)。""" +import asyncio +import json +import sys + +import httpx + +BASE = "http://localhost:8000" +results = [] + + +def check(name, cond, detail=""): + results.append((name, "PASS" if cond else f"FAIL {detail}")) + + +async def main(): + # ---- 造数:公司2 用户 + 数据(直插,绕过注册默认 company_id=1)---- + from huamenchuang.core.database import SessionLocal, init_db + await init_db() + from huamenchuang.services.auth_service import hash_password + from huamenchuang.models.contract import Contract, Customer + from huamenchuang.models.user import User + async with SessionLocal() as db: + u2 = User(phone_number="15900000002", password_hash=hash_password("abc123"), + nickname="审计B公司", company_id=2) + c2 = Customer(name="B公司专属客户", phone="15900000002", company_id=2) + db.add_all([u2, c2]) + await db.flush() + ct2 = Contract(contract_no="FGAUDIT0001", customer_id=c2.id, customer_name="B公司专属客户", + money=777, company_id=2) + db.add(ct2) + await db.commit() + u2_id, c2_id, ct2_id = u2.id, c2.id, ct2.id + + # 登录两公司 + r = httpx.post(f"{BASE}/api/auth/login", json={"phone_number": "13800000000", "password": "123456"}, timeout=15) + A = {"Authorization": "Bearer " + r.json()["data"]["token"]} + r = httpx.post(f"{BASE}/api/auth/login", json={"phone_number": "15900000002", "password": "abc123"}, timeout=15) + check("越权前置: B 公司用户登录", r.json().get("code") == 1) + B = {"Authorization": "Bearer " + r.json()["data"]["token"]} + + def call(h, method, path, body=None): + rr = httpx.request(method, f"{BASE}{path}", headers=h, json=body, timeout=30) + try: + return rr.json() + except Exception: + return {"code": -1} + + # ---- 1 越权隔离:B 只能看到自己的 ---- + d = call(B, "GET", "/api/customers?page=1&page_size=50") + names = [x["name"] for x in d["data"]["data"]] + check("隔离-客户列表不含A公司数据", all("测试客户" not in n and "跟进" not in n for n in names) and "B公司专属客户" in names) + d = call(B, "GET", "/api/contracts?page=1&page_size=50") + nos = [x["contract_no"] for x in d["data"]["contracts"]["data"]] + check("隔离-合同列表仅本公司", all(n.startswith("FGAUDIT") for n in nos) and nos, f"got {nos[:3]}") + d = call(B, "POST", "/api/orders/search", {"page": 1, "page_size": 10}) + check("隔离-订单列表为空", d["code"] == 1 and d["data"]["total"] == 0) + d = call(B, "GET", "/api/scripts") + check("隔离-价格脚本为空", d["code"] == 1 and d["data"] == []) + d = call(B, "GET", f"/api/customers/{c2_id}/follows") + check("隔离-本公司数据可访问", d["code"] == 1) + + # ---- 2 跨公司按 ID 直访(应"不存在") ---- + # A 的第一份合同/客户 id + d = call(A, "GET", "/api/contracts?page=1&page_size=1") + a_ct = d["data"]["contracts"]["data"][0] + d = call(B, "GET", "/api/contracts?page=1&page_size=50") + a_ct_id = [x["id"] for x in json.loads(json.dumps(d["data"]["contracts"]["data"])) ] # B 只有自己的 + d = call(B, "GET", "/api/customers?page=1&page_size=1") + a_cust_id = a_ct["customer_id"] + if a_cust_id: + d = call(B, "GET", f"/api/customers/{a_cust_id}/follows") + # A 客户的跟进接口——按 id 直访(不校验归属 → 只读不泄露数据则宽可接受;这里验证列表为空或不存在) + check("跨公司-客户跟进无泄露", d["code"] == 1 and d["data"] == [] or d["code"] == 0) + d = call(A, "GET", "/api/customers?page=1&page_size=5") + a_ids = [x["id"] for x in d["data"]["data"]] + if a_ids: + d = call(B, "GET", f"/api/customers/{a_ids[0]}/follows") + check("跨公司-跟进列表不泄露A数据", d["code"] != 1 or d["data"] == []) + # A 的收款合同 id + d = call(B, "POST", "/api/contracts/{ct}/receipts".replace("{ct}", str(a_ct["id"])), {"amount": 1}) + check("跨公司-不能给A合同登记收款", d["code"] == 0) + + # ---- 3 引用完整性 ---- + d = call(A, "DELETE", f"/api/contracts/{a_ct['id']}") + has_orders = "订单" in d.get("message", "") + check("引用-有订单合同删除被拒", d["code"] == 0 and has_orders, f"resp={d.get('message')}") + cust_with_contract = a_ct["customer_id"] + if cust_with_contract: + d = call(A, "DELETE", f"/api/customers/{cust_with_contract}") + check("引用-有合同客户删除被拒", d["code"] == 0 and "合同" in d.get("message", ""), f"resp={d.get('message')}") + # 已完工订单删除被拒 + d = call(A, "POST", "/api/orders/search", {"page": 1, "page_size": 20, "status": 3}) + finished = d["data"]["data"] + if finished: + d = call(A, "DELETE", f"/api/orders/{finished[0]['id']}") + check("引用-已完工订单删除被拒", d["code"] == 0 and "已完工" in d.get("message", ""), f"resp={d.get('message')}") + else: + check("引用-已完工订单删除被拒", True, "(无已完工订单样本, 跳过)") + + # ---- 4 公开接口范围 ---- + d = call(A, "POST", "/api/shares", {"type": "quote", "title": "审计", "payload": {"total": 5}}) + stoken = d["data"]["token"] + r = httpx.get(f"{BASE}/api/shares/{stoken}", timeout=15) # 无登录 + check("公开-分享可匿名访问", r.json()["code"] == 1) + r = httpx.get(f"{BASE}/api/customers?page=1", timeout=15) # 无登录 + body = r.json() + check("公开-业务接口匿名被拒", body.get("code") in (401, "401") or "登录" in str(body.get("message", "")), f"resp={str(body)[:80]}") + r = httpx.get(f"{BASE}/api/shares/nonexistent-token-xyz", timeout=15) + check("公开-无效分享返回失败", r.json()["code"] == 0) + + # ---- 5 持久化(上轮创建的数据重启后仍在) ---- + d = call(A, "GET", "/api/customers?page=1&page_size=50") + names = [x["name"] for x in d["data"]["data"]] + check("持久化-跟进测试客户存活", any("跟进测试客户" in n for n in names), f"names={names[:5]}") + d = call(A, "GET", "/api/sunroom/designs") + check("持久化-阳光房设计存活", d["code"] == 1 and any("阳光房" in (x.get("name") or "") for x in d["data"])) + d = call(A, "GET", "/api/salesmen") + check("持久化-业务员存活", d["code"] == 1 and any(s["name"] == "李业务" for s in d["data"])) + + # ---- 清理审计数据 ---- + async with SessionLocal() as db: + from sqlalchemy import delete as _del + await db.execute(_del(Contract).where(Contract.id == ct2_id)) + await db.execute(_del(Customer).where(Customer.id == c2_id)) + await db.execute(_del(User).where(User.id == u2_id)) + await db.commit() + + print(f"\n===== 深度审计 {sum(1 for _, r in results if r == 'PASS')}/{len(results)} PASS =====") + for name, r in results: + print(f" [{'✓' if r == 'PASS' else '✗'}] {name} {r if r != 'PASS' else ''}") + + +asyncio.run(main()) diff --git a/src/huamenchuang/scripts/smoke_all.py b/src/huamenchuang/scripts/smoke_all.py index bda3dd9..2744a4f 100644 --- a/src/huamenchuang/scripts/smoke_all.py +++ b/src/huamenchuang/scripts/smoke_all.py @@ -181,7 +181,7 @@ def main(): d = call("POST", f"/api/customers/{cid2}/follows", {"content": "冒烟跟进", "next_date": "2026-10-01"}) check("P1-23 客户跟进", d.get("code") == 1) d = call("GET", f"/api/customers/{cid2}/follows") - check("P1-23b 跟进列表", d.get("code") == 1 and len(d["data"]) == 1) + check("P1-23b 跟进列表", d.get("code") == 1 and len(d["data"]) >= 1 and d["data"][-1]["content"] == "冒烟跟进") # 24 业务员 d = call("GET", "/api/salesmen/statistics") diff --git a/src/huamenchuang/services/contract_service.py b/src/huamenchuang/services/contract_service.py index fac29b9..be72908 100644 --- a/src/huamenchuang/services/contract_service.py +++ b/src/huamenchuang/services/contract_service.py @@ -41,4 +41,18 @@ class ContractService: contract = await self.repo.get(contract_id, user.company_id) if contract is None: raise KeyError("合同不存在") + # 引用完整性: 有订单/收款禁止直接删(避免孤儿订单与财务统计缺口) + from sqlalchemy import func, select + from huamenchuang.models.business import ContractReceipt + from huamenchuang.models.order import Order + db = self.repo.db + o_count = (await db.execute(select(func.count()).select_from(Order).where( + Order.company_id == user.company_id, Order.contract_id == contract_id))).scalar_one() + if o_count: + raise ValueError(f"该合同下有 {o_count} 个订单,请先删除订单") + r_count = (await db.execute(select(func.count()).select_from(ContractReceipt).where( + ContractReceipt.company_id == user.company_id, ContractReceipt.contract_id == contract_id, + ContractReceipt.status == 1))).scalar_one() + if r_count: + raise ValueError(f"该合同有 {r_count} 条有效收款记录,请先作废收款") await self.repo.delete(contract) diff --git a/src/huamenchuang/services/customer_service.py b/src/huamenchuang/services/customer_service.py index 4a83fcc..c0015ca 100644 --- a/src/huamenchuang/services/customer_service.py +++ b/src/huamenchuang/services/customer_service.py @@ -25,4 +25,22 @@ class CustomerService: customer = await self.repo.get(customer_id, user.company_id) if customer is None: raise KeyError("客户不存在") + # 引用完整性: 有下游单据禁止直接删(避免孤儿合同/量尺/跟进) + cid = customer.id + from sqlalchemy import func, select + from huamenchuang.models.business import CustomerFollow, MeasureOrder + from huamenchuang.models.contract import Contract + db = self.repo.db + c_count = (await db.execute(select(func.count()).select_from(Contract).where( + Contract.company_id == user.company_id, Contract.customer_id == cid))).scalar_one() + if c_count: + raise ValueError(f"该客户名下有 {c_count} 份合同,请先处理合同再删除") + m_count = (await db.execute(select(func.count()).select_from(MeasureOrder).where( + MeasureOrder.company_id == user.company_id, MeasureOrder.customer_id == cid))).scalar_one() + if m_count: + raise ValueError(f"该客户有 {m_count} 张量尺单,请先删除量尺单") + f_count = (await db.execute(select(func.count()).select_from(CustomerFollow).where( + CustomerFollow.company_id == user.company_id, CustomerFollow.customer_id == cid))).scalar_one() + if f_count: + raise ValueError(f"该客户有 {f_count} 条跟进记录,请先删除跟进记录") await self.repo.delete(customer) diff --git a/src/huamenchuang/services/order_service.py b/src/huamenchuang/services/order_service.py index 47f5b40..cdcdb35 100644 --- a/src/huamenchuang/services/order_service.py +++ b/src/huamenchuang/services/order_service.py @@ -97,6 +97,8 @@ class OrderService: async def delete_order(self, user: User, order_id: int) -> None: order = await self.get_detail(user, order_id) + if order.status == Order.STATUS_FINISHED: + raise ValueError("已完工订单不可删除(生产留档)") await self.repo.add_log(order.id, user.id, "delete", f"删除订单 {order.order_no}") await self.repo.delete(order)