bindbox-game/internal/api/admin/pay_refund_admin.go

414 lines
15 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 admin
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strconv"
"time"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
paypkg "bindbox-game/internal/pkg/pay"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model"
usersvc "bindbox-game/internal/service/user"
)
type createRefundRequest struct {
OrderNo string `json:"order_no" form:"order_no"`
Amount int64 `json:"amount" form:"amount"`
Reason string `json:"reason" form:"reason"`
}
type createRefundResponse struct {
OrderNo string `json:"order_no"`
Status string `json:"status"`
}
// CreateRefund 管理端创建退款(占位实现)
// 入参order_no、amount(分)、reason
// 行为将订单状态置为已退款并写入积分流水refund_restore(按订单积分抵扣金额恢复)
// 注意真实场景需调用微信退款API并根据结果更新状态本版本为占位保证编译与联调
func (h *handler) CreateRefund() core.HandlerFunc {
return func(ctx core.Context) {
req := new(createRefundRequest)
rsp := new(createRefundResponse)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
// 查询订单
order, err := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.OrderNo.Eq(req.OrderNo)).First()
if err != nil || order == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 160001, "order not found"))
return
}
// 检查订单状态,只有已支付的订单才能退款
if order.Status != 2 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 160010, "订单状态不允许退款"))
return
}
// 预检查:检查是否有已兑换积分的资产
allInvs, _ := h.readDB.UserInventory.WithContext(ctx.RequestContext()).Where(h.readDB.UserInventory.OrderID.Eq(order.ID)).Where(h.readDB.UserInventory.Status.In(1, 3)).Find()
rePoints := regexp.MustCompile(`\|redeemed_points=(\d+)`)
var refundedSumCents int64
var isFullRefund bool
// ⭐ 根据 ActualAmount 决定是否需要微信退款
if order.ActualAmount == 0 {
// ActualAmount=0无需微信退款直接标记为全额退款
isFullRefund = true
h.logger.Info(fmt.Sprintf("refund: ActualAmount=0, skip wechat refund: order=%s", order.OrderNo))
} else {
// ActualAmount>0需要调用微信退款
// 计算已退款与可退余额(分)
ledgers, _ := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserPointsLedger.RefTable.Eq("orders"), h.readDB.UserPointsLedger.RefID.Eq(order.OrderNo)).Find()
for _, l := range ledgers {
if l.Action == "refund_amount" {
refundedSumCents += l.Points * 100
}
}
refundable := order.ActualAmount - refundedSumCents
if refundable < 0 {
refundable = 0
}
if req.Amount <= 0 || req.Amount > refundable {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 160005, fmt.Sprintf("invalid refund amount, max=%d", refundable)))
return
}
// 调用微信真实退款
wc, err := paypkg.NewWechatPayClient(ctx.RequestContext())
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 160006, err.Error()))
return
}
refundNo := fmt.Sprintf("R%s-%d", order.OrderNo, time.Now().Unix())
refundID, status, err := wc.RefundOrder(ctx.RequestContext(), order.OrderNo, refundNo, req.Amount, order.ActualAmount, req.Reason)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 160007, err.Error()))
return
}
pr := &model.PaymentRefunds{
OrderID: order.ID,
OrderNo: order.OrderNo,
RefundNo: refundNo,
Channel: "wechat_jsapi",
Status: status,
AmountRefund: req.Amount,
Reason: req.Reason,
SuccessTime: time.Now(),
Raw: func() string {
b, _ := json.Marshal(map[string]any{"refund_id": refundID, "status": status})
return string(b)
}(),
}
if err := h.writeDB.PaymentRefunds.WithContext(ctx.RequestContext()).Create(pr); err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 160009, err.Error()))
return
}
// 判断是否为全额退款
isFullRefund = req.Amount == order.ActualAmount-refundedSumCents
}
// 更新订单状态为已退款(全额退款时)
if isFullRefund {
_, err = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.writeDB.Orders.ID.Eq(order.ID)).Updates(map[string]any{
h.writeDB.Orders.Status.ColumnName().String(): 4,
h.writeDB.Orders.UpdatedAt.ColumnName().String(): time.Now(),
})
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 160002, err.Error()))
return
}
// 全额退款:恢复订单中使用的优惠券(细化余额与状态逻辑)
type ocRow struct {
UserCouponID int64
AppliedAmount int64
DiscountType int32
DiscountValue int64
BalanceAmount int64
}
var rows []ocRow
_ = h.repo.GetDbR().Raw("SELECT oc.user_coupon_id, oc.applied_amount, sc.discount_type, sc.discount_value, uc.balance_amount FROM order_coupons oc JOIN user_coupons uc ON uc.id=oc.user_coupon_id JOIN system_coupons sc ON sc.id=uc.coupon_id WHERE oc.order_id=?", order.ID).Scan(&rows).Error
for _, r := range rows {
if r.UserCouponID > 0 && r.AppliedAmount > 0 {
newBal := r.BalanceAmount + r.AppliedAmount
if r.DiscountType == 1 { // 直金额券:判断回退后是否全满
if newBal >= r.DiscountValue {
_ = h.repo.GetDbW().Exec("UPDATE user_coupons SET balance_amount=?, status=1, used_order_id=0, used_at=NULL WHERE id=?", r.DiscountValue, r.UserCouponID).Error
newBal = r.DiscountValue
} else {
// 若金额未满,维持 status=2 (已使用/使用中)
_ = h.repo.GetDbW().Exec("UPDATE user_coupons SET balance_amount=?, status=2 WHERE id=?", newBal, r.UserCouponID).Error
}
} else { // 满减/折扣券:一律恢复为未使用
_ = h.repo.GetDbW().Exec("UPDATE user_coupons SET status=1, used_order_id=0, used_at=NULL WHERE id=?", r.UserCouponID).Error
newBal = 0
}
// 记录流水
ledger := &model.UserCouponLedger{
UserID: order.UserID,
UserCouponID: r.UserCouponID,
ChangeAmount: r.AppliedAmount,
BalanceAfter: newBal,
OrderID: order.ID,
Action: "refund_restore",
CreatedAt: time.Now(),
}
_ = h.repo.GetDbW().Create(ledger).Error
}
}
// 全额退款:回收中奖资产与奖品库存(包含已兑换积分的资产)
svc := usersvc.New(h.logger, h.repo)
var pointsShortage bool
for _, inv := range allInvs {
if inv.Status == 1 {
// 状态1持有更新状态为2 (作废)
_ = h.repo.GetDbW().Exec("UPDATE user_inventory SET status=2, updated_at=NOW(3), remark=CONCAT(IFNULL(remark,''),'|refund_void') WHERE id=?", inv.ID).Error
// 恢复奖品库存
if inv.RewardID > 0 {
_ = h.repo.GetDbW().Exec("UPDATE activity_reward_settings SET quantity = quantity + 1 WHERE id=?", inv.RewardID).Error
}
} else if inv.Status == 3 {
// 状态3已兑换扣除积分并作废
matches := rePoints.FindStringSubmatch(inv.Remark)
if len(matches) > 1 {
p, _ := strconv.ParseInt(matches[1], 10, 64)
if p > 0 {
// 扣除积分(记录流水)- 使用柔性扣减
_, consumed, err := svc.ConsumePointsForRefund(ctx.RequestContext(), order.UserID, p, "user_inventory", strconv.FormatInt(inv.ID, 10), "订单退款回收兑换积分")
if err != nil {
h.logger.Error(fmt.Sprintf("refund reclaim points failed: order=%s user=%d points=%d err=%v", order.OrderNo, order.UserID, p, err))
}
if consumed < p {
pointsShortage = true
}
}
}
// 更新状态为2 (作废)
_ = h.repo.GetDbW().Exec("UPDATE user_inventory SET status=2, updated_at=NOW(3), remark=CONCAT(IFNULL(remark,''),'|refund_reclaimed') WHERE id=?", inv.ID).Error
// 恢复奖品库存
if inv.RewardID > 0 {
_ = h.repo.GetDbW().Exec("UPDATE activity_reward_settings SET quantity = quantity + 1 WHERE id=?", inv.RewardID).Error
}
}
}
// 如果扣减积分不足,将用户列入黑名单
if pointsShortage {
h.logger.Warn(fmt.Sprintf("[退款黑名单] 用户积分不足以回收已兑换资产,将其列入黑名单: user_id=%d, order_no=%s", order.UserID, order.OrderNo))
_ = h.repo.GetDbW().Exec("UPDATE users SET status=? WHERE id=?", model.UserStatusBlacklist, order.UserID).Error
}
// 全额退款:取消待发货记录
_ = h.repo.GetDbW().Exec("UPDATE shipping_records SET status=4, updated_at=NOW(3), remark=CONCAT(IFNULL(remark,''),'|refund_cancel') WHERE order_id=? AND status=1", order.ID).Error
// 全额退款:回退道具卡(支持两种记录方式)
var itemCardIDs []int64
// 方式1从 activity_draw_effects 表查询(无限赏等游戏类型)
_ = h.repo.GetDbR().Raw("SELECT user_item_card_id FROM activity_draw_effects WHERE draw_log_id IN (SELECT id FROM activity_draw_logs WHERE order_id=?)", order.ID).Scan(&itemCardIDs).Error
// 方式2从 user_item_cards 表的 used_draw_log_id 直接查询(对对碰等游戏类型)
var itemCardIDsFromItemCards []int64
_ = h.repo.GetDbR().Raw("SELECT id FROM user_item_cards WHERE used_draw_log_id IN (SELECT id FROM activity_draw_logs WHERE order_id=?) AND status=2", order.ID).Scan(&itemCardIDsFromItemCards).Error
// 合并去重
idSet := make(map[int64]struct{})
for _, icID := range itemCardIDs {
if icID > 0 {
idSet[icID] = struct{}{}
}
}
for _, icID := range itemCardIDsFromItemCards {
if icID > 0 {
idSet[icID] = struct{}{}
}
}
// 执行退还
for icID := range idSet {
_ = h.repo.GetDbW().Exec("UPDATE user_item_cards SET status=1, used_at=NULL, used_draw_log_id=0, used_activity_id=0, used_issue_id=0, updated_at=NOW(3) WHERE id=?", icID).Error
}
// 全额退款回退次数卡user_game_passes
// 解析订单 remark 中的 game_pass:xxx ID
reGamePass := regexp.MustCompile(`game_pass:(\d+)`)
gamePassMatches := reGamePass.FindStringSubmatch(order.Remark)
if len(gamePassMatches) > 1 {
gamePassID, _ := strconv.ParseInt(gamePassMatches[1], 10, 64)
if gamePassID > 0 {
// 恢复次数卡remaining +1, total_used -1
if err := h.repo.GetDbW().Exec("UPDATE user_game_passes SET remaining = remaining + 1, total_used = GREATEST(total_used - 1, 0), updated_at = NOW(3) WHERE id = ?", gamePassID).Error; err != nil {
h.logger.Error(fmt.Sprintf("refund restore game_pass failed: order=%s game_pass_id=%d err=%v", order.OrderNo, gamePassID, err))
} else {
h.logger.Info(fmt.Sprintf("refund restore game_pass success: order=%s game_pass_id=%d", order.OrderNo, gamePassID))
}
}
}
}
// 记录积分按比例恢复(幂等增量)- 仅对 ActualAmount > 0 的订单执行
if order.PointsAmount > 0 && order.ActualAmount > 0 {
restores, _ := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().Where(
h.readDB.UserPointsLedger.RefTable.Eq("orders"),
h.readDB.UserPointsLedger.RefID.Eq(order.OrderNo),
h.readDB.UserPointsLedger.Action.Eq("refund_restore"),
).Find()
var restoredPointsSum int64
for _, r := range restores {
restoredPointsSum += r.Points
}
totalRefundedAfter := refundedSumCents + req.Amount
if totalRefundedAfter > order.ActualAmount {
totalRefundedAfter = order.ActualAmount
}
restoreCentsTarget := (order.PointsAmount * totalRefundedAfter) / order.ActualAmount
restorePointsTarget := restoreCentsTarget
toAdd := restorePointsTarget - restoredPointsSum
maxPoints := order.PointsAmount
if toAdd < 0 {
toAdd = 0
}
if toAdd > (maxPoints - restoredPointsSum) {
toAdd = maxPoints - restoredPointsSum
}
if toAdd > 0 {
ledger := &model.UserPointsLedger{
UserID: order.UserID,
Action: "refund_restore",
Points: toAdd,
RefTable: "orders",
RefID: order.OrderNo,
Remark: req.Reason,
}
if err := h.writeDB.UserPointsLedger.WithContext(ctx.RequestContext()).Create(ledger); err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 160003, err.Error()))
return
}
}
}
rsp.OrderNo = order.OrderNo
rsp.Status = "success"
ctx.Payload(rsp)
}
}
type listRefundsRequest struct {
OrderNo string `form:"order_no"`
Status string `form:"status"`
Page int `form:"page"`
Size int `form:"size"`
}
type listRefundsResponse struct {
Total int64 `json:"total"`
List []map[string]any `json:"list"`
}
func (h *handler) ListRefunds() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listRefundsRequest)
if err := ctx.ShouldBindQuery(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.Size <= 0 || req.Size > 100 {
req.Size = 20
}
q := h.readDB.PaymentRefunds.WithContext(ctx.RequestContext()).ReadDB()
if req.OrderNo != "" {
q = q.Where(h.readDB.PaymentRefunds.OrderNo.Eq(req.OrderNo))
}
if req.Status != "" {
q = q.Where(h.readDB.PaymentRefunds.Status.Eq(req.Status))
}
total, _ := q.Count()
items, _ := q.Order(h.readDB.PaymentRefunds.ID.Desc()).Limit(req.Size).Offset((req.Page - 1) * req.Size).Find()
var list []map[string]any
for _, it := range items {
list = append(list, map[string]any{
"refund_no": it.RefundNo,
"order_no": it.OrderNo,
"status": it.Status,
"channel": it.Channel,
"amount_refund": it.AmountRefund,
"reason": it.Reason,
"created_at": it.CreatedAt,
})
}
ctx.Payload(&listRefundsResponse{Total: total, List: list})
}
}
type getRefundDetailResponse struct {
Refund map[string]any `json:"refund"`
Order map[string]any `json:"order"`
}
func (h *handler) GetRefundDetail() core.HandlerFunc {
return func(ctx core.Context) {
refundNo := ctx.Param("refund_no")
if refundNo == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "missing refund_no"))
return
}
r, err := h.readDB.PaymentRefunds.WithContext(ctx.RequestContext()).Where(h.readDB.PaymentRefunds.RefundNo.Eq(refundNo)).First()
if err != nil || r == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 160008, "refund not found"))
return
}
o, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.OrderNo.Eq(r.OrderNo)).First()
resp := &getRefundDetailResponse{
Refund: map[string]any{
"refund_no": r.RefundNo,
"order_no": r.OrderNo,
"status": r.Status,
"channel": r.Channel,
"amount_refund": r.AmountRefund,
"reason": r.Reason,
"created_at": r.CreatedAt,
},
Order: map[string]any{
"order_no": func() string {
if o != nil {
return o.OrderNo
}
return ""
}(),
"user_id": func() int64 {
if o != nil {
return o.UserID
}
return 0
}(),
"status": func() int32 {
if o != nil {
return o.Status
}
return 0
}(),
"actual_amount": func() int64 {
if o != nil {
return o.ActualAmount
}
return 0
}(),
"points_amount": func() int64 {
if o != nil {
return o.PointsAmount
}
return 0
}(),
},
}
ctx.Payload(resp)
}
}