bindbox-game/internal/service/snapshot/rollback_service.go

363 lines
12 KiB
Go
Raw Permalink 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 snapshot
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"bindbox-game/internal/pkg/pay"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/model"
"gorm.io/gorm"
)
// RollbackResult 回滚结果
type RollbackResult struct {
Success bool `json:"success"`
RollbackLogID int64 `json:"rollback_log_id"`
PointsRestored int64 `json:"points_restored"`
CouponsRestored int `json:"coupons_restored"`
ItemCardsRestored int `json:"item_cards_restored"`
InventoryRevoked int `json:"inventory_revoked"`
RefundAmount int64 `json:"refund_amount"`
SubsequentOrdersCount int `json:"subsequent_orders_count"`
SubsequentOrders []string `json:"subsequent_orders"`
RedemptionsReverted int `json:"redemptions_reverted"`
ErrorMsg string `json:"error_msg,omitempty"`
}
// RollbackService 回滚服务
type RollbackService interface {
// ExecuteRollback 执行时间点完整回滚
ExecuteRollback(ctx context.Context, orderID int64, operatorID int64, operatorName string, reason string) (*RollbackResult, error)
}
type rollbackService struct {
db mysql.Repo
snapshot Service
}
// NewRollbackService 创建回滚服务
func NewRollbackService(db mysql.Repo, snapshot Service) RollbackService {
return &rollbackService{
db: db,
snapshot: snapshot,
}
}
// ExecuteRollback 执行时间点完整回滚
// 回滚到目标订单消费前的状态,撤销该时间点之后的所有变化
func (s *rollbackService) ExecuteRollback(ctx context.Context, orderID int64, operatorID int64, operatorName string, reason string) (*RollbackResult, error) {
result := &RollbackResult{}
// 1. 查询目标订单
var order model.Orders
if err := s.db.GetDbR().WithContext(ctx).Where("id = ?", orderID).First(&order).Error; err != nil {
return nil, fmt.Errorf("订单不存在: %w", err)
}
// 2. 检查订单状态(只有已支付状态可以回滚)
if order.Status != 2 {
return nil, errors.New("订单状态不允许回滚,只有已支付订单可以回滚")
}
// 3. 检查是否已有回滚记录
var existingRollback model.AuditRollbackLogs
if err := s.db.GetDbR().WithContext(ctx).Where("order_id = ? AND status = 1", orderID).First(&existingRollback).Error; err == nil {
return nil, errors.New("该订单已经执行过回滚")
}
// 4. 获取快照
beforeSnap, afterSnap, err := s.snapshot.GetSnapshotsByOrderID(ctx, orderID)
if err != nil {
return nil, fmt.Errorf("获取快照失败: %w", err)
}
if beforeSnap == nil {
return nil, errors.New("未找到消费前快照")
}
// 5. 解析目标状态
var targetState UserStateSnapshot
if err := json.Unmarshal([]byte(beforeSnap.SnapshotData), &targetState); err != nil {
return nil, fmt.Errorf("解析快照失败: %w", err)
}
// 获取快照时间点
snapshotTime := beforeSnap.CreatedAt
// 6. 开始事务执行时间点完整回滚
var rollbackLogID int64
changesApplied := map[string]any{}
var totalRefundAmount int64
err = s.db.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
userID := order.UserID
// ============ 步骤1: 查询并回滚该时间点之后的所有订单 ============
var subsequentOrders []model.Orders
tx.Where("user_id = ? AND id > ? AND status = 2", userID, orderID).Order("id DESC").Find(&subsequentOrders)
subsequentOrderNos := []string{}
for _, subOrder := range subsequentOrders {
subsequentOrderNos = append(subsequentOrderNos, subOrder.OrderNo)
// 作废后续订单产生的资产
tx.Model(&model.UserInventory{}).Where("order_id = ? AND status IN (1,3)", subOrder.ID).Update("status", 2)
// 恢复后续订单使用的优惠券
if subOrder.CouponID > 0 {
tx.Model(&model.UserCoupons{}).Where("id = ?", subOrder.CouponID).Updates(map[string]any{
"status": 1,
"used_order_id": 0,
"used_at": nil,
})
}
// 恢复后续订单使用的道具卡
if subOrder.ItemCardID > 0 {
tx.Model(&model.UserItemCards{}).Where("id = ?", subOrder.ItemCardID).Updates(map[string]any{
"status": 1,
"used_draw_log_id": 0,
"used_activity_id": 0,
"used_issue_id": 0,
"used_at": nil,
})
}
// 取消发货记录
tx.Model(&model.ShippingRecords{}).Where("order_id = ?", subOrder.ID).Update("status", 5)
// 更新订单状态为已回滚
tx.Model(&subOrder).Update("status", 5)
// 累计退款金额
if subOrder.ActualAmount > 0 {
totalRefundAmount += subOrder.ActualAmount
}
}
result.SubsequentOrdersCount = len(subsequentOrders)
result.SubsequentOrders = subsequentOrderNos
changesApplied["subsequent_orders"] = subsequentOrderNos
// ============ 步骤2: 撤销该时间点之后的积分兑换记录 ============
// 查询资产兑换记录status=3表示已兑换
var redeemedInventories []model.UserInventory
tx.Where("user_id = ? AND status = 3 AND updated_at > ?", userID, snapshotTime).Find(&redeemedInventories)
for _, inv := range redeemedInventories {
// 恢复资产状态为持有
tx.Model(&inv).Update("status", 1)
result.RedemptionsReverted++
}
changesApplied["redemptions_reverted"] = result.RedemptionsReverted
// ============ 步骤3: 撤销该时间点之后的优惠券兑换 ============
// 查询该时间点之后获取的优惠券(积分兑换的)
var redeemedCoupons []model.UserCoupons
tx.Where("user_id = ? AND created_at > ? AND status = 1 AND source LIKE '%redeem%'", userID, snapshotTime).Find(&redeemedCoupons)
for _, coupon := range redeemedCoupons {
// 作废兑换的优惠券
tx.Model(&coupon).Update("status", 3) // 过期/作废
}
changesApplied["coupons_voided"] = len(redeemedCoupons)
// ============ 步骤4: 撤销该时间点之后的道具卡兑换 ============
var redeemedCards []model.UserItemCards
tx.Where("user_id = ? AND created_at > ? AND status = 1 AND source LIKE '%redeem%'", userID, snapshotTime).Find(&redeemedCards)
for _, card := range redeemedCards {
// 作废兑换的道具卡
tx.Model(&card).Update("status", 3)
}
changesApplied["item_cards_voided"] = len(redeemedCards)
// ============ 步骤5: 恢复目标订单的优惠券 ============
if order.CouponID > 0 {
for _, c := range targetState.Coupons {
if c.UserCouponID == order.CouponID {
updates := map[string]any{
"status": c.Status,
"balance_amount": c.BalanceAmount,
"used_order_id": 0,
"used_at": nil,
}
if err := tx.Model(&model.UserCoupons{}).Where("id = ?", order.CouponID).Updates(updates).Error; err != nil {
return fmt.Errorf("恢复优惠券失败: %w", err)
}
result.CouponsRestored++
break
}
}
if result.CouponsRestored == 0 {
updates := map[string]any{
"status": 1,
"used_order_id": 0,
"used_at": nil,
}
if err := tx.Model(&model.UserCoupons{}).Where("id = ?", order.CouponID).Updates(updates).Error; err == nil {
result.CouponsRestored++
}
}
}
changesApplied["coupons_restored"] = result.CouponsRestored
// ============ 步骤6: 恢复目标订单的道具卡 ============
if order.ItemCardID > 0 {
for _, c := range targetState.ItemCards {
if c.UserItemCardID == order.ItemCardID {
updates := map[string]any{
"status": c.Status,
"used_draw_log_id": 0,
"used_activity_id": 0,
"used_issue_id": 0,
"used_at": nil,
}
if err := tx.Model(&model.UserItemCards{}).Where("id = ?", order.ItemCardID).Updates(updates).Error; err != nil {
return fmt.Errorf("恢复道具卡失败: %w", err)
}
result.ItemCardsRestored++
break
}
}
if result.ItemCardsRestored == 0 {
updates := map[string]any{
"status": 1,
"used_draw_log_id": 0,
"used_activity_id": 0,
"used_issue_id": 0,
"used_at": nil,
}
if err := tx.Model(&model.UserItemCards{}).Where("id = ?", order.ItemCardID).Updates(updates).Error; err == nil {
result.ItemCardsRestored++
}
}
}
changesApplied["item_cards_restored"] = result.ItemCardsRestored
// ============ 步骤7: 作废目标订单产生的资产 ============
updateRes := tx.Model(&model.UserInventory{}).Where("order_id = ? AND status IN (1,3)", orderID).Update("status", 2)
if updateRes.Error != nil {
return fmt.Errorf("作废资产失败: %w", updateRes.Error)
}
result.InventoryRevoked = int(updateRes.RowsAffected)
changesApplied["inventory_revoked"] = result.InventoryRevoked
// ============ 步骤8: 取消目标订单发货记录 ============
tx.Model(&model.ShippingRecords{}).Where("order_id = ?", orderID).Update("status", 5)
// ============ 步骤9: 恢复积分到目标状态 ============
if targetState.Points != nil {
var currentPoints model.UserPoints
if err := tx.Where("user_id = ?", userID).First(&currentPoints).Error; err == nil {
pointsDiff := targetState.Points.Balance - currentPoints.Points
if pointsDiff != 0 {
if err := tx.Model(&currentPoints).Update("points", targetState.Points.Balance).Error; err != nil {
return fmt.Errorf("恢复积分失败: %w", err)
}
// 记录积分流水
ledger := &model.UserPointsLedger{
UserID: userID,
Action: "rollback_restore",
Points: pointsDiff,
RefTable: "orders",
RefID: order.OrderNo,
Remark: fmt.Sprintf("时间点回滚恢复积分(含后续%d单): %s", len(subsequentOrders), reason),
}
if err := tx.Create(ledger).Error; err != nil {
return fmt.Errorf("记录积分流水失败: %w", err)
}
result.PointsRestored = pointsDiff
changesApplied["points_restored"] = pointsDiff
}
}
}
// ============ 步骤10: 更新目标订单状态为已回滚 ============
if err := tx.Model(&order).Update("status", 5).Error; err != nil {
return fmt.Errorf("更新订单状态失败: %w", err)
}
// 累计目标订单退款金额
if order.ActualAmount > 0 {
totalRefundAmount += order.ActualAmount
}
// ============ 步骤11: 记录回滚日志 ============
changesJSON, _ := json.Marshal(changesApplied)
var beforeSnapID, afterSnapID int64
if beforeSnap != nil {
beforeSnapID = beforeSnap.ID
}
if afterSnap != nil {
afterSnapID = afterSnap.ID
}
rollbackLog := &model.AuditRollbackLogs{
OrderID: orderID,
OrderNo: order.OrderNo,
UserID: userID,
OperatorID: operatorID,
OperatorName: operatorName,
RollbackType: 2, // 2=时间点完整回滚
BeforeSnapshotID: afterSnapID,
AfterSnapshotID: beforeSnapID,
ChangesApplied: string(changesJSON),
Reason: reason,
Status: 1,
}
if err := tx.Create(rollbackLog).Error; err != nil {
return fmt.Errorf("记录回滚日志失败: %w", err)
}
rollbackLogID = rollbackLog.ID
return nil
})
if err != nil {
result.Success = false
result.ErrorMsg = err.Error()
return result, err
}
// ============ 步骤12: 调用微信退款(事务外执行) ============
if totalRefundAmount > 0 {
// 为每个需要退款的订单发起退款
s.refundOrders(ctx, order.UserID, orderID, reason, result)
}
result.Success = true
result.RollbackLogID = rollbackLogID
return result, nil
}
// refundOrders 为回滚的订单发起退款
func (s *rollbackService) refundOrders(ctx context.Context, userID int64, startOrderID int64, reason string, result *RollbackResult) {
// 查询需要退款的订单状态已变为5=已回滚)
var ordersToRefund []model.Orders
s.db.GetDbR().WithContext(ctx).Where("user_id = ? AND id >= ? AND status = 5 AND actual_amount > 0", userID, startOrderID).Find(&ordersToRefund)
wc, err := pay.NewWechatPayClient(ctx)
if err != nil {
return
}
for _, refundOrder := range ordersToRefund {
refundNo := fmt.Sprintf("RB%s-%d", refundOrder.OrderNo, time.Now().Unix())
_, status, err := wc.RefundOrder(ctx, refundOrder.OrderNo, refundNo, refundOrder.ActualAmount, refundOrder.ActualAmount, fmt.Sprintf("时间点回滚: %s", reason))
if err == nil {
refundRecord := &model.PaymentRefunds{
OrderID: refundOrder.ID,
OrderNo: refundOrder.OrderNo,
RefundNo: refundNo,
Channel: "wechat_jsapi",
Status: status,
AmountRefund: refundOrder.ActualAmount,
Reason: fmt.Sprintf("时间点回滚: %s", reason),
}
s.db.GetDbW().WithContext(ctx).Create(refundRecord)
result.RefundAmount += refundOrder.ActualAmount
}
}
}