414 lines
15 KiB
Go
414 lines
15 KiB
Go
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)
|
||
}
|
||
}
|