# 15 智能推广（CPC 广告）与展示券（手气广场）（for AI Agents）

> 关联代码：internal/service/ad/（service.go/refund.go）、internal/service/promo_slot/、internal/model/{ad,promo_slot}.go
> 管理端页面：web/src/views/ads/index.vue、web/src/views/promo-slot/；小程序页面：pages/ad/*、pages/promo-slot/*、components/{splash-ad,ad-slot,ad-popup}/
> 设计文档：docs/ad/design.md

====================================================================
1. 功能概述
====================================================================

两个推广变现子系统：
- 智能推广（CPC）：发起人/商家自助投放广告（按点击计费），在抽奖结束页/弹窗/开屏/信息流等场景展示，跳转关联抽奖；账户充值-出价-日预算-地区定向。
- 展示券（手气广场）：抽奖发起人付费购买「展示位券」，把自己的抽奖置顶到手气广场；套餐分单张/批量；开奖后系统赠送 120 分钟展示作为基础曝光。

====================================================================
2. 底层逻辑
====================================================================

2.1 CPC 广告
- Ad：投放计划。status draft/pending(待审核)/active(投放中)/paused(已暂停)/rejected(已驳回)。Scenes=after_draw/after_ended/popup/splash/video_feed（CSV 多选）；Regions 省市定向（ip2region 解析访客 IP）；BidFen 单次点击出价（分）；DailyBudgetFen 日预算。
- AdAccount：投放人余额；AdLedger 资金流水（recharge/click_spend/admin_adjust/refund，幂等键，对标钱包账本）。
- 充值：POST /mp/ad/recharge 下单（ProductType=ad_recharge，档位 10/50/100/500 元）→ 支付回调入账（幂等 ad_recharge_paid:<orderID>）。
- 选刊（SelectAds）：按场景 + IP 地区筛选 status=active 且当日花费<日预算 且余额≥出价，按出价排序取 limit；异步累加曝光。
- 点击计费（Click）：仅 active 计费；自点击不计费；事务 FOR UPDATE 锁 AdDailyStat 做日预算守卫；时间桶+viewer/IP 双维度幂等去重；Ledger.Spend 扣 BidFen（余额不足视为非致命，仍跳落地页）+ AdDailyStat upsert。
- 广告位：KV /mp/config/ad 配置广告位 ID（微信原生广告位优先），自建 CPC 兜底；smart_promo_enabled 为智能推广入口开关（功能开关总览可关），仅隐藏投放入口，展示与计费不受影响。

2.2 展示券（Promo Slot）
- PromoSlotProduct：套餐（时长天/奖品份数上限/单张价格/分级 single 单张|batch 批量（批量张数+总价）/可退款开关/排序/状态）。
- UserPromoSlot：用户持券库存；LotteryPromoSlot：抽奖的展示记录（审核 pending/approved/rejected）；LotteryPromoSlotLock：并发控制（防同一抽奖重复占用/超卖）。
- 购买：mp 展示券中心/购买页下单（ProductType=promo_slot）→ 回调发券到库存。
- 使用：抽奖详情页发起人「推广置顶」apply（扣库存、生成待审核记录）→ 后台「手气广场审核」通过后生效置顶（promoStatus.active 显示「推广中」）；驳回释放券回库存。
- 开奖赠送：抽奖开奖后自动赠送 120 分钟展示（DrawnHook，见 03）。

====================================================================
3. 后台操作
====================================================================

3.1 智能推广 /ads（views/ads/index.vue，权限 ad:read/write）
- 状态筛选：pending/active/paused/rejected；表格列：ID/投放人/关联抽奖/场景/出价(元)/日预算(元)/今日花费点击/余额(元)/状态/操作。
- 行操作：pending →「通过/驳回（弹窗填原因）」；active →「停用」；paused/rejected →「启用」；任意 →「数据」（近 30 日曝光/点击/消耗 + 日明细）。

3.2 展示券管理菜单（权限 promo_slot:read/write/audit）
- 套餐管理 /promo-slot/products：筛选关键词+状态；表格列：ID/名称/时长(天)/奖品份数上限/单张价格(元)/批量张数/批量总价(元)/排序/状态/操作。CRUD 弹窗（名称/时长天/奖品份数上限/单张价格/套餐分级 single|batch/批量张数+总价/排序/状态/可退款）。行操作：编辑、启用/禁用、删除。
- 全局配置 /promo-slot/config：说明/提示 textarea + 案例图文数组（类型/标题/图/内容，可增删）+ 使用流程与功能介绍 textarea + 保存按钮（小程序展示券中心消费）。
- 手气广场审核 /promo-slot/reviews：表格 + 「审核」弹窗（申请详情 + 通过/驳回单选，驳回原因必填；驳回释放券回商家库存）。

3.3 后台操作逻辑备注（隐藏约束，UI 不直接体现）
- 广告状态流转：完整状态机为 draft→pending→active→paused→rejected。后台管理员操作（PUT /status）仅支持 active↔paused 双向切换；审核（通过/驳回）走 PUT /audit 端点。
- 编辑触发重审：当广告需要审核（NeedAudit=true）时，编辑广告信息会自动将状态重置为 pending 待审核——修改后不会保持 active 投放状态。
- 点击计费防刷：60 秒内同一 viewer+IP 去重（幂等）；广告主自点击不计费（免费曝光）；超日预算的点击不拦截用户跳转（广告主欠费），但不会产生扣费——超出部分视为免费。
- 日预算守卫：事务内 FOR UPDATE 锁 AdDailyStat 做花费累加，超日预算即停止计费；日预算按自然日重置。
- 展示券审核约束：仅 pending 状态的申请可审核；驳回必须填写原因，驳回后释放券回商家库存；通过后的展示不可逆（不支持后台主动释放）。
- 开奖赠送的 120 分钟展示幂等：同一抽奖多次触发 DrawnHook 不会重复赠送（仅首次生效）。

====================================================================
4. 小程序端操作
====================================================================

广告主侧（发起人）：
- pages/ad/manage/index：创建/编辑广告——场景多选、地区定向（大区分组选择）、关联抽奖（pages/ad/select-lottery）、出价、日预算；提交后待审核。
- pages/ad/recharge/index：广告账户充值（10/50/100/500 元档）+ 流水。
- 广告组件（用户侧曝光/点击）：
  - components/splash-ad：开屏广告（优先微信原生插屏，降级自建 CPC；2 秒倒计时跳过；首页开屏位）
  - components/ad-slot：信息流广告卡片；components/ad-popup：底部弹层广告
  - 点击统一 POST /mp/ads/:id/click 计费后跳关联抽奖

展示券侧（发起人）：
- pages/promo-slot/buy/index：购买展示券（?lottery_id= 带回自动关联）
- pages/promo-slot/center/index：套餐购买 + 我的库存
- pages/promo-slot/inventory/index：库存明细
- 抽奖详情页发起人区：「使用展示券」按钮（无券跳购买页；已推广显示「推广中」）

====================================================================
5. API 速查
====================================================================

小程序端（/api/mp）：
GET  /api/mp/ad/scenes|regions|account          广告场景/地区选项/我的账户
POST /api/mp/ad/recharge                        充值下单（ad_recharge 订单）
GET|POST /api/mp/ads；GET /api/mp/ads/mine|:id；PUT /api/mp/ads/:id(/status)   我的投放
POST /api/mp/ads/:id/click                      点击计费（幂等去重）
GET  /api/mp/promo-slot/products|config         套餐/全局配置
POST /api/mp/promo-slot/order                   购买下单（promo_slot 订单）
GET  /api/mp/promo-slot/inventory               我的库存
POST /api/mp/promo-slot/apply                   对抽奖使用展示券
GET  /api/mp/promo-slot/lottery/:id/status      抽奖展示状态（详情页「推广中」判定）

管理端（/api/admin）：
GET  /api/admin/ads(/:id|/:id/stats)；PUT /api/admin/ads/:id/audit|status   审核与启停（ad:*）
GET|POST|PUT|DELETE /api/admin/promo-slot-products(:id)                     套餐 CRUD
GET|PUT /api/admin/promo-slot-config                                          全局配置
GET  /api/admin/promo-slot-reviews；PUT 审核（promo_slot:audit）

====================================================================
6. 注意事项
====================================================================

- 点击计费防刷是服务端幂等（时间桶+viewer/IP 双查重），排查「恶意点击」先看 AdLedger 流水与去重命中。
- 余额不足点击不拦截跳转（非致命）——广告主欠费仍产生展示点击，欠费由后续充值抵扣/退款流程处理。
- 广告余额与现金钱包是两个独立账本（AdAccount vs UserWallet），互不可转。
- 展示券并发占用由 Lock 表控制；「券扣了但没展示」先查审核状态（pending 未生效）与 Lock 表残留。
- 开奖赠送的 120 分钟展示是免费基础曝光，与付费展示券独立；运营答疑时区分。
