# 每日任务系统架构方案 本文定义 App 每日任务和专属任务的产品边界、服务归属、事件来源、数据模型、后台管理和关键异常处理。 ## 目标 - 后台增加 `活动管理` 一级菜单,`每日任务` 作为活动管理下的二级菜单。 - `每日任务` 页面包含两个 Tab:`每日任务`、`专属任务`。 - 每日任务按服务器统一时区每天刷新,当天只能领取一次,超额完成不结转到明天。 - 专属任务是用户维度终身只能完成并领取一次的任务。 - 任务奖励统一发金币,金币发放必须走 `wallet-service` 账本。 - 任务进度由服务端事实事件驱动,客户端不能上报任务进度。 ## 非目标 - 首版不做客户端自定义任务排序。 - 首版不做多奖励类型;奖励只支持金币。 - 首版不做任务进度回滚;退款、撤销、CP 解除等后续事件进入风控和补偿,不直接减已完成任务。 - 首版不让 room-service、wallet-service、user-service 同步调用任务系统更新进度。 ## 服务边界 | 模块 | 责任 | 不负责 | | --- | --- | --- | | `server/admin` | 菜单、后台 HTTP API、任务配置管理、启停、排序、查询统计 | 不直接写 app 业务库;通过 protobuf client 或明确集成接口访问后端 | | `activity-service` | 任务定义、任务版本、任务进度、完成状态、领奖状态、任务事件消费幂等 | 不直接改钱包余额,不拥有房间/钱包/用户基础事实 | | `wallet-service` | 金币奖励入账、账本幂等、奖励流水 | 不判断任务是否完成 | | `user-service` | CP 关系事实、用户麦上时长事实 | 不判断任务奖励 | | `room-service` | 房间和麦位状态 owner,产生房间 outbox | 不同步更新任务 | 建议首版把任务系统放在 `activity-service`,因为它已经承接活动、消息、fanout 等低频活动能力。后续如果任务规模独立膨胀,再拆 `task-service`。 ## 总体架构 ```mermaid flowchart LR Admin["admin 活动管理 > 每日任务"] --> AdminAPI["admin-server task APIs"] AdminAPI --> ActivityAdmin["activity-service AdminTaskService"] Room["room-service outbox"] --> UserMic["user-service mic time consumer"] UserMic --> UserOutbox["user-service user_outbox"] Wallet["wallet-service outbox"] --> TaskConsumer["activity-service task consumers"] UserOutbox --> TaskConsumer CP["user-service CP outbox"] --> TaskConsumer TaskConsumer --> TaskDB["activity MySQL task tables"] App["App task page"] --> Gateway["gateway-service"] Gateway --> ActivityQuery["activity-service TaskQueryService"] Gateway --> ActivityClaim["activity-service TaskClaimService"] ActivityClaim --> WalletGrant["wallet-service CreditTaskReward"] ``` 核心原则: - 任务系统只消费事实事件,不参与房间、钱包、CP 主链路。 - 金币奖励只通过钱包账本发放。 - 每个事件必须有稳定 `event_id`,任务消费必须幂等。 - 任务完成和领奖是两个状态,完成不等于已经发奖。 ## 任务类型 | 分类 | 进度指标 | 事件来源 | 口径 | | --- | --- | --- | --- | | CP 任务 | 组成 CP 次数 | user-service CP 关系创建事件 | 以 `CpRelationshipCreated` 为准,解除 CP 不回滚 | | 上麦任务 | 麦上有效分钟数 | user-service 麦位时长聚合事件 | 使用 `mic_online_ms`,不是单纯占麦时间 | | 游戏任务 | 游戏消耗金币数 | wallet-service 游戏扣费事件 | 只统计成功扣费的 COIN | | 送礼任务 | 送礼消耗金币数 | wallet-service 送礼扣费事件 | 使用钱包扣费金额,不能用客户端礼物价格 | | 幸运礼物任务 | 幸运礼物消耗金币数或中奖金币数 | wallet-service 幸运礼物结算事件 | 首版一个任务只配置一种指标:`spend_coin` 或 `win_coin` | | 充值任务 | 充值金币数 | wallet-service 充值成功事件 | Google、Apple、三方支付、币商转普通金币都可以按配置纳入口径 | ### 上麦任务口径 上麦任务默认读取 `user_mic_daily_stats.mic_online_ms` 或消费 `UserMicSessionClosed` 派生事件。`mic_online_ms` 只从确认发流开始累计,未发声、只占麦、pending_publish 不计入有效麦上时间。 跨天上麦必须按服务器任务时区拆分到不同 `task_day`,不能把整段时长归到下麦当天。 ### 幸运礼物任务口径 幸运礼物任务需要避免把“消耗”和“中奖”混在同一个字段: | 指标 | 含义 | | --- | --- | | `lucky_gift_spend_coin` | 用户购买/发送幸运礼物实际消耗金币 | | `lucky_gift_win_coin` | 用户因幸运礼物中奖获得的金币价值 | 如果运营需要“消耗 n 金币且中奖 n 金币”,应在第二阶段支持多条件 AND。首版建议一个任务一个指标,减少配置歧义。 ## 任务周期 ### 每日任务 - 周期 key 使用服务器统一时区计算,例如 `Asia/Shanghai` 下的 `2026-05-09`。 - 服务器时区是系统配置,不使用用户手机时区。 - 事件归属日按 `occurred_at_ms` 转服务器时区计算,不按消费时间。 - 用户当天完成后只能领取一次。 - 当天超额完成只保留当天进度,不结转到明天。 - 每日刷新不需要批量清空用户数据;查询时按当前 `task_day` 读取或懒创建进度。 ### 专属任务 - 周期 key 固定为 `lifetime`。 - 用户终身只能完成一次、领取一次。 - 首版可作为全体用户可见的 lifetime 任务。 - 如果后续要“指定用户专属”,再增加任务分配表,不要把用户列表塞进任务定义 JSON。 ## 后台管理 后台菜单: ```text 活动管理 - 每日任务 Tab 1: 每日任务 Tab 2: 专属任务 ``` ### 列表字段 | 字段 | 说明 | | --- | --- | | 任务 ID | 系统生成 | | 任务名称 | App 展示名 | | 任务分类 | CP / 上麦 / 游戏 / 送礼 / 幸运礼物 / 充值 | | 任务类型 | 每日任务 / 专属任务 | | 目标值 | n 次、n 分钟、n 金币 | | 奖励金币 | 完成后领取的金币数 | | 状态 | draft / active / paused / archived | | 生效时间 | 可选 | | 结束时间 | 可选 | | 排序值 | App 列表排序 | | 当前版本 | 配置版本 | | 创建人/更新时间 | 审计 | ### 新增/编辑任务 后台表单: | 字段 | 必填 | 说明 | | --- | --- | --- | | 任务类型 | 是 | daily / exclusive | | 任务分类 | 是 | 固定枚举 | | 任务名称 | 是 | App 展示 | | 任务描述 | 否 | App 展示 | | 指标类型 | 是 | 按分类收敛,例如 `mic_online_minutes`、`gift_spend_coin` | | 目标值 | 是 | 必须大于 0 | | 奖励金币 | 是 | 必须大于 0 | | 排序值 | 是 | 小值靠前 | | 生效/结束时间 | 否 | 控制任务上下架窗口 | | 状态 | 是 | draft 保存草稿,active 对 App 生效 | 编辑 active 任务时建议生成新版本。每日任务的新版本默认从下一个 `task_day` 生效,避免当天用户看到目标值突然变化。 ## App 接口 ### 查询任务列表 ```text GET /api/v1/tasks/tabs ``` 返回两个分区: - `daily` - `exclusive` 每个任务返回: | 字段 | 说明 | | --- | --- | | `task_id` | 任务 ID | | `task_type` | daily / exclusive | | `category` | 任务分类 | | `title` | 展示名称 | | `description` | 展示描述 | | `target_value` | 目标值 | | `progress_value` | 当前进度 | | `reward_coin_amount` | 奖励金币 | | `status` | in_progress / completed / claimed / expired | | `claimable` | 是否可领取 | | `task_day` | daily 任务返回当前日期 | | `server_time_ms` | 服务端时间 | | `next_refresh_at_ms` | 下次刷新时间 | ### 领取奖励 ```text POST /api/v1/tasks/claim ``` 请求: ```json { "task_id": "123", "task_type": "daily", "task_day": "2026-05-09", "command_id": "client-or-gateway-generated-id" } ``` 规则: - 必须已完成。 - 必须未领取。 - daily 只能领取当前 `task_day` 的任务。 - exclusive 只能领取 `lifetime` 任务。 - `command_id` 幂等,重复请求返回同一结果。 可选第二阶段增加: ```text POST /api/v1/tasks/claim-all ``` ## 数据模型 ### `task_definitions` 任务定义当前版本的读模型。 | 字段 | 说明 | | --- | --- | | `task_id` | 主键 | | `app_code` | App 租户 | | `task_type` | daily / exclusive | | `category` | cp / mic / game / gift / lucky_gift / recharge | | `metric_type` | 具体指标 | | `title` | 展示名称 | | `description` | 展示描述 | | `target_value` | 目标值 | | `target_unit` | count / minute / coin | | `reward_coin_amount` | 奖励金币 | | `status` | draft / active / paused / archived | | `sort_order` | 排序 | | `version` | 当前版本 | | `effective_from_ms` | 生效时间 | | `effective_to_ms` | 结束时间 | | `created_by_admin_id` | 创建人 | | `updated_by_admin_id` | 更新人 | | `created_at_ms` | 创建时间 | | `updated_at_ms` | 更新时间 | ### `task_definition_versions` 保存历史版本,保证事件和进度可以追溯。 | 字段 | 说明 | | --- | --- | | `task_version_id` | 主键 | | `task_id` | 任务 ID | | `version` | 版本号 | | `snapshot_json` | 任务配置快照 | | `effective_from_cycle` | 从哪个 daily cycle 或 lifetime 生效 | | `created_at_ms` | 创建时间 | ### `user_task_progress` 用户任务进度。 | 字段 | 说明 | | --- | --- | | `app_code` | App 租户 | | `user_id` | 用户 ID | | `task_id` | 任务 ID | | `task_version_id` | 任务版本 | | `cycle_key` | daily 使用 `yyyy-mm-dd`,exclusive 使用 `lifetime` | | `progress_value` | 当前进度,可超过目标值 | | `target_value` | 进度创建时的目标快照 | | `status` | in_progress / completed / claimed / expired | | `completed_at_ms` | 完成时间 | | `claimed_at_ms` | 领取时间 | | `updated_at_ms` | 更新时间 | 唯一键: ```text UNIQUE(app_code, user_id, task_id, cycle_key) ``` ### `task_event_consumption` 事件幂等表。 | 字段 | 说明 | | --- | --- | | `app_code` | App 租户 | | `event_id` | 来源事件 ID | | `event_type` | 来源事件类型 | | `source_service` | room / wallet / user | | `user_id` | 事件归属用户 | | `task_day` | 服务器时区日期 | | `status` | consumed / skipped / failed | | `skip_reason` | 跳过原因 | | `created_at_ms` | 创建时间 | | `consumed_at_ms` | 消费时间 | 主键: ```text PRIMARY KEY(app_code, event_id) ``` ### `task_reward_claims` 领奖幂等和钱包发奖状态。 | 字段 | 说明 | | --- | --- | | `claim_id` | 主键 | | `app_code` | App 租户 | | `command_id` | 领奖幂等键 | | `user_id` | 用户 ID | | `task_id` | 任务 ID | | `cycle_key` | daily 日期或 lifetime | | `reward_coin_amount` | 奖励金币快照 | | `wallet_command_id` | 钱包发奖幂等键 | | `status` | pending / granted / failed | | `failure_reason` | 失败原因 | | `created_at_ms` | 创建时间 | | `updated_at_ms` | 更新时间 | 唯一键: ```text UNIQUE(app_code, command_id) UNIQUE(app_code, user_id, task_id, cycle_key) ``` ## 事件消费流程 ```mermaid sequenceDiagram participant Source as Source outbox participant Task as activity task consumer participant DB as activity MySQL Source->>Task: event(event_id, user_id, metric, value, occurred_at_ms) Task->>DB: INSERT task_event_consumption(event_id) alt event_id 已存在 DB-->>Task: duplicate Task-->>Source: ack else 新事件 Task->>DB: 查 active task definitions Task->>DB: upsert user_task_progress Task->>DB: progress >= target 时标记 completed Task->>DB: task_event_consumption = consumed Task-->>Source: ack end ``` 进度更新必须在一个数据库事务内完成: 1. 写入 `task_event_consumption`。 2. 找到事件命中的 active 任务。 3. 按 `user_id + task_id + cycle_key` 锁定或创建进度行。 4. 增加进度。 5. 如果达到目标,标记 `completed`。 6. 标记事件已消费。 如果事件没有匹配任何任务,写 `skipped`,避免重复消费同一个无效事件。 ## 领奖流程 ```mermaid sequenceDiagram participant App participant Gateway participant Activity as activity-service participant DB as activity MySQL participant Wallet as wallet-service App->>Gateway: POST /tasks/claim Gateway->>Activity: ClaimTaskReward Activity->>DB: 锁 user_task_progress DB-->>Activity: completed and not claimed Activity->>DB: 创建 task_reward_claims(pending) Activity->>Wallet: CreditTaskReward(wallet_command_id) Wallet-->>Activity: granted Activity->>DB: progress=claimed, claim=granted Activity-->>Gateway: reward granted Gateway-->>App: OK ``` 钱包调用失败时: - `task_reward_claims` 保持 `pending` 或 `failed`。 - `user_task_progress` 不应标记 `claimed`。 - 用户重试同一 `command_id` 返回当前状态。 - 后台可以扫描 `pending/failed` claim 重试。 ## 边界和补充规则 ### 1. 服务器时区 必须定义全局任务时区,例如: ```yaml task_timezone: "Asia/Shanghai" ``` 所有 daily 周期都按这个时区计算: - `task_day` - `cycle_start_ms` - `cycle_end_ms` - `next_refresh_at_ms` 不要使用用户手机时区,不要使用请求 IP 所在时区。 ### 2. 任务定义修改 active 任务修改目标值或奖励时,必须生成新版本。 建议规则: - 文案、排序可以立即生效。 - 目标值、奖励金币、指标类型从下一个 daily cycle 生效。 - 专属任务修改后只影响未开始用户,已完成或已领取用户不重算。 ### 3. 延迟事件 事件按 `occurred_at_ms` 归属日期。 建议首版: - 当前 task_day 之前的迟到事件可以记录到历史进度。 - App 只展示当前 daily 任务。 - daily 奖励只允许在当前 task_day 领取。 - 是否允许“昨日补领”不要默认支持,后续作为后台补偿能力单独设计。 ### 4. 超额完成 `progress_value` 可以超过 `target_value`,便于展示真实进度,例如 `120/100`。 但: - daily 超额不结转。 - reward 仍只发一次。 - exclusive 完成后不再重复累计领取。 ### 5. 任务可见性 首版所有 active 任务对当前 App 全体用户可见。 后续可扩展: - 地区可见。 - 新用户可见。 - VIP 可见。 - 指定用户可见。 - 主播/Agency/BD 身份可见。 这些应通过 `task_audience_rules` 扩展,不要把复杂人群条件塞进基础任务表。 ### 6. CP 任务解除关系 CP 任务以成功组成 CP 的事件为准。后续解除 CP 不回滚已完成进度,避免用户完成后被动丢奖励。 如果运营需要“当前仍保持 CP”,应该设计状态型任务,不要复用事件计数任务。 ### 7. 上麦任务跨天 跨天麦位 session 必须拆分到不同 `task_day`。 例如服务器时区 23:50 上麦,00:10 下麦: ```text 前一天 +10 分钟 后一天 +10 分钟 ``` ### 8. 充值退款和拒付 充值任务按成功充值事件完成。Google/Apple 拒付、退款、风控追回不直接回滚已领任务奖励。 如果后续需要严格财务反作弊,应新增: - 充值奖励冻结期。 - 领奖后拒付追扣。 - 风控黑名单。 首版不建议把这些复杂规则放进每日任务主链路。 ### 9. 金币奖励发放 activity-service 不能直接写钱包余额。 钱包发奖命令必须包含: | 字段 | 说明 | | --- | --- | | `wallet_command_id` | 幂等键 | | `user_id` | 领取用户 | | `asset_type` | COIN | | `amount` | 奖励金币 | | `reason` | `daily_task_reward` / `exclusive_task_reward` | | `biz_id` | `task_id + cycle_key` | ### 10. 幂等 必须有三层幂等: - 来源事件:`task_event_consumption(event_id)`。 - 用户进度:`user_task_progress(user_id, task_id, cycle_key)`。 - 领奖:`task_reward_claims(command_id)` 和 `task_reward_claims(user_id, task_id, cycle_key)`。 ### 11. 删除任务 后台不做物理删除。 状态流转: ```text draft -> active -> paused -> archived ``` `archived` 后 App 不展示,但历史进度、领奖记录仍可审计。 ### 12. 任务排序 建议排序: 1. 可领取任务。 2. 已完成未领取任务。 3. 进行中任务。 4. 已领取任务。 5. 同状态按 `sort_order`。 ### 13. 任务进度查询性能 App 查询任务列表时不要实时扫钱包流水或房间事件。 查询路径只读: ```text task_definitions + user_task_progress ``` 没有进度行时返回 0,不必提前为所有用户初始化每日任务。 ### 14. 多 App 隔离 所有任务定义、进度、事件消费、领奖记录都必须带 `app_code`。 任务 ID 不能跨 App 复用语义。 ### 15. 管理端审计 后台每次创建、启停、修改任务都要写审计: - operator_admin_id - request_id - before_json - after_json - created_at_ms 任务配置影响发奖,必须可追溯。 ## 推荐实现顺序 1. Admin 菜单和权限:`活动管理` 一级菜单下增加 `每日任务` 二级菜单。 2. activity-service 增加任务定义表、版本表、进度表、事件消费表、领奖表。 3. Admin API:每日任务/专属任务列表、新增、编辑、启停、排序。 4. App 查询接口:返回 daily/exclusive 两个分区和用户进度。 5. 钱包金币奖励接口:`CreditTaskReward` 幂等入账。 6. 领奖接口:完成校验、claim 幂等、调用钱包发奖。 7. 接入充值、送礼、游戏、幸运礼物事件。 8. 接入上麦时长事件或 user-service 麦时长 read model。 9. 接入 CP 创建事件。 10. 增加补偿任务:扫描 pending claim、failed event、消费延迟。 ## 需要提前确认的问题 1. 服务器任务时区使用哪个 IANA 时区,是否所有 App 共用。 2. 幸运礼物任务首版是“消耗金币”或“中奖金币”二选一,还是需要 AND 条件。 3. 充值任务是否包含币商转金币、后台补偿、活动赠币;建议首版只包含真实充值和币商转普通金币。 4. daily 任务是否允许次日补领;建议首版不允许。 5. 专属任务是否全体用户可见,还是需要后台指定用户或人群。 6. 游戏消耗金币的事件来源和业务类型枚举需要先统一,否则任务无法区分普通扣费和游戏扣费。