package admin import ( "encoding/json" "fmt" "net/http" "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" ) 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 } // 计算已退款与可退余额(分) 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 } } // 全额退款:回收中奖资产与奖品库存 type invRow struct { ID int64 RewardID int64 } var invs []invRow _ = h.repo.GetDbR().Raw("SELECT id, reward_id FROM user_inventory WHERE order_id=? AND status=1", order.ID).Scan(&invs).Error for _, inv := range invs { // 更新状态为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 // 恢复奖品库存 (ActivityRewardSettings) 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) } }