# 03 普通抽奖核心（for AI Agents）

> 关联代码：internal/service/lottery/（service.go/draw.go/eligibility.go/condition.go/test_mode.go/fixed_winner.go）、internal/pkg/drawcore、internal/pkg/picker、internal/model/lottery.go
> 管理端页面：web/src/views/lottery/、web/src/views/annual/；小程序页面：miniprogram/pages/lottery/*
> 本档是系统主业务；红包入账细节见 10，商城奖品结算见 11/12，助力见 04，问卷见 05。

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

普通抽奖（Lottery）是平台主玩法：发起人（小程序用户或后台）创建活动 → 用户在满足参与条件后参与 → 按开奖方式开奖 → 按奖品类型履约（实物发货 / 卡密兑换码 / 红包入钱包 / 优惠券 / 商城奖品发起人代付）。

五种开奖方式（DrawType，决定何时开奖）：
- time：到点定时开奖（Scheduler 每 30s 扫描）
- count：满 N 人自动开奖
- instant：即抽即中（用户点一次抽一次，权重=奖品 Quantity，库存扣减）
- manual：仅发起人手动开奖
- stage：阶段开奖（cfg.stage_prizes 配置多阶段，达阈自动开该阶段）

八种玩法模板（PlayTemplate，与 DrawType 正交，纯前端呈现差异）：normal / wheel（大转盘）/ slot（抽奖机）/ stick（抽签）/ code（抽码）/ annual（年会）/ wechat（公众号）/ xhs（小红书）。

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

2.1 核心模型与状态机
- Lottery：status = draft → pending(待审核) → approved(已审核) → active(进行中) → drawing(开奖中) → drawn(已开奖)；任意态可 cancelled。可参与态 = active 或 approved。IsTest 与 status 正交（测试抽奖）。
- Prize：类型 physical(实物)/virtual(虚拟)/redpacket(现金红包)/coupon(优惠券)/code(兑换码)/mall(商城商品)/thanks(谢谢参与)；发放方式 express(邮寄)/code(兑换码)/auto(自动)/none；Amount 为分（红包）；mall 奖品带 MallProductID + UnitPrice 快照。
- Participant：参与记录；刻意无唯一索引（支持 MaxPerUser>1 多次参与），并发防护靠活动行锁 + 事务内计数；AssistCount/GroupID/JoinCode。
- Winner：中奖记录；DeliveryStatus = awaiting_payment(仅商城奖品，待发起人付款)/pending(待处理)/delivered(已发放)/received(已收货)/shipped(已发货)/cancelled；Extra 承载 stage_index、指定中奖标记、红包金额等。
- LotteryGroup：组团；GroupCode 唯一；status forming/full/drawn；满团才参与开奖，中奖整团同奖。
- FixedWinner：指定中奖（运营内定），status pending/fulfilled/failed；支持开奖前内定与已开奖补录。
- LotteryComment（pending/approved/rejected 留言审核）、LotteryStat/LotteryViewLog（PV/UV/分享/参与统计）、LotteryVote（投票门槛，每用户每活动一票）。

2.2 抽奖引擎（internal/pkg/drawcore，纯函数不碰 DB）
- BatchDraw（批量开奖：time/count/manual/stage）：奖品名额池展开（BuildSlots）→ 候选人 Fisher-Yates 洗牌 → 按位配对；直接返回携带中奖者的 Award 列表。
- SingleDraw（单抽：instant）：加权选一 + 谢谢参与回落 + 售罄剔除重试；四态返回（中奖/具名谢谢参与/全售罄未中奖/真实错误）。
- 随机源 internal/pkg/picker 基于 crypto/rand。
- 并发模型（勿回退的约束）：参与/即抽即中在事务开头 SELECT...FOR UPDATE 锁活动行串行化再计数校验；开奖在写入事务内做「status<>drawn → drawn」条件更新抢占，RowsAffected==1 才写 winners，否则回滚 ErrAlreadyDrawn（跨 MySQL/SQLite 安全）。Service.Draw 保留 drawing 中间态，失败回滚仅恢复 status=drawing 的行，防覆盖已并发完成的 drawn。

2.3 参与资格校验链（service/lottery/eligibility.go + condition.go，Join 前置）
按 Lottery.Config（JSON，下称 cfg）依次校验，任一不过返回对应 21xx 错误码：
- 口令（Password/IsPasswordOnly）：2103
- 区域限制（cfg.need_region）：用 IP（ip2region）或用户上报经纬度判定；GeoIP 库未配置时 fail-closed 拒绝参与；用户端缺定位授权 2110 / 定位服务不可用 2124
- 问卷门槛（cfg.need_questionnaire）：查 QuestionnaireSubmission 每用户每抽奖一份；未提交 2111、配置异常 2125（闭环见 05）
- 投票（cfg.need_vote）、分享（need_share）、群成员（cfg.group_member.open_gid，解密 openGId 校验）、公众号关注（查本地 OfficialFollower 粉丝表，未配置公众号 fail-closed）、企微好友（同 fail-closed）、年会名单（AnnualRoster.Used）
- 风控拦截（RiskSvc）：2108；近期中奖者限制：2106；名额/已参与：2101/2102
- 组团参与走独立接口（2130-2139 组团错误段）
注意：存在 need_quiz 字样的是死配置，真正生效的是 cfg.need_questionnaire + 问卷绑定记录。

2.4 开奖三入口与副作用
- 入口：Scheduler 自动（time 到点/count 满人/stage 达阈）；发起人 POST /mp/lottery/:id/draw；管理员 POST /api/admin/lotteries/:id/draw。
- 开奖成功后（fire-and-forget，recoverAfterDraw 每 30s 幂等兜底补跑）：
  - AfterDrawAssignCodes：code/coupon 类奖品分配兑换码池
  - AfterDrawCreditRedpackets：红包奖品入账钱包（credit_mode=auto 时；claim 模式由用户在中奖详情页领取触发），同时 Winner 置 delivered
  - 中奖事件发布 EventBus（积分/通知消费）、清理 today_prize 配置、DrawnHook（手气广场赠送展示时长、开奖提醒订阅消息）
  - 指定中奖未兑现者置 failed
- 测试抽奖（IsTest）跳过全部发奖副作用（Winner 照常写入供发起人查看名单）。

2.5 履约路径（按奖品类型）
- 实物 physical：用户在中奖详情页绑地址（PUT /mp/winner/:id/address）→ 后台订单页发货 + 物流轨迹 → received
- 兑换码 code / 卡密：Winner 关联 CouponCode（unused→assigned→used）；用户在中奖详情页复制；后台可批量导入兑换码
- 红包 redpacket：入账钱包或 claim 领取（见 10）
- 优惠券 coupon：发放 UserCoupon
- 商城奖品 mall：Winner 初始 awaiting_payment → 发起人在详情页「结算」生成结算单并微信支付（订单类型 lottery_prize_settlement）→ 支付成功扣库存推待发货 → 走订单发货流程（见 11/12）

2.6 测试抽奖（仅普通抽奖支持，service/lottery/test_mode.go）
- 走完整生命周期（join/draw/instant/scheduler/组团）但不发奖；所有者可反复编辑（PUT /mp/lottery/:id）、重测（POST /:id/reset-test）、转正式（POST /:id/convert-formal）。
- 重测清理：删 winners/participants/groups/assist_records/浏览统计，复位计数与兑换码池，status 回 active；库存复原仅对 instant（按 winners 中奖条数加回 quantity），其他开奖方式严禁复原（会虚增库存）。
- 小程序广场列表排除测试抽奖（所有者经「我的抽奖」或分享链接可见）；后台列表可见并打「测试」标。

2.7 组团（max_group_num 0/3/6/9）
- 用户开团（POST /mp/lottery/:id/group）得 GroupCode → 分享邀人凭团码参团（/group/join）→ 满团 status=full 参与开奖 → 中奖整团每人均得奖品。

2.8 阶段开奖（stage）
- cfg.stage_prizes 配置各阶段奖品与触发阈值；Scheduler 扫描达阈自动 DrawStage；Winner.Extra.stage_index 区分阶段；前端按已开阶段展示进度。

2.9 年会玩法（play_template=annual）
- 普通抽奖的年会皮肤 + 年会专属设施：AnnualRoster 名单（后台 Excel/文本导入「手机号/工号 姓名」，Used 防重复参与）、DanmuMessage 弹幕（需审核）、AnnualRedrawLog 核验重抽（输入中奖记录 ID 撤销重抽）、小程序大屏页 annual-screen 5 秒轮询。
- 参与 eligibility 校验年会名单。

2.10 统计与分享
- LotteryStat/LotteryViewLog 记录 PV/UV/分享数/参与数；分享带 st 归因 token（见 14）。

====================================================================
3. 后台操作（抽奖管理菜单）
====================================================================

3.1 普通抽奖列表 /lotteries（views/lottery/LotteryList.vue）
- 筛选：搜索抽奖（关键词）、状态（draft/pending/approved/active/drawing/drawn/cancelled 七态）。
- 表格列：抽奖ID/标题/发起人/开奖方式/参与人数/状态/官方推荐/创建时间/操作。
- 行操作：「详情」；「开奖」（仅 active/approved，二次确认，等同管理员手动开奖）；「编辑」（drawn/drawing/cancelled 禁用）；「删除」（Popconfirm）；「官方推荐」列内开关直切。
- 「新建抽奖」→ /lotteries/create。

3.2 新建/编辑表单 /lotteries/create|/:id/edit（views/lottery/LotteryForm.vue）
- 基础区：标题、封面（ImageUpload）、说明、赞助商名称+广告语、开奖方式（time/count/instant/manual/stage）、开奖时间、人数上下限、每人参与次数、需分享开关、参与口令、问卷门槛开关+选问卷（QuestionnaireSelectModal）。
- 奖品卡片列表：名称、类型（physical/virtual/redpacket/coupon/code/mall/thanks）、数量、红包金额（分）、mall 商品选择（ProductSelectModal）、发放方式（express/code/auto/none）、图片、描述；可增删奖品。
- 编辑限制：已有人参与后为 limited 模式（仅可改基础信息）；小程序发起人创建的活动（creator_id>0）问卷绑定不可改。

3.3 抽奖详情 /lotteries/:id（views/lottery/LotteryDetail.vue，6 个 Tab）
- 基本信息：描述列表 + 统计卡 + 奖品表 + 阶段奖品配置 + 问卷门槛；code 类奖品可「导入兑换码」（批量文本）。
- 参与者：分页表。
- 指定中奖：FixedWinnerPanel（奖品下拉 + 用户搜索[昵称/OpenID/邀请码] + 备注；已开奖活动支持「补录」fulfill）。
- 中奖名单：发放状态标签、物流单号。
- 留言：「通过/拒绝」审核（pending→approved/rejected）。
- 兑换码：分页表。
- 页头：状态下拉 + 「更新状态」（人工推进状态机）+「手动开奖」（二次确认）。

3.4 年会抽奖 /annual（views/annual/）
- 年会列表复用 /admin/lotteries?play_template=annual；管理页三区块：名单导入（textarea 每行「手机号/工号 姓名」）、名单查询、核验重抽（中奖记录 ID 逗号分隔 + 原因）。
- 名单导入为破坏性覆盖：每次导入先删除该活动全部旧名单再插入新数据，非增量合并；运营操作前确认名单完整性。
- 核验重抽：按中奖记录 ID 硬删对应 Winner 行并记录 AnnualRedrawLog，不改变活动状态（不会把 drawn 回退为 active）；重抽后该奖品名额释放但不会自动重新分配。
- 弹幕审核：内容上限 200 字符（rune），仅 approved 状态的弹幕在小程序大屏展示；pending/rejected 不展示。

3.5 投诉管理 /complaints：处理抽奖投诉（详情见 16）。

3.6 后台操作逻辑备注（隐藏约束，UI 不直接体现）
- 状态流转：PUT /status 只能沿 draft→pending→approved→active→drawing→drawn 正向推进或任意态→cancelled；不可逆向（drawn 不可回 active）。管理员手动开奖（POST /:id/draw）要求 status=active 或 approved。
- 删除约束：仅当 status 非 drawn/drawing 且 CurrentCount==0（无人参与）时可删；删除时软删活动本身、硬删关联 prizes。已有参与者的活动只能 cancelled 不可删。
- 编辑约束：drawn/drawing/cancelled 三种状态禁止编辑。已有人参与的活动进入 limited 模式——仅可改标题/封面/说明/开奖时间/最大参与人数等基础字段，奖品与参与条件不可改。小程序发起人创建的活动（creator_id>0）问卷绑定不可改。
- 配置合并：admin PUT 编辑活动时，新 config JSON 以旧 config 为底合并覆盖（非全量替换）——未在请求中出现的 cfg 字段保留原值，不会因前端未传而被清空。
- 状态副作用：将状态从 active/approved 切至其他状态时，系统自动清理该活动关联的 today_prize 配置（首页今日奖品区域不再展示该活动）。
- 商城奖品中奖：Winner.DeliveryStatus 初始为 awaiting_payment（待发起人垫付结算），未结算前不会进入发货流程；运营催办时提醒发起人在小程序详情页「结算」。
- 指定中奖：开奖前内定（pending）在开奖时自动兑现为 fulfilled；未兑现者置 failed。已开奖活动支持「补录」fulfill（手动指定某用户获得某奖品）。

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

- 发起/编辑：pages/lottery/create/index（TabBar 中央「发起」进入；?id= 复用为编辑）；详情介绍用内置富文本页 pages/lottery/rich-editor（eventChannel 传/回 HTML）。
- 广场：pages/lottery/list/index 分页列表。
- 详情页 pages/lottery/detail/index（条件渲染中枢，cfg=JSON.parse(lottery.config)）：
  - 参与：join({password, group_data, group_iv, extra[区域定位]})；多次参与由 max_per_user 驱动「再参与一次」；参与成功且 cfg.encourage_share 弹一次性分享引导
  - 即抽即中/大转盘：instant 接口 → pages/lottery/draw/index 转盘动画 → pages/lottery/result/index
  - 错误码分流（utils/error-map.ts）：2101 满员置灰、2111 未填问卷弹窗引导 /pages/questionnaire/fill?lotteryId=、2110 缺定位、2116 需关注公众号、2131 团码错误等
  - 手动开奖按钮：仅创建者；开奖提醒：wx.requestSubscribeMessage + POST /mp/notifications/draw-reminder（time/count 类型显示）
  - 发起人专属区：结算（商城奖品代付）、推广置顶（展示券）、智能推广入口、群绑定（bind-group，依赖群卡片分享进入换取的 globalData.groupShare）
  - 测试抽奖（isCreator && is_test）：编辑/重新测试/转正式按钮 + 测试横幅
  - 组团入口（max_group_num>0）→ pages/lottery/group/index（开团/凭团码参团/分享团码）
  - 手势：页底上滑切换下一个推荐抽奖；右滑>40px 唤起助力面板（见 04）
  - 已开奖：展示中奖名单 + 结束后广告（ad 场景 after_ended）
- 中奖履约：pages/lottery/prize-detail/index（红包 claim 入账、兑换码/卡密复制、实物/商城选地址提交、发起人引流弹窗）。
- 我的抽奖：pages/lottery/my/index 三 Tab（发起/参与/中奖，?tab= 直达）；参与者页 pages/lottery/participants/index（助力值排行榜 + 全部参与者）。
- 用户主页入口：GET /mp/lottery/user/:id（见 16）。

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

小程序端（/api/mp 前缀，JWT mp）：
POST/GET /mp/lottery                     发起 / 列表（广场 exclude 测试）
GET  /mp/lottery/by-code                 口令查抽奖
GET/PUT /mp/lottery/:id                  详情 / 测试态编辑
POST /mp/lottery/:id/reset-test          重测（清数据回 active）
POST /mp/lottery/:id/convert-formal      转正式
POST /mp/lottery/:id/join                参与（含口令/区域/组团参数）
POST /mp/lottery/:id/draw                发起人手动开奖
POST /mp/lottery/:id/instant             即抽即中
GET  /mp/lottery/:id/winners             中奖名单
GET/POST /mp/lottery/:id/settlement(/pay) 商城奖品结算预览/代付支付
GET  /mp/lottery/my|my-join|my-win       我发起/参与/中奖
POST /mp/winners/:id/address             中奖绑地址；GET /mp/winner/:id（+coupon-code）
POST /mp/lottery/:id/bind-group          绑定群（群门槛）
POST/GET /mp/lottery/:id/group|/group/join|GET /mp/lottery/group|my-group  组团
GET  /mp/lottery/:id/comments POST /mp/lottery/:id/comments  留言
GET  /mp/lottery/:id/stats               统计；GET /mp/lottery/user/:id 用户主页抽奖列表

管理端（/api/admin 前缀，JWT admin + 权限码 lottery:*）：
GET/POST/PUT/DELETE /admin/lotteries(:id)   CRUD
PUT  /admin/lotteries/:id/status            人工推进状态
PUT  /admin/lotteries/:id/recommend         官方推荐开关
POST /admin/lotteries/:id/draw              管理员手动开奖
GET  /admin/lotteries/:id/winners|participants|stats|delivery
GET/POST /admin/lotteries/:id/coupon-codes、POST /admin/lotteries/:id/batch-import-codes
GET/PUT/DELETE /admin/lotteries/comments(/:id)  留言审核
GET/POST /admin/fixed-winners、DELETE /:id、POST /:id/fulfill   指定中奖（权限 lottery:fixed）
POST /admin/annual/roster/import、GET /admin/annual/roster/:lottery_id、PUT /admin/annual/danmu/:id/review、POST /admin/annual/redraw  年会

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

- 并发不变量：Participant 无唯一索引是有意设计（支持多次参与）；不要给它加唯一索引来「修」重复参与，正确防护是行锁+事务计数（已实现）。
- 开奖 CAS 用「status<>drawn → drawn」，不要改成「drawing→drawing」式无变化更新判 RowsAffected（SQLite 误判为 1）。
- 测试抽奖库存复原仅限 instant；批量/time/count/manual/stage 开奖不扣 quantity，重测严禁复原库存。
- 四套抽奖子系统（lottery/signin_lottery/lucky_lottery/drawbox + daily666）刻意不合并奖品表与中奖表（副作用差异大），只共享 drawcore 算法编排；排查问题先确认是哪套。
- 区域限制与公众号/企微门槛均为 fail-closed：相应外部能力未配置时，带该门槛的活动拒绝所有参与，属预期行为不是 bug。
- 商城奖品需要发起人先垫付结算（awaiting_payment），未结算的中奖不会进入发货流；运营侧催办时提醒发起人在小程序详情页「结算」。
- 「官方推荐」与首页「官方大奖」（featured_lottery 配置）是两回事：前者是列表推荐位，后者是首页卡片文案配置（见 13/17 相关配置档）。
