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

390 lines
14 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"
"strings"
"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
}
// 预检查:检查是否有已兑换积分的资产,并验证用户积分余额是否足够扣除
allInvs, _ := h.readDB.UserInventory.WithContext(ctx.RequestContext()).Where(h.readDB.UserInventory.OrderID.Eq(order.ID)).Where(h.readDB.UserInventory.Status.In(1, 3)).Find()
var pointsToReclaim int64
rePoints := regexp.MustCompile(`\|redeemed_points=(\d+)`)
for _, inv := range allInvs {
if inv.Status == 3 && strings.Contains(inv.Remark, "redeemed_points=") {
matches := rePoints.FindStringSubmatch(inv.Remark)
if len(matches) > 1 {
p, _ := strconv.ParseInt(matches[1], 10, 64)
pointsToReclaim += p
}
}
}
if pointsToReclaim > 0 {
svc := usersvc.New(h.logger, h.repo)
balance, err := svc.GetPointsBalance(ctx.RequestContext(), order.UserID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 160010, "check points balance failed"))
return
}
if balance < pointsToReclaim {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 160011, fmt.Sprintf("用户积分不足以抵扣已兑换积分(需%d, 余额%d),无法退款", pointsToReclaim, balance)))
return
}
}
// 计算已退款与可退余额(分)
ledgers, _ := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserPointsLedger.RefTable.Eq("orders"), h.readDB.UserPointsLedger.RefID.Eq(order.OrderNo)).Find()
var refundedSumCents int64
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
}
// 更新订单状态为已退款
if req.Amount == order.ActualAmount-refundedSumCents {
_, 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)
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 {
// 扣除积分(记录流水)
_, err := svc.ConsumePointsFor(ctx.RequestContext(), order.UserID, p, "user_inventory", strconv.FormatInt(inv.ID, 10), "订单退款回收兑换积分", "refund_reclaim")
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))
}
}
}
// 更新状态为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
}
}
}
// 全额退款:取消待发货记录
_ = 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
_ = 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
for _, icID := range itemCardIDs {
if icID > 0 {
_ = 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
}
}
}
// 记录积分按比例恢复(幂等增量)
if order.PointsAmount > 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 / 100
toAdd := restorePointsTarget - restoredPointsSum
maxPoints := order.PointsAmount / 100
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
}
}
}
// 记录退款金额
amountLedger := &model.UserPointsLedger{
UserID: order.UserID,
Action: "refund_amount",
Points: req.Amount / 100,
RefTable: "payment_refund",
RefID: refundID,
Remark: req.Reason,
}
if err := h.writeDB.UserPointsLedger.WithContext(ctx.RequestContext()).Create(amountLedger); err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 160004, 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)
}
}