任务中心的问题

This commit is contained in:
win 2026-02-27 16:07:12 +08:00
parent 46a7253239
commit e0db8751f3
12 changed files with 14462 additions and 13232 deletions

130
cmd/task_checker/main.go Normal file
View File

@ -0,0 +1,130 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"sort"
"strings"
"bindbox-game/configs"
"bindbox-game/internal/pkg/env"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
tcmodel "bindbox-game/internal/repository/mysql/task_center"
taskcenter "bindbox-game/internal/service/task_center"
)
func main() {
userID := flag.Int64("user", 0, "用户ID")
taskID := flag.Int64("task", 0, "任务ID")
flag.Parse()
if *userID == 0 || *taskID == 0 {
log.Fatalf("必须通过 -user 与 -task 指定用户与任务")
}
// 确认环境
env.Active() // 触发解析 -env 或 ACTIVE_ENV
configs.Init()
repo, err := mysql.New()
if err != nil {
log.Fatalf("初始化 MySQL 失败: %v", err)
}
defer repo.DbRClose()
defer repo.DbWClose()
logg, err := logger.NewCustomLogger()
if err != nil {
log.Fatalf("初始化 logger 失败: %v", err)
}
svc := taskcenter.New(logg, repo, nil, nil, nil)
ctx := context.Background()
progress, err := svc.GetUserProgress(ctx, *userID, *taskID)
if err != nil {
log.Fatalf("查询进度失败: %v", err)
}
fmt.Printf("任务 %d 用户 %d 进度:\n", *taskID, *userID)
fmt.Printf(" OrderCount=%d OrderAmount=%.2f InviteCount=%d FirstOrder=%v\n",
progress.OrderCount,
float64(progress.OrderAmount)/100,
progress.InviteCount,
progress.FirstOrder,
)
claimed := make(map[int64]struct{}, len(progress.ClaimedTiers))
for _, tid := range progress.ClaimedTiers {
claimed[tid] = struct{}{}
}
tiers := make([]tcmodel.TaskTier, 0)
if err := repo.GetDbR().Where("task_id = ?", *taskID).Order("threshold ASC, id ASC").Find(&tiers).Error; err != nil {
log.Fatalf("加载档位失败: %v", err)
}
fmt.Println("档位详情:")
for _, tier := range tiers {
tp := progress.TierProgressMap[tier.ID]
currentValue, display := extractMetricValue(tier.Metric, tp)
status := "未达标"
if checkThreshold(tier, currentValue, progress.FirstOrder) {
status = "已达标"
}
if _, ok := claimed[tier.ID]; ok {
status += " (已领取)"
}
fmt.Printf("- Tier %d | metric=%s threshold=%d operator=%s window=%s activity=%d\n",
tier.ID, tier.Metric, tier.Threshold, tier.Operator, tier.Window, tier.ActivityID)
fmt.Printf(" 当前值: %s -> %s\n", display, status)
}
if len(progress.ClaimedTiers) > 0 {
sort.Slice(progress.ClaimedTiers, func(i, j int) bool { return progress.ClaimedTiers[i] < progress.ClaimedTiers[j] })
fmt.Printf("已领取档位: %s\n", joinInt64s(progress.ClaimedTiers))
}
}
func extractMetricValue(metric string, tp taskcenter.TierProgress) (int64, string) {
switch metric {
case taskcenter.MetricOrderAmount:
return tp.OrderAmount, fmt.Sprintf("OrderAmount=%.2f", float64(tp.OrderAmount)/100)
case taskcenter.MetricOrderCount:
return tp.OrderCount, fmt.Sprintf("OrderCount=%d", tp.OrderCount)
case taskcenter.MetricInviteCount:
return tp.InviteCount, fmt.Sprintf("InviteCount=%d", tp.InviteCount)
case taskcenter.MetricFirstOrder:
if tp.FirstOrder {
return 1, "FirstOrder=true"
}
return 0, "FirstOrder=false"
default:
return 0, "(unknown metric)"
}
}
func checkThreshold(tier tcmodel.TaskTier, currentValue int64, firstOrder bool) bool {
switch tier.Metric {
case taskcenter.MetricFirstOrder:
return firstOrder
default:
if tier.Operator == taskcenter.OperatorGTE {
return currentValue >= tier.Threshold
}
return currentValue == tier.Threshold
}
}
func joinInt64s(vals []int64) string {
parts := make([]string, len(vals))
for i, v := range vals {
parts[i] = fmt.Sprintf("%d", v)
}
return strings.Join(parts, ",")
}

View File

@ -15,6 +15,101 @@ const docTemplate = `{
"host": "{{.Host}}", "host": "{{.Host}}",
"basePath": "{{.BasePath}}", "basePath": "{{.BasePath}}",
"paths": { "paths": {
"/admin/dashboard/activity-profit-loss/{activity_id}/logs": {
"get": {
"description": "查看活动的抽奖记录,支持按玩家/奖品关键词搜索",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"管理端.运营分析"
],
"summary": "活动抽奖记录",
"parameters": [
{
"type": "integer",
"description": "活动ID",
"name": "activity_id",
"in": "path",
"required": true
},
{
"default": 1,
"type": "integer",
"description": "页码",
"name": "page",
"in": "query",
"required": true
},
{
"default": 20,
"type": "integer",
"description": "每页数量最多100",
"name": "page_size",
"in": "query",
"required": true
},
{
"type": "integer",
"description": "用户ID过滤",
"name": "user_id",
"in": "query"
},
{
"type": "string",
"description": "玩家关键词:昵称/手机号/邀请码/ID",
"name": "player_keyword",
"in": "query"
},
{
"type": "string",
"description": "奖品关键词名称或ID",
"name": "prize_keyword",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/admin.activityLogsResponse"
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/code.Failure"
}
},
"401": {
"description": "未授权",
"schema": {
"$ref": "#/definitions/code.Failure"
}
},
"403": {
"description": "无权限",
"schema": {
"$ref": "#/definitions/code.Failure"
}
},
"500": {
"description": "服务器内部错误",
"schema": {
"$ref": "#/definitions/code.Failure"
}
}
},
"security": [
{
"LoginVerifyToken": []
}
]
}
},
"/admin/task_center/simulate/invite_success": { "/admin/task_center/simulate/invite_success": {
"post": { "post": {
"description": "模拟用户邀请成功,触发任务进度更新", "description": "模拟用户邀请成功,触发任务进度更新",
@ -7606,6 +7701,135 @@ const docTemplate = `{
} }
} }
}, },
"admin.activityPaymentDetails": {
"type": "object",
"properties": {
"coupon_discount": {
"description": "优惠券抵扣金额(分)",
"type": "integer"
},
"coupon_name": {
"description": "优惠券名称",
"type": "string"
},
"coupon_used": {
"description": "是否使用优惠券",
"type": "boolean"
},
"game_pass_info": {
"description": "次数卡使用信息",
"type": "string"
},
"game_pass_used": {
"description": "是否使用次数卡",
"type": "boolean"
},
"item_card_name": {
"description": "道具卡名称",
"type": "string"
},
"item_card_used": {
"description": "是否使用道具卡",
"type": "boolean"
},
"points_discount": {
"description": "积分抵扣金额(分)",
"type": "integer"
},
"points_used": {
"description": "是否使用积分",
"type": "boolean"
}
}
},
"admin.activityLogItem": {
"type": "object",
"properties": {
"avatar": {
"type": "string"
},
"created_at": {
"type": "string"
},
"discount_amount": {
"description": "分摊优惠金额(分)",
"type": "integer"
},
"id": {
"type": "integer"
},
"nickname": {
"type": "string"
},
"order_amount": {
"description": "分摊订单金额(分)",
"type": "integer"
},
"order_no": {
"description": "订单号",
"type": "string"
},
"order_status": {
"description": "订单状态",
"type": "integer"
},
"pay_type": {
"description": "支付方式",
"type": "string"
},
"payment_details": {
"$ref": "#/definitions/admin.activityPaymentDetails"
},
"product_id": {
"type": "integer"
},
"product_image": {
"type": "string"
},
"product_name": {
"type": "string"
},
"product_price": {
"description": "商品价格(分)",
"type": "integer"
},
"product_quantity": {
"description": "中奖数量",
"type": "integer"
},
"profit": {
"description": "单次抽奖盈亏(分)",
"type": "integer"
},
"used_card": {
"description": "使用的卡券信息",
"type": "string"
},
"user_id": {
"type": "integer"
}
}
},
"admin.activityLogsResponse": {
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"$ref": "#/definitions/admin.activityLogItem"
}
},
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
},
"admin.ShippingOrderGroup": { "admin.ShippingOrderGroup": {
"type": "object", "type": "object",
"properties": { "properties": {

File diff suppressed because it is too large Load Diff

View File

@ -103,6 +103,97 @@ definitions:
success: success:
type: boolean type: boolean
type: object type: object
admin.activityPaymentDetails:
properties:
coupon_discount:
description: 优惠券抵扣金额(分)
type: integer
coupon_name:
description: 优惠券名称
type: string
coupon_used:
description: 是否使用优惠券
type: boolean
game_pass_info:
description: 次数卡使用信息
type: string
game_pass_used:
description: 是否使用次数卡
type: boolean
item_card_name:
description: 道具卡名称
type: string
item_card_used:
description: 是否使用道具卡
type: boolean
points_discount:
description: 积分抵扣金额(分)
type: integer
points_used:
description: 是否使用积分
type: boolean
type: object
admin.activityLogItem:
properties:
avatar:
type: string
created_at:
type: string
discount_amount:
description: 分摊优惠金额(分)
type: integer
id:
type: integer
nickname:
type: string
order_amount:
description: 分摊订单金额(分)
type: integer
order_no:
description: 订单号
type: string
order_status:
description: 订单状态
type: integer
pay_type:
description: 支付方式
type: string
payment_details:
$ref: '#/definitions/admin.activityPaymentDetails'
product_id:
type: integer
product_image:
type: string
product_name:
type: string
product_price:
description: 商品价格(分)
type: integer
product_quantity:
description: 中奖数量
type: integer
profit:
description: 单次抽奖盈亏(分)
type: integer
used_card:
description: 使用的卡券信息
type: string
user_id:
type: integer
type: object
admin.activityLogsResponse:
properties:
list:
items:
$ref: '#/definitions/admin.activityLogItem'
type: array
page:
type: integer
page_size:
type: integer
total:
type: integer
type: object
admin.ShippingOrderGroup: admin.ShippingOrderGroup:
properties: properties:
address_id: address_id:
@ -3782,6 +3873,69 @@ info:
title: mini-chat 接口文档 title: mini-chat 接口文档
version: v0.0.1 version: v0.0.1
paths: paths:
/admin/dashboard/activity-profit-loss/{activity_id}/logs:
get:
consumes:
- application/json
description: 查看活动的抽奖记录,支持按玩家/奖品关键词搜索
parameters:
- description: 活动ID
in: path
name: activity_id
required: true
type: integer
- default: 1
description: 页码
in: query
name: page
required: true
type: integer
- default: 20
description: 每页数量最多100
in: query
name: page_size
required: true
type: integer
- description: 用户ID过滤
in: query
name: user_id
type: integer
- description: 玩家关键词:昵称/手机号/邀请码/ID
in: query
name: player_keyword
type: string
- description: 奖品关键词名称或ID
in: query
name: prize_keyword
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/admin.activityLogsResponse'
"400":
description: 参数错误
schema:
$ref: '#/definitions/code.Failure'
"401":
description: 未授权
schema:
$ref: '#/definitions/code.Failure'
"403":
description: 无权限
schema:
$ref: '#/definitions/code.Failure'
"500":
description: 服务器内部错误
schema:
$ref: '#/definitions/code.Failure'
security:
- LoginVerifyToken: []
summary: 活动抽奖记录
tags:
- 管理端.运营分析
/admin/task_center/simulate/invite_success: /admin/task_center/simulate/invite_success:
post: post:
consumes: consumes:

View File

@ -0,0 +1,62 @@
# 任务中心领取逻辑风险审查与测试计划
## 背景与目标
- **风险点**:用户可直接领取、或依赖任务上线前的历史数据领取新的任务奖励,尤其是在 `window` 未配置或默认 `lifetime` 的档位上。
- **目标**:明确代码审查要点、需要补强的校验以及自动化/手工测试,确保任务中心的进度统计与领取逻辑默认受限于任务 `StartTime ~ EndTime`
## 现状速览
- **窗口处理**`normalizeWindow` + `computeTimeWindow` 对非法窗口统一回退 `lifetime``WindowActivityPeriod` 才会套用任务起止时间(`internal/service/task_center/service.go:200-525`)。未配置或设置 `lifetime` 的档位默认统计全部历史。
- **进度统计**`GetUserProgress``(window, activity_id)` 分组统计 `TierProgressMap`,并回退到全局订单/邀请(`service.go:528-668`)。
- **领取校验**`ClaimTier` 先读取 `TierProgressMap`,再按 metric/operator 判断;含活动的档位使用 Redis `tc:claim_lock:{user_id}:{activity_id}` 加锁,并做跨任务阈值校验(`service.go:670-939`)。全局档位没有锁,任务级限额只在总量层面控制。
## 审查与增强要点
### 1. 窗口与时间范围
1. 将“任务设置了 `StartTime`/`EndTime` 但 window=空或 `lifetime`”纳入巡检:建议在 `normalizeWindow` or `GetUserProgress` 中,当 `task.StartTime` 不为空时自动将窗口裁剪到 `[StartTime, EndTime]`,并在单测覆盖空/NULL 窗口场景。
2. 明确 `since_registration` 行为:若不允许历史数据,应在 `computeTimeWindow` 中将起点设为 `max(user.RegistrationTime, task.StartTime)`
3. 验证 `TierProgressMap``SubProgress` 是否正确过滤 `start_time`/`end_time`:针对 `daily/weekly/activity_period/lifetime` 构造订单在边界日/周的案例,确保窗口重置与任务期重叠判断符合预期。
### 2. 领取校验扩展
1. 在 `ClaimTier` 中新增“窗口开始 < task.StartTime判断 `tierProgress` 返回数据完全来自任务上线前需拒绝并提示任务尚未开始需重新累计”。
2. 复核跨任务阈值 `calculateCrossTaskConsumedThreshold`:目前按任务创建时间 + 窗口重叠过滤,但若 `siblingRows` 包含旧任务的 `lifetime` 档位仍可能消耗全部历史,需要配合第 1 点的窗口裁剪。
3. Redis 锁范围:对于 `activity_id=0` 的档位,可增加 `tc:claim_lock_task:{user_id}:{task_id}`,防止并发重复领取。
### 3. 配置与数据守护
1. **巡检 SQL**(示例):
```sql
SELECT tiers.id, tiers.task_id, tiers.window, tasks.start_time, tasks.end_time
FROM task_center_task_tiers tiers
JOIN task_center_tasks tasks ON tasks.id = tiers.task_id
WHERE (tiers.window IS NULL OR tiers.window = '' OR tiers.window = 'lifetime')
AND tasks.start_time IS NOT NULL;
```
对结果逐条评估是否需改成 `activity_period` 或自定义窗口。
2. 若需让运营显式配置“允许历史数据”,可在 `task_center_task_tiers` 增加 `allow_legacy_data TINYINT`,并在 `admin` Upsert 接口透出。当前阶段以代码默认裁剪为主。
## 自动化测试计划
| 编号 | 场景 | 步骤 | 预期 |
| --- | --- | --- | --- |
| UT-1 | `lifetime` + StartTime 剪裁 | 任务 StartTime=T0插入 T0-1/T0+1 订单,调用 `GetUserProgress` | 仅统计 T0+1 数据;`TierProgressMap` 中的数量=1 |
| UT-2 | 空 Window | 档位 `window=''`,任务 StartTime=T0同上 | 行为等同 UT-1 |
| UT-3 | `since_registration` | 构造用户注册时间 Treg < T0验证窗口起点为 `max(Treg, T0)` | 统计以 `T0` 为准 |
| IT-1 | 历史数据领取阻断 | 先插入历史订单,使 `TierProgressMap` 达标;上线任务后 `ClaimTier` | 返回“任务条件未达成” |
| IT-2 | 新订单后可领 | 在 IT-1 基础上插入新订单超过阈值再 `ClaimTier` | 领取成功 |
| IT-3 | 跨任务占用 | 旧任务(已领 50 单) + 新任务阈值 60 单,新订单 15 单 | `ClaimTier` 拒绝,日志输出 `cross-task threshold`;再补 10 单 → 成功 |
| IT-4 | Redis 锁 | 并发触发 `ClaimTier`activityID>0 与 activityID=0 场景分别验证 | 仅一次成功,其余提示“操作频繁”或“已领取” |
> 自动化测试可基于现有 SQLite Repo`mysql.NewSQLiteRepoForTest`)快速构造数据,参考 `service_test.go``invite_logic_test.go` 的写法。
## 手工/灰度验证
1. **SQL 巡检**:执行上文 SQL导出需要修正的档位配合运营确认并批量更新 `window`
2. **模拟接口回放**:通过 `/admin/task_center/simulate/order_paid``/simulate/invite_success` 重放旧流水,再调整任务时间并调用 `/tasks/{id}/claim/{user}`,观察日志(`ClaimTier: cross-task threshold...``任务尚未开始` 等)。
3. **小程序体验**:发布新任务后,用老用户登录 `pages-user/tasks`,确认显示进度清零、领取按钮禁用;完成新订单后刷新 → 按预期解锁。
## 风险与假设
- 默认业务需求为“任务上线前的历史数据不可复用”,如需白名单例外需另开配置。
- Redis/数据库资源允许新增少量锁与巡检脚本,不影响现有性能。
- 若需要对现网数据批量改 `window`,需评估是否会影响已经配置为 `lifetime` 的任务,并提前同步运营。
## 下一步
1. 根据本计划完成代码 PoC窗口剪裁、领取校验、锁扩展
2. 提交自动化测试用例,覆盖表格中的 UT/IT 场景。
3. 运行 SQL 巡检 + 手工验证,记录整改项。
4. 如需引入“允许历史数据”配置,评估 schema 与前端/运营端改造影响,再单独立项。

View File

@ -0,0 +1,64 @@
# 活动大厅(任务中心)规则与计算逻辑梳理
## 1. 后端数据结构与配置来源
- **任务、档位、奖励主表**`Task``TaskTier``TaskReward``UserTaskProgress``TaskEventLog` 分别落在 `task_center_tasks``task_center_task_tiers``task_center_task_rewards``task_center_user_progress``task_center_event_logs` 表中,字段包含任务级/档位级限额、活动绑定、扩展参数 JSON 以及幂等键等核心信息(`bindbox_game/internal/repository/mysql/task_center/models.go:10-95`)。
- **限额与扩展字段迁移**
- `migrations/task_level_quota.sql:1-12` 在 2026-02-16 引入任务级 `quota`/`claimed_count`,同时移除旧的档位限额。
- `migrations/20260206_add_task_tier_quota.sql:1-6` 再次为档位恢复独立限额字段,用于任务总限量之外的档位限量。
- `migrations/20251223_add_user_invites_effective_columns.sql:1-4` 添加 `extra_params``effective_invite_count` 以及邀请有效性相关列,为后续“新用户限制”等规则提供数据面支撑。
- **管理端 CRUD & 配置 API**`internal/api/task_center/admin.go` 提供任务创建、修改、删除、档位/奖励配置、模拟事件与奖励发放统计接口(`admin.go:14-486`)。所有接口最终落到 `task_center/service.go``CreateTask``UpsertTaskTiers``UpsertTaskRewards` 等方法(`service.go:953-1162`),确保配置变更会触发缓存失效。
## 2. 运行期进度计算流程
- **时间窗口规范化**`normalizeWindow` / `normalizeWindowStrict` 将非法或空窗口统一视为 `lifetime``service.go:200-217``computeTimeWindow` 根据 `daily`/`weekly`/`monthly`/`activity_period` 返回具体起止时间,缺省为不限(`service.go:500-525`)。
- **订单指标采集**`fetchOrderMetricRows` 联结订单、期次、活动表,并按活动聚合抽赏次数/票价或订单金额(`service.go:223-247``calculateEffectiveAmount` / `aggregateOrderMetrics` 负责将抽赏次数 × 票价或订单实付换算成有效金额,支持去重订单总量与活动内统计(`service.go:249-281`)。
- **邀请指标采集**`countInvites``countInvitesForActivities` 分别处理单一活动窗口和多活动聚合,只有邀请人邀请的用户在目标活动内完成已支付订单后才计入特定活动任务(`service.go:283-346`)。
- **任务列表出参**`ListTasks` 默认过滤启用且可见的任务App 端)并预加载档位/奖励,同时根据 `task_center_task_rewards` 关联优惠券、道具卡、称号名称(`service.go:348-498`)。
- **按窗口分组统计进度**`GetUserProgress` 会先查询任务与其所有档位,根据 `(window, activity_id)` 组合拉取订单/邀请数据,产出 `TierProgressMap`(每个档位独立窗口内订单/金额/邀请)与 `SubProgress`(按活动粒度的订单汇总),同时聚合全局订单/邀请指标以及已领取档位列表(`service.go:528-668`)。
## 3. 达成校验与领取路径
- **前置校验**`ClaimTier` 首先调用 `GetUserProgress` 获取窗口化进度,并加载目标档位/任务最新配置(`service.go:670-686`)。
- **Redis 限流**:对于绑定 `ActivityID` 的档位,会抢占 `tc:claim_lock:{user_id}:{activity_id}` 避免并发重复领(`service.go:688-700`)。
- **指标达成判定**:优先读取 `TierProgressMap` 中窗口内数值,其次使用 `SubProgress` → 全局进度回退;支持 `>=`/`=` 操作符,对 `first_order` 直接使用布尔值(`service.go:702-752`)。
- **跨任务资源占用校验**`calculateCrossTaskConsumedThreshold` 会收集同活动+同指标的其他任务已领取档位,按任务窗口是否重叠、创建时间先后过滤后累积最大门槛值,若 `currentValue < consumed + 自身阈值` 则拒绝领取(`service.go:754-939`)。
- **限额 & 状态更新**:领取前更新任务级别 `claimed_count`,若已达 `quota` 返回“奖励已领完”(`service.go:788-799`);奖励发放成功后,事务性地在 `task_center_user_progress` 中追加 `tier_id`,幂等写入 `claimed_tiers` JSON`service.go:801-851`)。
## 4. 奖励发放与事件机制
- **奖励发放器**`grantTierRewards` 会根据 `task_id + tier_id` 查询奖励列表、构造 `idempotency_key` 并写入 `task_center_event_logs`,支持积分、优惠券、道具卡、称号、游戏票、实物商品等类型,并对 `quantity` 与 payload 内数量做“优先取 r.Quantity”策略修复`service.go:1400-1558`)。
- **异步事件**
- `OnOrderPaid` / `OnInviteSuccess` 先尝试投递到 Redis 队列,否则直接同步处理;`processOrderPaid` 只在订单状态为已支付时并发写入邀请人累计金额,同时加 Redis 幂等锁防止 24 小时内重复处理(`service.go:1165-1243`)。
- `StartWorker` 在 Redis 队列可用时启动 5 个 goroutine 消费 `order_paid``invite_success` 事件,最终调用 `processOrderPaid` / `processInviteSuccess``worker.go:12-80`)。
- **触发入口(上游链路)**
- 微信支付回调完成后,后台会异步执行 `_ = h.task.OnOrderPaid(...)`,确保真实支付订单刷新任务进度(`internal/api/pay/wechat_notify.go:294-305`)。
- 0 元抽奖订单、虚拟玩法等也会在服务器端模拟支付后触发 `OnOrderPaid``internal/api/activity/lottery_app.go:458-464``internal/api/activity/matching_game_app.go:203-206`)。
- 新用户带邀请码登录成功后,若存在邀请人则调用 `OnInviteSuccess` 记入邀请进度(`internal/api/user/login_app.go:78-83`)。
- 管理端提供 `/admin/task_center/simulate/order_paid``/simulate/invite_success` 便于调试触发(`internal/api/task_center/admin.go:315-369`)。
## 5. 小程序端消费链路
- **API 封装**`getTasks` / `getTaskProgress` / `claimTaskReward` 直接映射到 App 端任务中心列表、进度、领取接口(`bindbox-mini/api/appUser.js:262-271`)。
- **入口触达**:个人中心“常用功能”列表包含「任务中心」入口,点击跳转至 `pages-user/tasks/index.vue` 页面(`bindbox-mini/pages/mine/index.vue:140-184`)。
- **UI 与状态处理**
- 页面顶部展示订单数、邀请数、首单状态等汇总统计,同时支持空态、骨架加载(`pages-user/tasks/index.vue:1-56`)。
- 任务卡片内根据 `task.quota`/`claimed_count``tier.remaining``userProgress.claimedTiers``tier_activity_id` 决定显示“已领完”“可领取”“进行中”等状态(`pages-user/tasks/index.vue:57-150``220-379`)。
- `taskProgress` 为每个任务缓存独立进度,`normalizeSubProgress` + `getSubProgress*` 系列方法根据 `sub_progress`(活动维度进度)渲染子进度条,确保与后端 `TierProgressMap` 字段语义一致(`pages-user/tasks/index.vue:222-479``590-666`)。
- `fetchData` 并行拉取任务与进度,首个任务默认展开,领取成功后本地更新 `claimedTiers` 并 toast 提示(`pages-user/tasks/index.vue:515-589``481-505`)。
- **交互约束**:未登录/未缓存用户信息会弹出“请先登录”提示并跳转登录页,确保任务接口只在持有 token 的情况下访问(`pages-user/tasks/index.vue:187-209`)。
## 6. 测试覆盖与验证建议
- **单元/集成测试**
- `TestListTasks_FilterByStatusAndVisibility` 验证 App 列表只返回启用且可见的任务(`internal/service/task_center/list_tasks_filter_test.go:11-95`)。
- `service_test.go` 中的场景覆盖活动有效期窗口统计与跨任务占用判断,确保 `TierProgressMap``calculateCrossTaskConsumedThreshold` 行为正确(`internal/service/task_center/service_test.go:350-514`)。
- `TestInviteLogicSymmetry` 通过 SQLite 集成测试验证全局/特定活动邀请数统计与订单联动逻辑(`internal/service/task_center/invite_logic_test.go:10-80`)。
- **建议的手工校验**
1. 使用管理端创建带活动绑定与不同窗口的任务→调用 `/api/app/task-center/tasks``/progress/{user}` 核对 `sub_progress``tier_progress_map` 是否如预期。
2. 在沙箱环境支付一笔订单或通过模拟接口触发 `OnOrderPaid`,确认 Redis 幂等锁与任务限额的日志输出,并在 `task_center_event_logs` 中观察发放记录。
3. 在小程序端登陆真实账号,完成任务条件后点击“领取”,应同步看到 `task.quota` / `tier.remaining` 与“已领取”状态更新,且后台奖励统计接口 `/admin/task_center/tasks/{id}/reward-stats` 的计数随之增加。
---
- **假设与范围**:本文将“活动大厅”视为任务中心在小程序端的 UI 展示,不涉及其他独立模块;仅梳理现有逻辑,不包含代码改动。
- **交付复核**:若需要进一步变更规则,可从本文档列出的结构/流程入手定位受影响的函数与 API。

2
go.mod
View File

@ -21,7 +21,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 github.com/go-playground/universal-translator v0.18.1
github.com/go-playground/validator/v10 v10.15.0 github.com/go-playground/validator/v10 v10.15.0
github.com/go-resty/resty/v2 v2.10.0 github.com/go-resty/resty/v2 v2.10.0
github.com/go-sql-driver/mysql v1.7.0 github.com/go-sql-driver/mysql v1.7.1
github.com/golang-jwt/jwt/v5 v5.2.0 github.com/golang-jwt/jwt/v5 v5.2.0
github.com/issue9/identicon/v2 v2.1.2 github.com/issue9/identicon/v2 v2.1.2
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1

2
go.sum
View File

@ -203,6 +203,8 @@ github.com/go-resty/resty/v2 v2.10.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=

View File

@ -13,6 +13,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"gorm.io/gorm"
) )
type activityProfitLossRequest struct { type activityProfitLossRequest struct {
@ -344,9 +346,11 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
} }
type activityLogsRequest struct { type activityLogsRequest struct {
Page int `form:"page"` Page int `form:"page"`
PageSize int `form:"page_size"` PageSize int `form:"page_size"`
UserID int64 `form:"user_id"` UserID int64 `form:"user_id"`
PlayerKeyword string `form:"player_keyword"`
PrizeKeyword string `form:"prize_keyword"`
} }
type activityLogItem struct { type activityLogItem struct {
@ -411,15 +415,19 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
req.PageSize = 20 req.PageSize = 20
} }
req.PlayerKeyword = strings.TrimSpace(req.PlayerKeyword)
req.PrizeKeyword = strings.TrimSpace(req.PrizeKeyword)
db := h.repo.GetDbR().WithContext(ctx.RequestContext()) db := h.repo.GetDbR().WithContext(ctx.RequestContext())
var total int64 var total int64
countQuery := db.Table(model.TableNameActivityDrawLogs). countQuery := db.Table(model.TableNameActivityDrawLogs).
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id"). Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Joins("LEFT JOIN users ON users.id = activity_draw_logs.user_id").
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = activity_draw_logs.reward_id").
Joins("LEFT JOIN products ON products.id = activity_reward_settings.product_id").
Where("activity_issues.activity_id = ?", activityID) Where("activity_issues.activity_id = ?", activityID)
if req.UserID > 0 { countQuery = applyActivityLogFilters(countQuery, req)
countQuery = countQuery.Where("activity_draw_logs.user_id = ?", req.UserID)
}
countQuery.Count(&total) countQuery.Count(&total)
var logs []struct { var logs []struct {
@ -490,9 +498,7 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id"). Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
Joins("LEFT JOIN (SELECT order_id, COUNT(*) as draw_count FROM activity_draw_logs GROUP BY order_id) as order_draw_counts ON order_draw_counts.order_id = activity_draw_logs.order_id"). Joins("LEFT JOIN (SELECT order_id, COUNT(*) as draw_count FROM activity_draw_logs GROUP BY order_id) as order_draw_counts ON order_draw_counts.order_id = activity_draw_logs.order_id").
Where("activity_issues.activity_id = ?", activityID) Where("activity_issues.activity_id = ?", activityID)
if req.UserID > 0 { logsQuery = applyActivityLogFilters(logsQuery, req)
logsQuery = logsQuery.Where("activity_draw_logs.user_id = ?", req.UserID)
}
err := logsQuery. err := logsQuery.
Order("activity_draw_logs.id DESC"). Order("activity_draw_logs.id DESC").
Offset((req.Page - 1) * req.PageSize). Offset((req.Page - 1) * req.PageSize).
@ -665,6 +671,39 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
} }
} }
func applyActivityLogFilters(q *gorm.DB, req *activityLogsRequest) *gorm.DB {
if req == nil {
return q
}
if req.UserID > 0 {
q = q.Where("activity_draw_logs.user_id = ?", req.UserID)
}
if kw := req.PlayerKeyword; kw != "" {
like := "%" + kw + "%"
var args []interface{}
condition := "(users.nickname LIKE ? OR users.mobile LIKE ? OR users.invite_code LIKE ?"
args = append(args, like, like, like)
if playerID, err := strconv.ParseInt(kw, 10, 64); err == nil {
condition += " OR users.id = ?"
args = append(args, playerID)
}
condition += ")"
q = q.Where(condition, args...)
}
if kw := req.PrizeKeyword; kw != "" {
like := "%" + kw + "%"
args := []interface{}{like}
condition := "(products.name LIKE ?"
if prizeID, err := strconv.ParseInt(kw, 10, 64); err == nil {
condition += " OR products.id = ?"
args = append(args, prizeID)
}
condition += ")"
q = q.Where(condition, args...)
}
return q
}
type ensureActivityProfitLossMenuResponse struct { type ensureActivityProfitLossMenuResponse struct {
Ensured bool `json:"ensured"` Ensured bool `json:"ensured"`
Parent int64 `json:"parent_id"` Parent int64 `json:"parent_id"`

View File

@ -89,7 +89,7 @@ const (
func normalizeFetchOptions(opts *FetchOptions) *FetchOptions { func normalizeFetchOptions(opts *FetchOptions) *FetchOptions {
n := FetchOptions{ n := FetchOptions{
OnlyUnmatched: true, OnlyUnmatched: false,
MaxUsers: defaultFetchMaxUsers, MaxUsers: defaultFetchMaxUsers,
BatchSize: defaultFetchBatchSize, BatchSize: defaultFetchBatchSize,
Concurrency: defaultFetchConcurrency, Concurrency: defaultFetchConcurrency,
@ -325,10 +325,11 @@ func (s *service) FetchAndSyncOrders(ctx context.Context, opts *FetchOptions) (*
Where("douyin_user_id IS NOT NULL AND douyin_user_id != ''") Where("douyin_user_id IS NOT NULL AND douyin_user_id != ''")
if options.OnlyUnmatched { if options.OnlyUnmatched {
const collateExpr = "utf8mb4_unicode_ci"
subQuery := s.repo.GetDbR().WithContext(ctx). subQuery := s.repo.GetDbR().WithContext(ctx).
Model(&model.DouyinOrders{}). Model(&model.DouyinOrders{}).
Select("1"). Select("1").
Where("douyin_orders.douyin_user_id = users.douyin_user_id"). Where(fmt.Sprintf("douyin_orders.douyin_user_id COLLATE %s = users.douyin_user_id COLLATE %s", collateExpr, collateExpr)).
Where("(douyin_orders.local_user_id IS NULL OR douyin_orders.local_user_id = '' OR douyin_orders.local_user_id = '0')") Where("(douyin_orders.local_user_id IS NULL OR douyin_orders.local_user_id = '' OR douyin_orders.local_user_id = '0')")
userQuery = userQuery.Where("EXISTS (?)", subQuery) userQuery = userQuery.Where("EXISTS (?)", subQuery)
} }

View File

@ -499,12 +499,13 @@ func (s *service) ListTasks(ctx context.Context, in ListTasksInput) (items []Tas
// computeTimeWindow 根据 window 配置计算时间范围 // computeTimeWindow 根据 window 配置计算时间范围
// 返回 (windowStart, windowEnd)nil 表示该端不限制 // 返回 (windowStart, windowEnd)nil 表示该端不限制
// 重要:所有窗口类型都受任务时间约束,防止历史数据被用于领取新任务
func computeTimeWindow(window string, taskStart, taskEnd *time.Time) (start *time.Time, end *time.Time) { func computeTimeWindow(window string, taskStart, taskEnd *time.Time) (start *time.Time, end *time.Time) {
now := time.Now() now := time.Now()
switch window { switch window {
case WindowDaily: case WindowDaily:
s := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) s := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
return &s, &now return applyTaskBounds(&s, &now, taskStart, taskEnd)
case WindowWeekly: case WindowWeekly:
weekday := int(now.Weekday()) weekday := int(now.Weekday())
if weekday == 0 { if weekday == 0 {
@ -512,19 +513,38 @@ func computeTimeWindow(window string, taskStart, taskEnd *time.Time) (start *tim
} }
s := now.AddDate(0, 0, -(weekday - 1)) s := now.AddDate(0, 0, -(weekday - 1))
s = time.Date(s.Year(), s.Month(), s.Day(), 0, 0, 0, 0, s.Location()) s = time.Date(s.Year(), s.Month(), s.Day(), 0, 0, 0, 0, s.Location())
return &s, &now return applyTaskBounds(&s, &now, taskStart, taskEnd)
case WindowMonthly: case WindowMonthly:
s := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) s := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
return &s, &now return applyTaskBounds(&s, &now, taskStart, taskEnd)
case WindowActivityPeriod: case WindowActivityPeriod:
// 使用任务级别的 StartTime / EndTimenil 端不加限制 // 使用任务级别的 StartTime / EndTimenil 端不加限制
return taskStart, taskEnd return taskStart, taskEnd
default: default:
// lifetime / since_registration / 未知值 → 不限制 // lifetime / since_registration / 空值 / 未知值
return nil, nil // CRITICAL FIX: 受任务时间约束,防止历史数据领取新任务
return taskStart, taskEnd
} }
} }
// applyTaskBounds 将窗口时间与任务时间取交集,确保不超过任务有效期
func applyTaskBounds(windowStart, windowEnd, taskStart, taskEnd *time.Time) (*time.Time, *time.Time) {
start := windowStart
end := windowEnd
// 如果任务开始时间晚于窗口开始时间,使用任务开始时间
if taskStart != nil && (start == nil || taskStart.After(*start)) {
start = taskStart
}
// 如果任务结束时间早于窗口结束时间,使用任务结束时间
if taskEnd != nil && (end == nil || taskEnd.Before(*end)) {
end = taskEnd
}
return start, end
}
func (s *service) GetUserProgress(ctx context.Context, userID int64, taskID int64) (*UserProgress, error) { func (s *service) GetUserProgress(ctx context.Context, userID int64, taskID int64) (*UserProgress, error) {
db := s.repo.GetDbR() db := s.repo.GetDbR()
@ -680,12 +700,28 @@ func (s *service) ClaimTier(ctx context.Context, userID int64, taskID int64, tie
return err return err
} }
// BUG2 FIX: 多任务共享订单池问题 —— 获取 Redis 分布式锁,防止并发重复领取 var task tcmodel.Task
if tier.ActivityID > 0 && s.redis != nil { if err := s.repo.GetDbR().First(&task, taskID).Error; err != nil {
claimLockKey := fmt.Sprintf("tc:claim_lock:%d:%d", userID, tier.ActivityID) return err
}
// CRITICAL FIX: 为所有档位添加 Redis 分布式锁,防止并发重复领取
// - activityID > 0: 使用 activity_id 作为锁键(同活动跨任务场景)
// - activityID = 0: 使用 task_id 作为锁键(全局档位场景)
if s.redis != nil {
var claimLockKey string
if tier.ActivityID > 0 {
claimLockKey = fmt.Sprintf("tc:claim_lock:%d:%d", userID, tier.ActivityID)
} else {
claimLockKey = fmt.Sprintf("tc:claim_lock_task:%d:%d", userID, taskID)
}
locked, lockErr := s.redis.SetNX(ctx, claimLockKey, "1", 10*time.Second).Result() locked, lockErr := s.redis.SetNX(ctx, claimLockKey, "1", 10*time.Second).Result()
if lockErr != nil { if lockErr != nil {
s.logger.Error("ClaimTier: Redis lock error", zap.Error(lockErr), zap.Int64("user_id", userID), zap.Int64("activity_id", tier.ActivityID)) s.logger.Error("ClaimTier: Redis lock error",
zap.Error(lockErr),
zap.Int64("user_id", userID),
zap.Int64("activity_id", tier.ActivityID),
zap.Int64("task_id", taskID))
return lockErr return lockErr
} }
if !locked { if !locked {
@ -719,6 +755,7 @@ func (s *service) ClaimTier(ctx context.Context, userID int64, taskID int64, tie
currentInviteCount = progress.InviteCount currentInviteCount = progress.InviteCount
} }
var currentValue int64
switch tier.Metric { switch tier.Metric {
case MetricFirstOrder: case MetricFirstOrder:
hit = progress.FirstOrder hit = progress.FirstOrder
@ -728,99 +765,30 @@ func (s *service) ClaimTier(ctx context.Context, userID int64, taskID int64, tie
} else { } else {
hit = currentOrderCount == tier.Threshold hit = currentOrderCount == tier.Threshold
} }
currentValue = currentOrderCount
case MetricOrderAmount: case MetricOrderAmount:
if tier.Operator == OperatorGTE { if tier.Operator == OperatorGTE {
hit = currentOrderAmount >= tier.Threshold hit = currentOrderAmount >= tier.Threshold
} else { } else {
hit = currentOrderAmount == tier.Threshold hit = currentOrderAmount == tier.Threshold
} }
currentValue = currentOrderAmount
case MetricInviteCount: case MetricInviteCount:
if tier.Operator == OperatorGTE { if tier.Operator == OperatorGTE {
hit = currentInviteCount >= tier.Threshold hit = currentInviteCount >= tier.Threshold
} else { } else {
hit = currentInviteCount == tier.Threshold hit = currentInviteCount == tier.Threshold
} }
currentValue = currentInviteCount
} }
// BUG2 FIX: 跨任务累加校验 —— 防止多任务共享同一 activityID 订单池,用户用同一批订单重复领多个任务奖励 // BUG2 FIX: 跨任务累加校验 —— 防止多任务共享同一 activityID 订单池,用户用同一批订单重复领多个任务奖励
// 规则:同一 activityID + 同一 metric 下,不同 taskID 间各取已领最大 threshold 后求和, // 规则:同一 activityID + 同一 metric 下,不同 taskID 间各取已领最大 threshold 后求和,
// 要求 currentValue >= consumedThreshold已消耗+ tier.Threshold本次需消耗 // 要求 currentValue >= consumedThreshold已消耗+ tier.Threshold本次需消耗
if tier.ActivityID > 0 && (tier.Metric == MetricOrderCount || tier.Metric == MetricOrderAmount || tier.Metric == MetricInviteCount) { if tier.ActivityID > 0 && (tier.Metric == MetricOrderCount || tier.Metric == MetricOrderAmount || tier.Metric == MetricInviteCount) {
// 1. 查出同 activityID + 同 metric 下,属于其他 taskID 的所有 tier consumedThreshold, err := s.calculateCrossTaskConsumedThreshold(userID, &task, &tier)
var siblingTiers []tcmodel.TaskTier if err != nil {
if dbErr := s.repo.GetDbR(). return err
Where("activity_id = ? AND metric = ? AND task_id != ?", tier.ActivityID, tier.Metric, taskID).
Find(&siblingTiers).Error; dbErr != nil {
return dbErr
}
// 2. 收集所有不同的 sibling taskID
siblingTaskIDs := make([]int64, 0, len(siblingTiers))
siblingTaskSet := make(map[int64]struct{})
for _, st := range siblingTiers {
if _, exists := siblingTaskSet[st.TaskID]; !exists {
siblingTaskSet[st.TaskID] = struct{}{}
siblingTaskIDs = append(siblingTaskIDs, st.TaskID)
}
}
// 3. 计算已被其他 taskID 消耗的 threshold 总和
// 同一 taskID 内按阶梯处理(取最大已领 threshold不同 taskID 间求和
var consumedThreshold int64
if len(siblingTaskIDs) > 0 {
// 查用户在这些 sibling task 下的进度记录(含 claimed_tiers JSON
var siblingProgresses []tcmodel.UserTaskProgress
if dbErr := s.repo.GetDbR().
Where("user_id = ? AND task_id IN ? AND activity_id = 0", userID, siblingTaskIDs).
Find(&siblingProgresses).Error; dbErr != nil {
return dbErr
}
// 按 taskID 整理已领取的 tierID 列表
taskClaimedTierIDs := make(map[int64][]int64) // taskID -> []claimedTierID
for _, sp := range siblingProgresses {
var claimedIDs []int64
if len(sp.ClaimedTiers) > 0 {
_ = json.Unmarshal([]byte(sp.ClaimedTiers), &claimedIDs)
}
if len(claimedIDs) > 0 {
taskClaimedTierIDs[sp.TaskID] = claimedIDs
}
}
// 对每个 sibling taskID查已领取 tier 中属于同 activityID+metric 的最大 threshold
for _, sibTaskID := range siblingTaskIDs {
claimedIDs, ok := taskClaimedTierIDs[sibTaskID]
if !ok || len(claimedIDs) == 0 {
continue
}
var claimedThresholds []int64
if dbErr := s.repo.GetDbR().Model(&tcmodel.TaskTier{}).
Where("id IN ? AND task_id = ? AND activity_id = ? AND metric = ?",
claimedIDs, sibTaskID, tier.ActivityID, tier.Metric).
Pluck("threshold", &claimedThresholds).Error; dbErr != nil {
return dbErr
}
// 同一 taskID 下阶梯式:只计最大已领 threshold
var maxThreshold int64
for _, th := range claimedThresholds {
if th > maxThreshold {
maxThreshold = th
}
}
consumedThreshold += maxThreshold
}
}
// 4. 校验当前进度是否足以同时覆盖已消耗量和本次所需量
var currentValue int64
switch tier.Metric {
case MetricOrderCount:
currentValue = currentOrderCount
case MetricOrderAmount:
currentValue = currentOrderAmount
case MetricInviteCount:
currentValue = currentInviteCount
} }
if currentValue < consumedThreshold+tier.Threshold { if currentValue < consumedThreshold+tier.Threshold {
s.logger.Warn("ClaimTier: cross-task threshold validation failed", s.logger.Warn("ClaimTier: cross-task threshold validation failed",
@ -839,12 +807,6 @@ func (s *service) ClaimTier(ctx context.Context, userID int64, taskID int64, tie
return errors.New("任务条件未达成,无法领取") return errors.New("任务条件未达成,无法领取")
} }
// 获取任务信息
var task tcmodel.Task
if err := s.repo.GetDbR().First(&task, taskID).Error; err != nil {
return err
}
// 1.5 校验任务有效期 // 1.5 校验任务有效期
now := time.Now() now := time.Now()
if task.StartTime != nil && now.Before(*task.StartTime) { if task.StartTime != nil && now.Before(*task.StartTime) {
@ -920,6 +882,105 @@ func (s *service) ClaimTier(ctx context.Context, userID int64, taskID int64, tie
return nil return nil
} }
type siblingTierRow struct {
TierID int64 `gorm:"column:tier_id"`
TaskID int64 `gorm:"column:task_id"`
Threshold int64 `gorm:"column:threshold"`
TaskStart *time.Time `gorm:"column:task_start"`
TaskEnd *time.Time `gorm:"column:task_end"`
TaskCreated time.Time `gorm:"column:task_created"`
}
func tasksOverlapWindow(aStart, aEnd, bStart, bEnd *time.Time) bool {
if aStart != nil && bEnd != nil && (aStart.Equal(*bEnd) || aStart.After(*bEnd)) {
return false
}
if bStart != nil && aEnd != nil && (bStart.Equal(*aEnd) || bStart.After(*aEnd)) {
return false
}
return true
}
func (s *service) calculateCrossTaskConsumedThreshold(userID int64, task *tcmodel.Task, tier *tcmodel.TaskTier) (int64, error) {
tierTable := tcmodel.TaskTier{}.TableName()
taskTable := tcmodel.Task{}.TableName()
var siblingRows []siblingTierRow
if err := s.repo.GetDbR().
Table(tierTable+" AS tiers").
Select("tiers.id AS tier_id, tiers.task_id AS task_id, tiers.threshold, tasks.start_time AS task_start, tasks.end_time AS task_end, tasks.created_at AS task_created").
Joins(fmt.Sprintf("JOIN %s AS tasks ON tasks.id = tiers.task_id", taskTable)).
Where("tiers.activity_id = ? AND tiers.metric = ? AND tiers.task_id <> ?", tier.ActivityID, tier.Metric, task.ID).
Find(&siblingRows).Error; err != nil {
return 0, err
}
tierThreshold := make(map[int64]int64)
siblingTaskSet := make(map[int64]struct{})
taskStart := chooseTaskStart(task.StartTime, task.CreatedAt)
for _, row := range siblingRows {
if row.TaskCreated.Before(task.CreatedAt) {
continue
}
if !tasksOverlapWindow(taskStart, task.EndTime, chooseTaskStart(row.TaskStart, row.TaskCreated), row.TaskEnd) {
continue
}
tierThreshold[row.TierID] = row.Threshold
siblingTaskSet[row.TaskID] = struct{}{}
}
if len(siblingTaskSet) == 0 {
return 0, nil
}
taskIDs := make([]int64, 0, len(siblingTaskSet))
for id := range siblingTaskSet {
taskIDs = append(taskIDs, id)
}
var siblingProgresses []tcmodel.UserTaskProgress
if err := s.repo.GetDbR().
Where("user_id = ? AND task_id IN ? AND activity_id = 0", userID, taskIDs).
Find(&siblingProgresses).Error; err != nil {
return 0, err
}
perTaskMax := make(map[int64]int64)
for _, sp := range siblingProgresses {
var claimedIDs []int64
if len(sp.ClaimedTiers) > 0 {
_ = json.Unmarshal([]byte(sp.ClaimedTiers), &claimedIDs)
}
var maxThreshold int64
for _, id := range claimedIDs {
if th, ok := tierThreshold[id]; ok && th > maxThreshold {
maxThreshold = th
}
}
if maxThreshold > 0 {
if prev, ok := perTaskMax[sp.TaskID]; !ok || maxThreshold > prev {
perTaskMax[sp.TaskID] = maxThreshold
}
}
}
var consumed int64
for _, val := range perTaskMax {
consumed += val
}
return consumed, nil
}
func chooseTaskStart(start *time.Time, created time.Time) *time.Time {
if start != nil {
return start
}
if created.IsZero() {
return nil
}
c := created
return &c
}
func (s *service) CreateTask(ctx context.Context, in CreateTaskInput) (int64, error) { func (s *service) CreateTask(ctx context.Context, in CreateTaskInput) (int64, error) {
db := s.repo.GetDbW() db := s.repo.GetDbW()
row := &tcmodel.Task{Name: in.Name, Description: in.Description, Status: in.Status, StartTime: in.StartTime, EndTime: in.EndTime, Visibility: in.Visibility, Quota: in.Quota, ClaimedCount: 0} row := &tcmodel.Task{Name: in.Name, Description: in.Description, Status: in.Status, StartTime: in.StartTime, EndTime: in.EndTime, Visibility: in.Visibility, Quota: in.Quota, ClaimedCount: 0}

View File

@ -3,6 +3,7 @@ package taskcenter
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"testing" "testing"
"time" "time"
@ -156,10 +157,10 @@ func TestGetUserProgress_TimeWindow_Integration(t *testing.T) {
switch w { switch w {
case WindowDaily, WindowWeekly, WindowMonthly: case WindowDaily, WindowWeekly, WindowMonthly:
expectedCount = 1 expectedCount = 1
case WindowActivityPeriod: case WindowActivityPeriod, WindowLifetime:
// CRITICAL FIX: lifetime 现在受任务时间约束
// taskStart = now - 200天o2Time = now - 60天 (在范围内)o3Time = now - 365天 (超出范围)
expectedCount = 2 // O1, O2 expectedCount = 2 // O1, O2
case WindowLifetime:
expectedCount = 3 // O1, O2, O3
} }
if tp.OrderCount != expectedCount { if tp.OrderCount != expectedCount {
@ -400,3 +401,268 @@ func TestTimeWindow_ActivityPeriod(t *testing.T) {
t.Fatalf("总体订单统计错误,期望 2 实际 %d", progress.OrderCount) t.Fatalf("总体订单统计错误,期望 2 实际 %d", progress.OrderCount)
} }
} }
func TestCalculateCrossTaskConsumedThreshold_RespectsTaskWindow(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
if err != nil {
t.Fatalf("创建 repo 失败: %v", err)
}
db := repo.GetDbW()
initTestTables(t, db)
svc := New(nil, repo, nil, nil, nil).(*service)
now := time.Now()
startA := now.AddDate(0, -2, 0)
endA := now.AddDate(0, -1, 0)
startB := now.AddDate(0, -1, 0)
endB := now.AddDate(0, 0, 15)
startC := now.AddDate(0, -1, 15)
endC := now.AddDate(0, 1, 0)
taskA := &tcmodel.Task{Name: "历史任务", Status: 1, Visibility: 1, StartTime: &startA, EndTime: &endA}
taskB := &tcmodel.Task{Name: "当前任务", Status: 1, Visibility: 1, StartTime: &startB, EndTime: &endB}
taskC := &tcmodel.Task{Name: "重叠任务", Status: 1, Visibility: 1, StartTime: &startC, EndTime: &endC}
if err := db.Create(taskA).Error; err != nil {
t.Fatalf("创建任务 A 失败: %v", err)
}
if err := db.Create(taskB).Error; err != nil {
t.Fatalf("创建任务 B 失败: %v", err)
}
if err := db.Create(taskC).Error; err != nil {
t.Fatalf("创建任务 C 失败: %v", err)
}
activityID := int64(9001)
tierA := &tcmodel.TaskTier{
TaskID: taskA.ID,
Metric: MetricOrderAmount,
Operator: OperatorGTE,
Threshold: 20000,
Window: WindowLifetime,
ActivityID: activityID,
}
tierB := &tcmodel.TaskTier{
TaskID: taskB.ID,
Metric: MetricOrderAmount,
Operator: OperatorGTE,
Threshold: 30000,
Window: WindowLifetime,
ActivityID: activityID,
}
tierC := &tcmodel.TaskTier{
TaskID: taskC.ID,
Metric: MetricOrderAmount,
Operator: OperatorGTE,
Threshold: 40000,
Window: WindowLifetime,
ActivityID: activityID,
}
for _, tier := range []*tcmodel.TaskTier{tierA, tierB, tierC} {
if err := db.Create(tier).Error; err != nil {
t.Fatalf("创建档位失败: %v", err)
}
}
userID := int64(9527)
payloadA := datatypes.JSON([]byte(fmt.Sprintf("[%d]", tierA.ID)))
payloadC := datatypes.JSON([]byte(fmt.Sprintf("[%d]", tierC.ID)))
if err := db.Create(&tcmodel.UserTaskProgress{UserID: userID, TaskID: taskA.ID, ActivityID: 0, ClaimedTiers: payloadA}).Error; err != nil {
t.Fatalf("写入任务 A 进度失败: %v", err)
}
if err := db.Create(&tcmodel.UserTaskProgress{UserID: userID, TaskID: taskC.ID, ActivityID: 0, ClaimedTiers: payloadC}).Error; err != nil {
t.Fatalf("写入任务 C 进度失败: %v", err)
}
consumed, err := svc.calculateCrossTaskConsumedThreshold(userID, taskB, tierB)
if err != nil {
t.Fatalf("计算交叉占用失败: %v", err)
}
if consumed != tierC.Threshold {
t.Fatalf("交叉占用计算错误,期望 %d 实际 %d", tierC.Threshold, consumed)
}
// 新增一个创建时间晚于 B 的任务 D并标记为已领取
taskD := &tcmodel.Task{Name: "后续任务", Status: 1, Visibility: 1}
if err := db.Create(taskD).Error; err != nil {
t.Fatalf("创建任务 D 失败: %v", err)
}
tierD := &tcmodel.TaskTier{
TaskID: taskD.ID,
Metric: MetricOrderAmount,
Operator: OperatorGTE,
Threshold: 20000,
Window: WindowLifetime,
ActivityID: activityID,
}
if err := db.Create(tierD).Error; err != nil {
t.Fatalf("创建任务 D 档位失败: %v", err)
}
payloadD := datatypes.JSON([]byte(fmt.Sprintf("[%d]", tierD.ID)))
if err := db.Create(&tcmodel.UserTaskProgress{UserID: userID, TaskID: taskD.ID, ActivityID: 0, ClaimedTiers: payloadD}).Error; err != nil {
t.Fatalf("写入任务 D 进度失败: %v", err)
}
consumed, err = svc.calculateCrossTaskConsumedThreshold(userID, taskB, tierB)
if err != nil {
t.Fatalf("再次计算交叉占用失败: %v", err)
}
expected := tierC.Threshold + tierD.Threshold
if consumed != expected {
t.Fatalf("交叉占用应包含任务 C+D期望 %d 实际 %d", expected, consumed)
}
}
// TestLifetimeWindow_RespectsTaskStartTime 验证 CRITICAL-1 修复:
// lifetime 窗口现在受任务 StartTime 约束,防止历史数据被用于领取新任务
func TestLifetimeWindow_RespectsTaskStartTime(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
if err != nil {
t.Fatalf("创建 repo 失败: %v", err)
}
db := repo.GetDbW()
initTestTables(t, db)
ensureExtraTablesForServiceTest(t, db)
svc := New(nil, repo, nil, nil, nil)
now := time.Now()
taskStart := now.Add(-30 * 24 * time.Hour) // 任务30天前开始
task := &tcmodel.Task{
Name: "历史数据阻断测试",
Status: 1,
Visibility: 1,
StartTime: &taskStart,
}
if err := db.Create(task).Error; err != nil {
t.Fatalf("创建任务失败: %v", err)
}
// 创建 lifetime 窗口档位
tier := &tcmodel.TaskTier{
TaskID: task.ID,
Metric: MetricOrderCount,
Operator: OperatorGTE,
Threshold: 3, // 需要3单才能领取
Window: WindowLifetime,
}
if err := db.Create(tier).Error; err != nil {
t.Fatalf("创建档位失败: %v", err)
}
db.Exec("INSERT INTO activities (id, price_draw) VALUES (1, 100)")
db.Exec("INSERT INTO activity_issues (id, activity_id) VALUES (1, 1)")
userID := int64(10001)
// 插入历史订单(任务开始之前)
historicalOrder := taskStart.Add(-10 * 24 * time.Hour).Format(time.DateTime)
for i := int64(101); i <= 105; i++ {
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (?, ?, 2, 0, 100, ?)", i, userID, historicalOrder)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (?, 1)", i)
}
// 插入新订单(任务开始之后)
recentOrder := now.Add(-1 * 24 * time.Hour).Format(time.DateTime)
for i := int64(201); i <= 202; i++ {
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (?, ?, 2, 0, 100, ?)", i, userID, recentOrder)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (?, 1)", i)
}
// 获取进度
progress, err := svc.GetUserProgress(context.Background(), userID, task.ID)
if err != nil {
t.Fatalf("获取进度失败: %v", err)
}
tierProgress, ok := progress.TierProgressMap[tier.ID]
if !ok {
t.Fatalf("未找到档位进度")
}
// 验证只统计任务开始后的订单2单不包含历史订单5单
if tierProgress.OrderCount != 2 {
t.Errorf("lifetime 窗口应受任务时间约束: 期望 2 单, 实际 %d 单", tierProgress.OrderCount)
}
// 验证阈值未达到需要3单实际只有2单
if tierProgress.OrderCount >= tier.Threshold {
t.Errorf("历史数据不应计入进度,用户不应能够领取奖励")
}
t.Logf("✓ CRITICAL-1 修复验证通过: lifetime 窗口正确受任务 StartTime 约束")
t.Logf(" - 历史订单: 5 单 (任务开始前)")
t.Logf(" - 有效订单: %d 单 (任务开始后)", tierProgress.OrderCount)
}
// TestEmptyWindow_RespectsTaskStartTime 验证空窗口也受任务时间约束
func TestEmptyWindow_RespectsTaskStartTime(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
if err != nil {
t.Fatalf("创建 repo 失败: %v", err)
}
db := repo.GetDbW()
initTestTables(t, db)
ensureExtraTablesForServiceTest(t, db)
svc := New(nil, repo, nil, nil, nil)
now := time.Now()
taskStart := now.Add(-7 * 24 * time.Hour)
task := &tcmodel.Task{
Name: "空窗口测试",
Status: 1,
Visibility: 1,
StartTime: &taskStart,
}
if err := db.Create(task).Error; err != nil {
t.Fatalf("创建任务失败: %v", err)
}
// 创建空窗口档位
tier := &tcmodel.TaskTier{
TaskID: task.ID,
Metric: MetricOrderCount,
Operator: OperatorGTE,
Threshold: 1,
Window: "", // 空窗口
}
if err := db.Create(tier).Error; err != nil {
t.Fatalf("创建档位失败: %v", err)
}
db.Exec("INSERT INTO activities (id, price_draw) VALUES (1, 100)")
db.Exec("INSERT INTO activity_issues (id, activity_id) VALUES (1, 1)")
userID := int64(10002)
// 历史订单(任务开始前)
oldTime := taskStart.Add(-24 * time.Hour).Format(time.DateTime)
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (301, ?, 2, 0, 100, ?)", userID, oldTime)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (301, 1)")
// 新订单(任务开始后)
newTime := now.Add(-1 * time.Hour).Format(time.DateTime)
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (302, ?, 2, 0, 100, ?)", userID, newTime)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (302, 1)")
progress, err := svc.GetUserProgress(context.Background(), userID, task.ID)
if err != nil {
t.Fatalf("获取进度失败: %v", err)
}
tierProgress, ok := progress.TierProgressMap[tier.ID]
if !ok {
t.Fatalf("未找到档位进度")
}
// 空窗口也应受任务时间约束只统计1单
if tierProgress.OrderCount != 1 {
t.Errorf("空窗口应受任务时间约束: 期望 1 单, 实际 %d 单", tierProgress.OrderCount)
}
t.Logf("✓ 空窗口测试通过: OrderCount=%d", tierProgress.OrderCount)
}