fix: 修复微信通知字段截断导致的编码错误 feat: 添加有效邀请相关字段和任务中心常量 refactor: 重构一番赏奖品格位逻辑 perf: 优化道具卡列表聚合显示 docs: 更新项目说明文档和API文档 test: 添加字符串截断工具测试
390 lines
14 KiB
Go
390 lines
14 KiB
Go
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)
|
||
}
|
||
}
|