fix: 修复微信通知字段截断导致的编码错误 feat: 添加有效邀请相关字段和任务中心常量 refactor: 重构一番赏奖品格位逻辑 perf: 优化道具卡列表聚合显示 docs: 更新项目说明文档和API文档 test: 添加字符串截断工具测试
577 lines
23 KiB
Go
577 lines
23 KiB
Go
package activity
|
||
|
||
import (
|
||
"bindbox-game/configs"
|
||
"bindbox-game/internal/pkg/logger"
|
||
"bindbox-game/internal/pkg/notify"
|
||
paypkg "bindbox-game/internal/pkg/pay"
|
||
"bindbox-game/internal/pkg/wechat"
|
||
"bindbox-game/internal/repository/mysql"
|
||
"bindbox-game/internal/repository/mysql/dao"
|
||
"bindbox-game/internal/repository/mysql/model"
|
||
strat "bindbox-game/internal/service/activity/strategy"
|
||
usersvc "bindbox-game/internal/service/user"
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
type scheduledConfig struct {
|
||
PlayType string `json:"play_type"`
|
||
DrawMode string `json:"draw_mode"`
|
||
MinParticipants int64 `json:"min_participants"`
|
||
ScheduledTime string `json:"scheduled_time"`
|
||
ScheduledDelayMinutes int64 `json:"scheduled_delay_minutes"`
|
||
IntervalMinutes int64 `json:"interval_minutes"`
|
||
LastSettledAt string `json:"last_settled_at"`
|
||
RefundCouponType string `json:"refund_coupon_type"`
|
||
RefundCouponAmount float64 `json:"refund_coupon_amount"`
|
||
RefundCouponID int64 `json:"refund_coupon_id"`
|
||
}
|
||
|
||
func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo) {
|
||
r := dao.Use(repo.GetDbR())
|
||
w := dao.Use(repo.GetDbW())
|
||
us := usersvc.New(l, repo)
|
||
|
||
// Ensure lottery_refund_logs table exists
|
||
_ = repo.GetDbW().Exec(`CREATE TABLE IF NOT EXISTS lottery_refund_logs (
|
||
id bigint unsigned AUTO_INCREMENT PRIMARY KEY,
|
||
issue_id bigint NOT NULL DEFAULT 0,
|
||
order_id bigint NOT NULL DEFAULT 0,
|
||
user_id bigint NOT NULL DEFAULT 0,
|
||
amount bigint NOT NULL DEFAULT 0,
|
||
coupon_type varchar(64) DEFAULT '',
|
||
coupon_amount bigint DEFAULT 0,
|
||
reason varchar(255) DEFAULT '',
|
||
status varchar(32) DEFAULT '',
|
||
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
INDEX idx_issue (issue_id),
|
||
INDEX idx_order (order_id)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`).Error
|
||
|
||
go func() {
|
||
t := time.NewTicker(30 * time.Second)
|
||
defer t.Stop()
|
||
for range t.C {
|
||
ctx := context.Background()
|
||
now := time.Now()
|
||
fmt.Printf("[定时开奖] ====== 开始检查 时间=%s ======\n", now.Format("2006-01-02 15:04:05"))
|
||
var acts []struct {
|
||
ID int64
|
||
PlayType string
|
||
DrawMode string
|
||
MinParticipants int64
|
||
IntervalMinutes int64
|
||
ScheduledTime *time.Time
|
||
RefundCouponID int64
|
||
LastSettledAt *time.Time
|
||
}
|
||
_ = repo.GetDbR().Raw("SELECT id, play_type, draw_mode, min_participants, interval_minutes, scheduled_time, refund_coupon_id, last_settled_at FROM activities WHERE draw_mode='scheduled' AND (scheduled_time IS NOT NULL OR interval_minutes > 0)").Scan(&acts)
|
||
fmt.Printf("[定时开奖] 查询到定时开奖活动数量=%d\n", len(acts))
|
||
for _, a := range acts {
|
||
fmt.Printf("[定时开奖] 检查活动 ID=%d PlayType=%s IntervalMinutes=%d ScheduledTime=%v LastSettledAt=%v\n",
|
||
a.ID, a.PlayType, a.IntervalMinutes, a.ScheduledTime, a.LastSettledAt)
|
||
|
||
// 计算开奖时间
|
||
st := time.Time{}
|
||
if a.ScheduledTime != nil {
|
||
st = *a.ScheduledTime
|
||
}
|
||
|
||
// 【修复】如果 scheduled_time 是过去的时间且 interval_minutes > 0,
|
||
// 跳过所有过期窗口,推进到最近的一个开奖时间
|
||
if !st.IsZero() && now.After(st) && a.IntervalMinutes > 0 {
|
||
// 计算需要跳过多少个周期
|
||
elapsed := now.Sub(st)
|
||
periods := int64(elapsed.Minutes()) / a.IntervalMinutes
|
||
if periods > 0 {
|
||
// 跳过所有完整的过期周期,st 变成最近的开奖时间点
|
||
st = st.Add(time.Duration(periods*a.IntervalMinutes) * time.Minute)
|
||
fmt.Printf("[定时开奖] 活动ID=%d 跳过过期周期数=%d 新开奖时间=%s\n", a.ID, periods, st.Format("2006-01-02 15:04:05"))
|
||
}
|
||
}
|
||
|
||
if st.IsZero() && a.IntervalMinutes > 0 {
|
||
if a.LastSettledAt != nil && !a.LastSettledAt.IsZero() {
|
||
st = a.LastSettledAt.Add(time.Duration(a.IntervalMinutes) * time.Minute)
|
||
} else {
|
||
st = now
|
||
}
|
||
}
|
||
|
||
fmt.Printf("[定时开奖] 活动ID=%d 计算开奖时间st=%s 当前时间now=%s 是否跳过=%t\n",
|
||
a.ID, st.Format("2006-01-02 15:04:05"), now.Format("2006-01-02 15:04:05"), st.IsZero() || now.Before(st))
|
||
|
||
if st.IsZero() || now.Before(st) {
|
||
continue
|
||
}
|
||
|
||
aid := a.ID
|
||
// 【修复】使用 last_settled_at 或很早的时间作为查询起点
|
||
last := time.Time{}
|
||
if a.LastSettledAt != nil && !a.LastSettledAt.IsZero() {
|
||
last = *a.LastSettledAt
|
||
}
|
||
|
||
// 【修复】查询从 last 到 now 的所有订单(而非到 st),确保能找到最新订单
|
||
fmt.Printf("[定时开奖] 活动ID=%d 查询订单范围: last=%s now=%s\n", aid, last.Format("2006-01-02 15:04:05"), now.Format("2006-01-02 15:04:05"))
|
||
orders, _ := r.Orders.WithContext(ctx).ReadDB().Where(
|
||
r.Orders.Status.Eq(2),
|
||
r.Orders.SourceType.Eq(2),
|
||
r.Orders.Remark.Like(fmt.Sprintf("lottery:activity:%d|%%", aid)),
|
||
r.Orders.CreatedAt.Gte(last),
|
||
).Find()
|
||
count := int64(len(orders))
|
||
fmt.Printf("[定时开奖] 活动ID=%d 查询到订单数=%d 最低参与人数=%d\n", aid, count, a.MinParticipants)
|
||
|
||
// Initialize Wechat Client if needed
|
||
wc, _ := paypkg.NewWechatPayClient(ctx)
|
||
|
||
refundedIssues := make(map[int64]bool)
|
||
|
||
// 【优化】一番赏定时退款:检查是否售罄
|
||
if a.PlayType == "ichiban" {
|
||
issueIDs := make(map[int64]struct{})
|
||
for _, o := range orders {
|
||
iss := extractIssueID(o.Remark)
|
||
if iss > 0 {
|
||
issueIDs[iss] = struct{}{}
|
||
}
|
||
}
|
||
|
||
for iss := range issueIDs {
|
||
// Check Sales
|
||
// 一番赏:每种奖品 = 1个格位
|
||
totalSlots, _ := r.ActivityRewardSettings.WithContext(ctx).Where(r.ActivityRewardSettings.IssueID.Eq(iss)).Count()
|
||
|
||
soldSlots, _ := r.IssuePositionClaims.WithContext(ctx).Where(r.IssuePositionClaims.IssueID.Eq(iss)).Count()
|
||
|
||
fmt.Printf("[定时开奖-一番赏] 检查售罄 IssueID=%d Sold=%d Total=%d\n", iss, soldSlots, totalSlots)
|
||
|
||
if soldSlots < totalSlots {
|
||
fmt.Printf("[定时开奖-一番赏] ❌ IssueID=%d 未售罄,执行全额退款\n", iss)
|
||
refundedIssues[iss] = true
|
||
|
||
// Find ALL valid orders for this issue
|
||
issueOrders, _ := r.Orders.WithContext(ctx).Where(
|
||
r.Orders.Status.Eq(2),
|
||
r.Orders.SourceType.Eq(2),
|
||
r.Orders.Remark.Like(fmt.Sprintf("%%issue:%d|%%", iss)),
|
||
).Find()
|
||
|
||
for _, o := range issueOrders {
|
||
refundOrder(ctx, o, "ichiban_not_sold_out", wc, r, w, us, a.RefundCouponID)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
shouldRefund := false
|
||
if a.PlayType != "ichiban" {
|
||
if count < a.MinParticipants {
|
||
shouldRefund = true
|
||
}
|
||
}
|
||
|
||
if shouldRefund {
|
||
fmt.Printf("[定时开奖] 活动ID=%d ❌ 人数不足,进行退款处理\n", aid)
|
||
for _, o := range orders {
|
||
refundOrder(ctx, o, "scheduled_not_enough", wc, r, w, us, a.RefundCouponID)
|
||
}
|
||
} else {
|
||
fmt.Printf("[定时开奖] 活动ID=%d ✅ 人数满足(或一番赏模式),开始开奖处理\n", aid)
|
||
if a.PlayType == "ichiban" {
|
||
fmt.Printf("[定时开奖] 活动ID=%d 一番赏模式开奖,订单数=%d\n", aid, len(orders))
|
||
// 一番赏定时开奖逻辑
|
||
ichibanSel := strat.NewIchiban(r, w)
|
||
for _, o := range orders {
|
||
iss := extractIssueID(o.Remark)
|
||
|
||
if refundedIssues[iss] {
|
||
fmt.Printf("[定时开奖-一番赏] OrderID=%d IssueID=%d 已退款,跳过开奖\n", o.ID, iss)
|
||
continue
|
||
}
|
||
|
||
uid := o.UserID
|
||
fmt.Printf("[定时开奖-一番赏] 处理订单 OrderID=%d UserID=%d IssueID=%d\n", o.ID, uid, iss)
|
||
|
||
// 检查是否已经处理过
|
||
logs, _ := r.ActivityDrawLogs.WithContext(ctx).Where(r.ActivityDrawLogs.OrderID.Eq(o.ID)).Find()
|
||
if len(logs) > 0 {
|
||
fmt.Printf("[定时开奖-一番赏] 订单ID=%d 已处理过,跳过\n", o.ID)
|
||
continue
|
||
}
|
||
|
||
// 查找该订单锁定的所有格位
|
||
claims, _ := r.IssuePositionClaims.WithContext(ctx).ReadDB().Where(
|
||
r.IssuePositionClaims.IssueID.Eq(iss),
|
||
r.IssuePositionClaims.OrderID.Eq(o.ID),
|
||
).Find()
|
||
fmt.Printf("[定时开奖-一番赏] 订单ID=%d 找到格位占用数=%d\n", o.ID, len(claims))
|
||
|
||
for claimIdx, claim := range claims {
|
||
fmt.Printf("[定时开奖-一番赏] 处理格位 SlotIndex=%d (索引=%d)\n", claim.SlotIndex, claimIdx)
|
||
|
||
// 【幂等检查】检查该订单的该格位是否已经发过奖
|
||
existingLogCnt, _ := r.ActivityDrawLogs.WithContext(ctx).Where(
|
||
r.ActivityDrawLogs.OrderID.Eq(o.ID),
|
||
r.ActivityDrawLogs.IssueID.Eq(iss),
|
||
).Count()
|
||
if existingLogCnt > int64(claimIdx) {
|
||
fmt.Printf("[定时开奖-一番赏] ⚠️ 格位 SlotIndex=%d 已处理过(日志数=%d,当前索引=%d),跳过\n", claim.SlotIndex, existingLogCnt, claimIdx)
|
||
continue
|
||
}
|
||
|
||
// 使用 claim 中的 slot_index 直接获取奖品
|
||
// Use Commitment (via SelectItemBySlot internal logic)
|
||
rid, proof, err := ichibanSel.SelectItemBySlot(ctx, aid, iss, claim.SlotIndex)
|
||
if err != nil || rid <= 0 {
|
||
fmt.Printf("[定时开奖-一番赏] ❌ SelectItemBySlot失败 err=%v rid=%d\n", err, rid)
|
||
continue
|
||
}
|
||
|
||
rw, err := r.ActivityRewardSettings.WithContext(ctx).Where(r.ActivityRewardSettings.ID.Eq(rid)).First()
|
||
if err != nil || rw == nil {
|
||
fmt.Printf("[定时开奖-一番赏] ❌ 奖品设置不存在 rid=%d\n", rid)
|
||
continue
|
||
}
|
||
|
||
// 【先记录日志,再发奖】确保日志创建成功后再发奖,防止重复
|
||
drawLog := &model.ActivityDrawLogs{
|
||
UserID: uid,
|
||
IssueID: iss,
|
||
OrderID: o.ID,
|
||
RewardID: rid,
|
||
IsWinner: 1,
|
||
Level: rw.Level,
|
||
CurrentLevel: 1,
|
||
}
|
||
if err := w.ActivityDrawLogs.WithContext(ctx).Create(drawLog); err != nil {
|
||
fmt.Printf("[定时开奖-一番赏] ❌ 创建开奖日志失败 err=%v,可能已存在,跳过\n", err)
|
||
continue
|
||
}
|
||
|
||
// 发放奖励(在原订单上添加中奖商品,不创建新订单)
|
||
fmt.Printf("[定时开奖-一番赏] 发放奖励到原订单 OrderID=%d UserID=%d RewardID=%d 奖品名=%s\n", o.ID, uid, rid, rw.Name)
|
||
_, err = us.GrantRewardToOrder(ctx, uid, usersvc.GrantRewardToOrderRequest{
|
||
OrderID: o.ID,
|
||
ProductID: rw.ProductID,
|
||
Quantity: 1,
|
||
ActivityID: &aid,
|
||
RewardID: &rid,
|
||
Remark: rw.Name,
|
||
})
|
||
if err != nil {
|
||
fmt.Printf("[定时开奖-一番赏] ⚠️ 发放奖励失败 err=%v\n", err)
|
||
}
|
||
|
||
fmt.Printf("[定时开奖-一番赏] ✅ 开奖成功 UserID=%d OrderID=%d RewardID=%d\n", uid, o.ID, rid)
|
||
|
||
// 保存可验证凭据
|
||
if err := strat.SaveDrawReceipt(ctx, w, drawLog.ID, iss, uid, proof); err != nil {
|
||
fmt.Printf("[定时开奖-一番赏] ⚠️ 保存凭据失败 DrawLogID=%d IssueID=%d UserID=%d err=%v proof=%+v\n", drawLog.ID, iss, uid, err, proof)
|
||
} else {
|
||
fmt.Printf("[定时开奖-一番赏] ✅ 保存凭据成功 DrawLogID=%d IssueID=%d\n", drawLog.ID, iss)
|
||
}
|
||
}
|
||
// 【开奖后虚拟发货】定时一番赏开奖后上传虚拟发货
|
||
uploadVirtualShippingForScheduledDraw(ctx, r, o.ID, o.OrderNo, uid, func() string {
|
||
act, _ := r.Activities.WithContext(ctx).Where(r.Activities.ID.Eq(aid)).First()
|
||
if act != nil {
|
||
return act.Name
|
||
}
|
||
return "活动"
|
||
}(), "ichiban")
|
||
}
|
||
} else {
|
||
// 默认玩法逻辑
|
||
sel := strat.NewDefault(r, w)
|
||
// Daily Seed removed
|
||
for _, o := range orders {
|
||
uid := o.UserID
|
||
iss := extractIssueID(o.Remark)
|
||
dc := extractCount(o.Remark)
|
||
if dc <= 0 {
|
||
dc = 1
|
||
}
|
||
logs, _ := r.ActivityDrawLogs.WithContext(ctx).Where(r.ActivityDrawLogs.OrderID.Eq(o.ID)).Find()
|
||
done := int64(len(logs))
|
||
for i := done; i < dc; i++ {
|
||
rid, proof, err := sel.SelectItem(ctx, aid, iss, uid)
|
||
if err != nil || rid <= 0 {
|
||
break
|
||
}
|
||
rw, err := r.ActivityRewardSettings.WithContext(ctx).Where(r.ActivityRewardSettings.ID.Eq(rid)).First()
|
||
if err != nil || rw == nil {
|
||
break
|
||
}
|
||
// 【先记录日志,再发奖】确保日志创建成功后再发奖,防止重复
|
||
drawLog := &model.ActivityDrawLogs{UserID: uid, IssueID: iss, OrderID: o.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1}
|
||
if err := w.ActivityDrawLogs.WithContext(ctx).Create(drawLog); err != nil {
|
||
fmt.Printf("[定时开奖-默认] ❌ 创建开奖日志失败 err=%v,可能已存在,跳过\n", err)
|
||
break
|
||
}
|
||
// 发放奖励(在原订单上添加中奖商品,不创建新订单)
|
||
_, _ = us.GrantRewardToOrder(ctx, uid, usersvc.GrantRewardToOrderRequest{OrderID: o.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &aid, RewardID: &rid, Remark: rw.Name})
|
||
// 保存可验证凭据
|
||
if err := strat.SaveDrawReceipt(ctx, w, drawLog.ID, iss, uid, proof); err != nil {
|
||
fmt.Printf("[定时开奖-默认] ⚠️ 保存凭据失败 DrawLogID=%d IssueID=%d UserID=%d err=%v proof=%+v\n", drawLog.ID, iss, uid, err, proof)
|
||
} else {
|
||
fmt.Printf("[定时开奖-默认] ✅ 保存凭据成功 DrawLogID=%d IssueID=%d\n", drawLog.ID, iss)
|
||
}
|
||
}
|
||
// 【开奖后虚拟发货】定时开奖后上传虚拟发货(非一番赏不发通知)
|
||
uploadVirtualShippingForScheduledDraw(ctx, r, o.ID, o.OrderNo, uid, func() string {
|
||
act, _ := r.Activities.WithContext(ctx).Where(r.Activities.ID.Eq(aid)).First()
|
||
if act != nil {
|
||
return act.Name
|
||
}
|
||
return "活动"
|
||
}(), "default")
|
||
}
|
||
}
|
||
}
|
||
|
||
// 【修复】无论成功开奖还是退款,都更新活动的结算时间与下次计划
|
||
var next *time.Time
|
||
if a.IntervalMinutes > 0 {
|
||
t := now.Add(time.Duration(a.IntervalMinutes) * time.Minute)
|
||
next = &t
|
||
}
|
||
var nextVal sql.NullTime
|
||
if next != nil {
|
||
nextVal = sql.NullTime{Time: next.UTC(), Valid: true}
|
||
}
|
||
fmt.Printf("[定时开奖] 活动ID=%d 更新时间: last_settled_at=%s scheduled_time=%s\n", aid, now.Format("2006-01-02 15:04:05"), next)
|
||
_ = repo.GetDbW().WithContext(ctx).Exec("UPDATE activities SET last_settled_at=?, scheduled_time=? WHERE id= ?", now.UTC(), nextVal, aid).Error
|
||
}
|
||
|
||
// 即时开奖:处理所有已支付且未记录抽奖日志的订单
|
||
var instantActs []struct {
|
||
ID int64
|
||
}
|
||
_ = repo.GetDbR().WithContext(ctx).Raw("SELECT id FROM activities WHERE draw_mode='instant'").Scan(&instantActs)
|
||
if len(instantActs) > 0 {
|
||
sel2 := strat.NewDefault(r, w)
|
||
for _, ia := range instantActs {
|
||
orders2, _ := r.Orders.WithContext(ctx).ReadDB().Where(
|
||
r.Orders.Status.Eq(2),
|
||
r.Orders.SourceType.Eq(2),
|
||
r.Orders.Remark.Like(fmt.Sprintf("lottery:activity:%d|%%", ia.ID)),
|
||
).Find()
|
||
// Daily Seed removed
|
||
for _, o2 := range orders2 {
|
||
uid := o2.UserID
|
||
iss := extractIssueID(o2.Remark)
|
||
dc := extractCount(o2.Remark)
|
||
if dc <= 0 {
|
||
dc = 1
|
||
}
|
||
logs2, _ := r.ActivityDrawLogs.WithContext(ctx).Where(r.ActivityDrawLogs.OrderID.Eq(o2.ID)).Find()
|
||
done2 := int64(len(logs2))
|
||
for i := done2; i < dc; i++ {
|
||
rid, proof, err := sel2.SelectItem(ctx, ia.ID, iss, uid)
|
||
if err != nil || rid <= 0 {
|
||
break
|
||
}
|
||
rw, err := r.ActivityRewardSettings.WithContext(ctx).Where(r.ActivityRewardSettings.ID.Eq(rid)).First()
|
||
if err != nil || rw == nil {
|
||
break
|
||
}
|
||
// 【先记录日志,再发奖】确保日志创建成功后再发奖,防止重复
|
||
drawLog := &model.ActivityDrawLogs{UserID: uid, IssueID: iss, OrderID: o2.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1}
|
||
if err := w.ActivityDrawLogs.WithContext(ctx).Create(drawLog); err != nil {
|
||
fmt.Printf("[即时开奖补偿] ❌ 创建开奖日志失败 err=%v,可能已存在,跳过\n", err)
|
||
break
|
||
}
|
||
_, _ = us.GrantRewardToOrder(ctx, uid, usersvc.GrantRewardToOrderRequest{OrderID: o2.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &ia.ID, RewardID: &rid, Remark: rw.Name})
|
||
// 保存可验证凭据
|
||
if err := strat.SaveDrawReceipt(ctx, w, drawLog.ID, iss, uid, proof); err != nil {
|
||
fmt.Printf("[即时开奖补偿] ⚠️ 保存凭据失败 DrawLogID=%d IssueID=%d UserID=%d err=%v proof=%+v\n", drawLog.ID, iss, uid, err, proof)
|
||
} else {
|
||
fmt.Printf("[即时开奖补偿] ✅ 保存凭据成功 DrawLogID=%d IssueID=%d\n", drawLog.ID, iss)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
func parseTime(s string) time.Time {
|
||
if s == "" {
|
||
return time.Time{}
|
||
}
|
||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||
return t
|
||
}
|
||
if t2, err2 := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err2 == nil {
|
||
return t2
|
||
}
|
||
return time.Time{}
|
||
}
|
||
|
||
func parseInt64(s string) int64 {
|
||
var n int64
|
||
for i := 0; i < len(s); i++ {
|
||
c := s[i]
|
||
if c < '0' || c > '9' {
|
||
break
|
||
}
|
||
n = n*10 + int64(c-'0')
|
||
}
|
||
return n
|
||
}
|
||
|
||
func extractIssueID(remark string) int64 {
|
||
if remark == "" {
|
||
return 0
|
||
}
|
||
parts := strings.Split(remark, "|")
|
||
for _, p := range parts {
|
||
if strings.HasPrefix(p, "issue:") {
|
||
return parseInt64(p[6:])
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func extractCount(remark string) int64 {
|
||
if remark == "" {
|
||
return 1
|
||
}
|
||
parts := strings.Split(remark, "|")
|
||
for _, p := range parts {
|
||
if strings.HasPrefix(p, "count:") {
|
||
return parseInt64(p[6:])
|
||
}
|
||
}
|
||
return 1
|
||
}
|
||
|
||
// uploadVirtualShippingForScheduledDraw 定时开奖后上传虚拟发货
|
||
// 收集中奖产品名称并调用微信虚拟发货API
|
||
// playType: 活动玩法类型,只有 ichiban 时才发送开奖结果通知
|
||
func uploadVirtualShippingForScheduledDraw(ctx context.Context, r *dao.Query, orderID int64, orderNo string, userID int64, actName string, playType string) {
|
||
// 获取开奖记录
|
||
drawLogs, _ := r.ActivityDrawLogs.WithContext(ctx).Where(r.ActivityDrawLogs.OrderID.Eq(orderID)).Find()
|
||
if len(drawLogs) == 0 {
|
||
fmt.Printf("[定时开奖-虚拟发货] 没有开奖记录,跳过 order_id=%d\n", orderID)
|
||
return
|
||
}
|
||
// 收集赏品名称
|
||
var rewardNames []string
|
||
for _, lg := range drawLogs {
|
||
if rw, _ := r.ActivityRewardSettings.WithContext(ctx).Where(r.ActivityRewardSettings.ID.Eq(lg.RewardID)).First(); rw != nil {
|
||
rewardNames = append(rewardNames, rw.Name)
|
||
}
|
||
}
|
||
itemsDesc := actName + " " + orderNo + " 盲盒赏品: " + strings.Join(rewardNames, ", ")
|
||
if len(itemsDesc) > 120 {
|
||
itemsDesc = itemsDesc[:120]
|
||
}
|
||
// 获取支付交易信息
|
||
tx, _ := r.PaymentTransactions.WithContext(ctx).Where(r.PaymentTransactions.OrderNo.Eq(orderNo)).First()
|
||
if tx == nil || tx.TransactionID == "" {
|
||
fmt.Printf("[定时开奖-虚拟发货] 没有支付交易记录,跳过 order_no=%s\n", orderNo)
|
||
return
|
||
}
|
||
// 获取用户openid
|
||
u, _ := r.Users.WithContext(ctx).Where(r.Users.ID.Eq(userID)).First()
|
||
payerOpenid := ""
|
||
if u != nil {
|
||
payerOpenid = u.Openid
|
||
}
|
||
// 获取微信配置
|
||
c := configs.Get()
|
||
cfg := &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret}
|
||
fmt.Printf("[定时开奖-虚拟发货] 上传 order_no=%s transaction_id=%s items_desc=%s\n", orderNo, tx.TransactionID, itemsDesc)
|
||
if err := wechat.UploadVirtualShippingForBackground(ctx, cfg, tx.TransactionID, orderNo, payerOpenid, itemsDesc); err != nil {
|
||
fmt.Printf("[定时开奖-虚拟发货] 上传失败: %v\n", err)
|
||
}
|
||
// 【定时开奖后推送通知】只有一番赏才发送
|
||
if playType == "ichiban" {
|
||
notifyCfg := ¬ify.WechatNotifyConfig{
|
||
AppID: c.Wechat.AppID,
|
||
AppSecret: c.Wechat.AppSecret,
|
||
LotteryResultTemplateID: c.Wechat.LotteryResultTemplateID,
|
||
}
|
||
_ = notify.SendLotteryResultNotification(ctx, notifyCfg, payerOpenid, actName, rewardNames, orderNo, time.Now())
|
||
}
|
||
}
|
||
|
||
func refundOrder(ctx context.Context, o *model.Orders, reason string, wc *paypkg.WechatPayClient, r *dao.Query, w *dao.Query, us usersvc.Service, refundCouponID int64) {
|
||
// 1. Refund Points
|
||
if o.PointsAmount > 0 {
|
||
refundPts := o.PointsAmount / 100
|
||
_, _ = us.RefundPoints(ctx, o.UserID, refundPts, o.OrderNo, reason)
|
||
}
|
||
|
||
// 2. Refund WeChat
|
||
if o.ActualAmount > 0 && wc != nil {
|
||
refundNo := fmt.Sprintf("R%s-%d", o.OrderNo, time.Now().Unix())
|
||
refundID, status, err := wc.RefundOrder(ctx, o.OrderNo, refundNo, o.ActualAmount, o.ActualAmount, reason)
|
||
if err == nil {
|
||
_ = w.PaymentRefunds.WithContext(ctx).Create(&model.PaymentRefunds{
|
||
OrderID: o.ID,
|
||
OrderNo: o.OrderNo,
|
||
RefundNo: refundNo,
|
||
Channel: "wechat_jsapi",
|
||
Status: status,
|
||
AmountRefund: o.ActualAmount,
|
||
Reason: reason,
|
||
SuccessTime: time.Now(),
|
||
})
|
||
_ = w.UserPointsLedger.WithContext(ctx).Create(&model.UserPointsLedger{UserID: o.UserID, Action: "refund_amount", Points: o.ActualAmount / 100, RefTable: "payment_refund", RefID: refundID})
|
||
} else {
|
||
fmt.Printf("[Refund] WeChat refund failed for order %s: %v\n", o.OrderNo, err)
|
||
}
|
||
}
|
||
|
||
// 3. Refund Used Coupons
|
||
ocs, _ := r.OrderCoupons.WithContext(ctx).Where(r.OrderCoupons.OrderID.Eq(o.ID)).Find()
|
||
for _, oc := range ocs {
|
||
// Restore user coupon status to 1 (Unused) and clear usage info
|
||
_, err := w.UserCoupons.WithContext(ctx).Where(w.UserCoupons.ID.Eq(oc.UserCouponID)).Updates(map[string]interface{}{
|
||
"status": 1,
|
||
"used_order_id": 0,
|
||
"used_at": nil,
|
||
})
|
||
if err != nil {
|
||
fmt.Printf("[Refund] Failed to restore coupon %d for order %s: %v\n", oc.UserCouponID, o.OrderNo, err)
|
||
} else {
|
||
fmt.Printf("[Refund] Restored coupon %d for order %s\n", oc.UserCouponID, o.OrderNo)
|
||
}
|
||
}
|
||
|
||
// 3.5. 一番赏退款:删除 issue_position_claims 记录,恢复格位
|
||
iss := extractIssueID(o.Remark)
|
||
if iss > 0 {
|
||
result, err := w.IssuePositionClaims.WithContext(ctx).Where(
|
||
w.IssuePositionClaims.OrderID.Eq(o.ID),
|
||
).Delete()
|
||
if err != nil {
|
||
fmt.Printf("[Refund] Failed to delete position claims for order %d: %v\n", o.ID, err)
|
||
} else if result.RowsAffected > 0 {
|
||
fmt.Printf("[Refund] ✅ Restored %d slot position(s) for order %d issue %d\n", result.RowsAffected, o.ID, iss)
|
||
}
|
||
}
|
||
|
||
// 4. Update Order Status
|
||
_, _ = w.Orders.WithContext(ctx).Where(w.Orders.ID.Eq(o.ID)).Updates(map[string]any{w.Orders.Status.ColumnName().String(): 4})
|
||
|
||
// 5. Log Refund
|
||
iss = extractIssueID(o.Remark)
|
||
_ = w.Orders.WithContext(ctx).UnderlyingDB().Exec("INSERT INTO lottery_refund_logs(issue_id, order_id, user_id, amount, coupon_type, coupon_amount, reason, status) VALUES(?,?,?,?,?,?,?,?)", iss, o.ID, o.UserID, o.ActualAmount, "", 0, reason, "done").Error
|
||
|
||
// 6. Compensation
|
||
if refundCouponID > 0 {
|
||
_ = us.AddCoupon(ctx, o.UserID, refundCouponID)
|
||
}
|
||
}
|