任务中心的问题
This commit is contained in:
parent
46a7253239
commit
e0db8751f3
130
cmd/task_checker/main.go
Normal file
130
cmd/task_checker/main.go
Normal 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, ",")
|
||||||
|
}
|
||||||
224
docs/docs.go
224
docs/docs.go
@ -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": {
|
||||||
|
|||||||
26481
docs/swagger.json
26481
docs/swagger.json
File diff suppressed because it is too large
Load Diff
@ -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:
|
||||||
|
|||||||
62
docs/任务中心领取风险审查与测试计划.md
Normal file
62
docs/任务中心领取风险审查与测试计划.md
Normal 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 与前端/运营端改造影响,再单独立项。
|
||||||
64
docs/活动大厅任务中心规则梳理.md
Normal file
64
docs/活动大厅任务中心规则梳理.md
Normal 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
2
go.mod
@ -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
2
go.sum
@ -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=
|
||||||
|
|||||||
@ -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"`
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 / EndTime,nil 端不加限制
|
// 使用任务级别的 StartTime / EndTime,nil 端不加限制
|
||||||
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}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user