fix(task_center): 邀请任务校验被邀请人活动期间消费达标

修复"有效邀请消费门槛(amount_threshold)"配置生效但后端从未判定的缺陷:
此前 invite_count 档位在全局维度仅统计 user_invites 绑定记录,导致
被邀请人零消费也算有效邀请,用户可白嫖邀请奖励。

- countInvites 新增 amountThreshold 参数:门槛>0 时按
  "被邀请人在活动窗口内已支付实付金额累计 >= 门槛" 统计有效邀请,
  消费时间严格限定在活动窗口内(活动期间消费达标,历史消费不计入);
  门槛=0 保持"注册即计"原行为
- GetUserProgress 改为按档位各自的 amount_threshold 独立统计
- matchAndGrantExtended 自动发放路径同步门槛重算(防御性)
- 新增 parseInviteAmountThreshold 辅助函数
This commit is contained in:
Zuncle 2026-06-15 00:21:20 +08:00
parent 9f96f235e7
commit 87be56e0c8

View File

@ -289,18 +289,24 @@ func (s *service) aggregateOrderMetrics(rows []orderMetricRow, perActivity bool)
return count, amount
}
func (s *service) countInvites(ctx context.Context, inviterID int64, activityID int64, start, end *time.Time) (int64, error) {
// countInvites 统计某邀请人的有效邀请数。
// amountThreshold > 0 时,要求"被邀请人在活动窗口 [start, end] 内累计已支付消费(实付金额, 单位分) >= amountThreshold"
// 才算 1 个有效邀请amountThreshold <= 0 时表示"注册即计",被邀请人无需消费。
// 关键:消费达标判定严格限定在活动窗口内(活动期间消费达标),历史消费不计入。
func (s *service) countInvites(ctx context.Context, inviterID int64, activityID int64, amountThreshold int64, start, end *time.Time) (int64, error) {
db := s.repo.GetDbR().WithContext(ctx)
var count int64
if activityID > 0 {
// 活动维度:被邀请人需在该活动产生已支付抽奖订单
query := `
SELECT COUNT(DISTINCT ui.invitee_id)
FROM user_invites ui
INNER JOIN orders o ON o.user_id = ui.invitee_id AND o.status = 2 AND o.source_type != 1
INNER JOIN activity_draw_logs dl ON dl.order_id = o.id
INNER JOIN activity_issues ai ON ai.id = dl.issue_id
WHERE ui.inviter_id = ? AND ai.activity_id = ?
SELECT COUNT(*) FROM (
SELECT ui.invitee_id
FROM user_invites ui
INNER JOIN orders o ON o.user_id = ui.invitee_id AND o.status = 2 AND o.source_type != 1
INNER JOIN activity_draw_logs dl ON dl.order_id = o.id
INNER JOIN activity_issues ai ON ai.id = dl.issue_id
WHERE ui.inviter_id = ? AND ai.activity_id = ?
`
args := []interface{}{inviterID, activityID}
if start != nil {
@ -311,25 +317,93 @@ func (s *service) countInvites(ctx context.Context, inviterID int64, activityID
query += " AND o.created_at <= ?"
args = append(args, *end)
}
query += " GROUP BY ui.invitee_id"
if amountThreshold > 0 {
// 活动期间消费达标:被邀请人在该活动下实付金额累计 >= 门槛
query += " HAVING SUM(o.actual_amount) >= ?"
args = append(args, amountThreshold)
}
query += ") t"
if err := db.Raw(query, args...).Scan(&count).Error; err != nil {
return 0, err
}
return count, nil
}
query := db.Model(&model.UserInvites{}).Where("inviter_id = ?", inviterID)
// 全局维度activity_id = 0
if amountThreshold <= 0 {
// 门槛为 0注册即计任意绑定的被邀请人都算 1 个有效邀请
query := db.Model(&model.UserInvites{}).Where("inviter_id = ?", inviterID)
if start != nil {
query = query.Where("created_at >= ?", *start)
}
if end != nil {
query = query.Where("created_at <= ?", *end)
}
if err := query.Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// 门槛 > 0被邀请人需在活动期间累计已支付消费(实付金额)达标,才算 1 个有效邀请
query := `
SELECT COUNT(*) FROM (
SELECT ui.invitee_id
FROM user_invites ui
INNER JOIN orders o ON o.user_id = ui.invitee_id AND o.status = 2
WHERE ui.inviter_id = ?
`
args := []interface{}{inviterID}
// 拉新动作(邀请绑定)须发生在活动窗口内
if start != nil {
query = query.Where("created_at >= ?", *start)
query += " AND ui.created_at >= ?"
args = append(args, *start)
}
if end != nil {
query = query.Where("created_at <= ?", *end)
query += " AND ui.created_at <= ?"
args = append(args, *end)
}
if err := query.Count(&count).Error; err != nil {
// 被邀请人消费须发生在活动窗口内(以支付时间为准,回退创建时间)
paidExpr := "COALESCE(NULLIF(o.paid_at, '1970-01-01 00:00:00'), o.created_at)"
if start != nil {
query += " AND " + paidExpr + " >= ?"
args = append(args, *start)
}
if end != nil {
query += " AND " + paidExpr + " <= ?"
args = append(args, *end)
}
query += `
GROUP BY ui.invitee_id
HAVING SUM(o.actual_amount) >= ?
) t
`
args = append(args, amountThreshold)
if err := db.Raw(query, args...).Scan(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// parseInviteAmountThreshold 从档位 ExtraParams 中解析"有效邀请消费门槛"(单位:分)。
// 字段缺失、为负或解析失败时返回 0注册即计
func parseInviteAmountThreshold(extra datatypes.JSON) int64 {
if len(extra) == 0 {
return 0
}
var p struct {
AmountThreshold int64 `json:"amount_threshold"`
}
if err := json.Unmarshal([]byte(extra), &p); err != nil {
return 0
}
if p.AmountThreshold < 0 {
return 0
}
return p.AmountThreshold
}
func (s *service) countInvitesForActivities(ctx context.Context, inviterID int64, activityIDs []int64) (int64, error) {
db := s.repo.GetDbR().WithContext(ctx)
var count int64
@ -622,12 +696,16 @@ func (s *service) GetUserProgress(ctx context.Context, userID int64, taskID int6
return nil, err
}
orderCount, orderAmount := s.aggregateOrderMetrics(rows, perActivity)
inviteCount, err := s.countInvites(ctx, userID, wk.ActivityID, wStart, wEnd)
if err != nil {
return nil, err
}
for _, tier := range groupTiers {
// 邀请人数:按该档位配置的"有效邀请消费门槛"(amount_threshold, 单位分)独立统计,
// 要求被邀请人在活动窗口内消费达标才计入;门槛为 0 时注册即计。
// 非邀请档位 ExtraParams 通常为空 → 门槛 0 → 退化为按窗口内绑定数统计(向后兼容)。
amountThreshold := parseInviteAmountThreshold(tier.ExtraParams)
inviteCount, err := s.countInvites(ctx, userID, wk.ActivityID, amountThreshold, wStart, wEnd)
if err != nil {
return nil, err
}
tierProgressMap[tier.ID] = TierProgress{
TierID: tier.ID,
OrderCount: orderCount,
@ -1464,10 +1542,20 @@ func (s *service) matchAndGrantExtended(ctx context.Context, t *tcmodel.Task, p
hit = false
}
case MetricInviteCount:
inviteCount := p.InviteCount
// 有效邀请消费门槛:要求被邀请人在活动窗口内消费达标才计入
if amountThreshold := parseInviteAmountThreshold(tier.ExtraParams); amountThreshold > 0 {
wStart, wEnd := computeTimeWindow(normalizeWindow(tier.Window), t.StartTime, t.EndTime)
vc, cerr := s.countInvites(ctx, p.UserID, tier.ActivityID, amountThreshold, wStart, wEnd)
if cerr != nil {
return cerr
}
inviteCount = vc
}
if tier.Operator == OperatorGTE {
hit = p.InviteCount >= tier.Threshold
hit = inviteCount >= tier.Threshold
} else {
hit = p.InviteCount == tier.Threshold
hit = inviteCount == tier.Threshold
}
}
if !hit {