阅读数防刷 + A端 权限边界(波波 2026-09-25)

义父两问:

  1. 「阅读数+1 的问题怎么解决」
  2. 「公司人员可以通过 A端 帮助 B端 商户直接进行基础的配置管理,这是一种服务上的便捷,

但是不能涉及商户经营数据」


一、阅读数:一个 GET 在做写操作,且可被任意刷

现状

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

真解是拆出 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)下的多个游客会被合并成同一访客 ⇒ 阅读数偏低。

偏低不产生假数据,偏高才会;对防刷目的而言偏低是安全方向。

验证(prod 实测)

同一访客连打 4 次 : 12857 → 12857 → 12857 → 12857   只 +1 ✅
换两个访客各打 1 次: 12858, 12859                    各 +1 ✅

验证产生的 3 次计数已还原(测试流量不该留在展示数据里)。


二、A端 权限边界:落成门禁,不靠口头原则

义父定的边界很清晰,但写在文档里等于没写 —— 新人加一个 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_*禁写

L2 例外:五道守卫缺一即报红

平台兜底(退款执行、订单干预)必须存在 —— 商户跑路时用户找谁?

但"兜底"不等于"随便改商户的钱",必须:

① 鉴权    ② 事务    ③ 行锁 FOR UPDATE    ④ 审计留痕    ⑤ 目的地隔离

门禁首跑:2 项真红(均已逐条核实,非误报)

#端点缺什么定性
1/api/admin/orders/{id}/refund/executeaudit全文 121 行,零处 log/reason/operator。平台执行退款却没记录是谁操作的。已有商户前置 approve + 状态机 + 事务 + 行锁 + 隔离,只缺操作员留痕
2/api/miniapp/admin/orders/{id}/intervenetx / lock / dest阿仁 WIP-267 的正式交付(契约 endpoint 330)。与 admin_backend 同名端点两套并存、防护等级不一致

关于第 2 项,要说明清楚(我第一版判断错了,已更正):

两项都属他人域(退款链路 / 阿仁交付),未擅改,报请裁定。

判据修正:3 个误报已消除

第一版只认 _require_admin,把下面这些误报成"无鉴权写端点":

已放宽为认 get_current_user 系列,并对 login/password/verify 白名单 ——

但只豁免鉴权,不豁免写经营数据(login 端点本来就不该碰 orders)。

反证 5/5(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.pyA端 权限边界门禁(三层分类 + 五守卫)
scripts/revproof_admin_boundary.py门禁反证(5 用例,可复跑)

四、待裁定

  1. 退款执行缺操作员留痕 —— 建议补一条 operator_id/operator_role/reason 的操作日志

(对齐 order_interventions 的做法)。平台动钱无留痕,提审与监管都会问。

  1. 两套订单干预端点并存 —— 建议二选一:给 admin_ops 那套补 WIP-352 隔离,

或废弃它统一到 admin_backend 那套。功能重复的弱防护实现是负债不是资产。