邹方成 a7a0f639e1 feat: 新增取消发货功能并优化任务中心
fix: 修复微信通知字段截断导致的编码错误
feat: 添加有效邀请相关字段和任务中心常量
refactor: 重构一番赏奖品格位逻辑
perf: 优化道具卡列表聚合显示
docs: 更新项目说明文档和API文档
test: 添加字符串截断工具测试
2025-12-23 22:26:07 +08:00

577 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 := &notify.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)
}
}