义父两问:
但是不能涉及商户经营数据」
GET /api/miniapp/guides/{id} 的取数语句本身就在写库:
row = await pool.fetchrow(
"UPDATE guides SET read_count = read_count + 1 "
f"WHERE id = $1 {cond} RETURNING id, slug, title, ...", guide_id)
只读语义的 GET 里做了写操作,而且无幂等、无去重。
read_count 是游客可见的展示指标:
| 位置 | 用法 | ||
|---|---|---|---|
content-library.wxml:39 | {{item.readCount}} 次阅读 | ||
home.ts:36 | `reads: x.reads \ | \ | x.read_count` |
prod 实测:guides.read_count 合计 67624,单条最高 12856
("冈仁波齐转山完整指南 12856 次阅读")。
也就是说,游客看到的这个数字可以被任何人一条 curl 循环刷到任意值 ——
属于"业务数据可被任意游客写",直接违反刘总 2026-09-23 铁律
(业务数据由运营在 A端 录入,C端 只读)。
真解是拆出 POST /{id}/view、GET 纯读。但 C端 content-article.ts 只调 GET,
单方面改会让阅读数彻底不涨(功能退化),需 C端 配合(小齐域)。
所以先做后端单方可完成、不破坏现有功能的去重;语义归位另议。
102_guide_view_dedup.sql + guides.py)先按 (guide_id, 访客, 自然日) 去重,通过才自增。
counted = True # 默认照常计数
try:
got = await pool.fetchval(
"INSERT INTO guide_view_dedup (guide_id, visitor_key) VALUES ($1,$2) "
"ON CONFLICT DO NOTHING RETURNING true", guide_id, vkey)
counted = bool(got)
except Exception:
counted = True # 去重表不可用 ⇒ 退化,绝不让详情页打不开
取舍(必须知道,别当成精确 UV):
攻略详情是免登录接口,拿不到 user_id,故 visitor_key = sha256(IP + User-Agent)。
同一 NAT 出口(一家客栈的 WiFi)下的多个游客会被合并成同一访客 ⇒ 阅读数偏低。
偏低不产生假数据,偏高才会;对防刷目的而言偏低是安全方向。
同一访客连打 4 次 : 12857 → 12857 → 12857 → 12857 只 +1 ✅
换两个访客各打 1 次: 12858, 12859 各 +1 ✅
验证产生的 3 次计数已还原(测试流量不该留在展示数据里)。
义父定的边界很清晰,但写在文档里等于没写 —— 新人加一个 admin 端点、
往 orders 里写一笔,没人会发现。所以做成可复跑的门禁。
| 层 | 内容 | A端 |
|---|---|---|
| L0 平台自有 | system_settings / content_items / guides / guide_articles / pois / routes / rules / knowledge / cms_posts / ai_config / road_status / identities / distributors | 可写 |
| L1 代商户配置 | merchants / merchant_pages / products / packages / package_components / service_items / 房型 / 菜品 / 桌台 | 可写(服务便捷,建议留痕) |
| L2 商户经营数据 | orders / order_items / refund_records / settlements / folios / shift_handovers / guests / room_bookings / redemption_codes / service_fee_records / pay_* | 禁写 |
平台兜底(退款执行、订单干预)必须存在 —— 商户跑路时用户找谁?
但"兜底"不等于"随便改商户的钱",必须:
① 鉴权 ② 事务 ③ 行锁 FOR UPDATE ④ 审计留痕 ⑤ 目的地隔离
| # | 端点 | 缺什么 | 定性 |
|---|---|---|---|
| 1 | /api/admin/orders/{id}/refund/execute | audit | 全文 121 行,零处 log/reason/operator。平台执行退款却没记录是谁操作的。已有商户前置 approve + 状态机 + 事务 + 行锁 + 隔离,只缺操作员留痕 |
| 2 | /api/miniapp/admin/orders/{id}/intervene | tx / lock / dest | 阿仁 WIP-267 的正式交付(契约 endpoint 330)。与 admin_backend 同名端点两套并存、防护等级不一致 |
关于第 2 项,要说明清楚(我第一版判断错了,已更正):
/api/admin(admin_backend,WIP-352 有目的地隔离)⇒ 当前安全两项都属他人域(退款链路 / 阿仁交付),未擅改,报请裁定。
第一版只认 _require_admin,把下面这些误报成"无鉴权写端点":
track/event —— 匿名埋点,Depends(get_current_user) 且 user 可空,设计如此admin_content 的 items/{key} —— 用的是 _require_staff_or_admin(员工级)login —— 天然不可能有前置鉴权,否则死锁已放宽为认 get_current_user 系列,并对 login/password/verify 白名单 ——
但只豁免鉴权,不豁免写经营数据(login 端点本来就不该碰 orders)。
revproof_admin_boundary.py)| 用例 | 期望 |
|---|---|
| ① 写 orders 且完全无鉴权 | 报红 |
| ② 写 orders 有鉴权但缺事务/行锁/留痕/隔离 | 报红 |
| ③ 写 orders 且五守卫齐全 | 不报红(防过宽) |
| ④ 写平台自有表 guides | 不报红(防误伤 L0) |
| ⑤ login 写平台表、无鉴权 | 不报红 |
过程中我自己踩了两次,留痕:
| 文件 | 作用 |
|---|---|
backend/sql/102_guide_view_dedup.sql | 阅读数去重表(自然日窗口,保留 90 天) |
backend/app/routers/guides.py | 详情接口自增前先去重,异常退化 |
scripts/check_admin_boundary.py | A端 权限边界门禁(三层分类 + 五守卫) |
scripts/revproof_admin_boundary.py | 门禁反证(5 用例,可复跑) |
operator_id/operator_role/reason 的操作日志 (对齐 order_interventions 的做法)。平台动钱无留痕,提审与监管都会问。
admin_ops 那套补 WIP-352 隔离, 或废弃它统一到 admin_backend 那套。功能重复的弱防护实现是负债不是资产。