// 积分账务模块,负责积分入账、扣减、退款及批次分配记录维护。 package billing import ( "errors" "fmt" "strconv" "strings" "github.com/google/uuid" "gorm.io/gorm" ) var ErrInsufficientPoints = errors.New("积分余额不足,请前往充值") // pointGrant 表示参与扣减的可用积分批次。 type pointGrant struct { ID uuid.UUID AvailableAmount string } // pointAllocation 表示原扣减流水关联的积分批次和分配金额。 type pointAllocation struct { GrantID uuid.UUID Amount string } // parsePointCents 将最多两位小数的积分字符串转换为整数分值。 func parsePointCents(value string) (int64, error) { value = strings.TrimSpace(value) if value == "" { return 0, errors.New("积分数量为空") } parts := strings.Split(value, ".") if len(parts) > 2 || strings.HasPrefix(parts[0], "-") { return 0, fmt.Errorf("无效积分数量 %q", value) } whole, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { return 0, fmt.Errorf("无效积分数量 %q", value) } fraction := "" if len(parts) == 2 { fraction = parts[1] } if len(fraction) > 2 { if strings.Trim(fraction[2:], "0") != "" { return 0, fmt.Errorf("积分数量最多保留两位小数") } fraction = fraction[:2] } fraction += strings.Repeat("0", 2-len(fraction)) fractionValue, err := strconv.ParseInt(fraction, 10, 64) if err != nil || whole > (int64(^uint64(0)>>1)-fractionValue)/100 { return 0, fmt.Errorf("无效积分数量 %q", value) } return whole*100 + fractionValue, nil } // formatPointCents 将整数分值格式化为两位小数的积分字符串。 func formatPointCents(value int64) string { return fmt.Sprintf("%d.%02d", value/100, value%100) } // lockUser 锁定用户积分余额,避免并发账务操作导致余额和批次不一致。 func lockUser(tx *gorm.DB, userID uuid.UUID, requireActive bool) (string, error) { var balance string userQuery := "SELECT point_balance::text FROM web_users WHERE id=? FOR UPDATE" if requireActive { userQuery = "SELECT point_balance::text FROM web_users WHERE id=? AND enabled=true AND deleted_at IS NULL FOR UPDATE" } if err := tx.Raw(userQuery, userID).Scan(&balance).Error; err != nil { return "", err } if balance == "" { return "", gorm.ErrRecordNotFound } return balance, nil } // CreditPoints 创建永久有效的积分批次,并原子写入用户余额和积分流水。 func CreditPoints(tx *gorm.DB, userID uuid.UUID, sourceType, sourceID, points, remark string) (string, bool, error) { amount, err := parsePointCents(points) if err != nil || amount <= 0 { return "", false, errors.New("入账积分必须大于零") } balance, err := lockUser(tx, userID, true) if err != nil { return "", false, err } grantID := uuid.New() result := tx.Exec(`INSERT INTO point_grants(id,user_id,source_type,source_id,granted_amount,available_amount) VALUES(?,?,?,?,?::numeric,?::numeric) ON CONFLICT(source_type,source_id) DO NOTHING`, grantID, userID, sourceType, sourceID, points, points) if result.Error != nil { return "", false, result.Error } if result.RowsAffected == 0 { return balance, false, nil } if err := tx.Raw(`UPDATE web_users SET point_balance=point_balance+?::numeric WHERE id=? RETURNING point_balance::text`, points, userID).Scan(&balance).Error; err != nil { return "", false, err } if err := tx.Exec(`INSERT INTO point_ledger(user_id,change_amount,balance_after,business_type,business_id,remark,idempotency_key) VALUES(?,?::numeric,?::numeric,?,?,?,?)`, userID, points, balance, sourceType, sourceID, remark, sourceType+":"+sourceID).Error; err != nil { return "", false, err } return balance, true, nil } // CreditRedemptionPoints 将兑换码对应积分以永久有效批次计入用户账户。 func CreditRedemptionPoints(tx *gorm.DB, userID, codeID uuid.UUID, points string) (string, bool, error) { return CreditPoints(tx, userID, "redemption", codeID.String(), points, "兑换码兑换") } // DebitPoints 按批次创建顺序扣减积分,并记录分配明细供后续退款恢复。 func DebitPoints(tx *gorm.DB, userID uuid.UUID, points, businessType, businessID, remark, idempotencyKey string) (string, error) { requested, err := parsePointCents(points) if err != nil { return "", err } balance, err := lockUser(tx, userID, true) if err != nil { return "", err } if requested == 0 { return balance, nil } var grants []pointGrant if err := tx.Raw(`SELECT id,available_amount::text AS available_amount FROM point_grants WHERE user_id=? AND available_amount>0 ORDER BY created_at,id FOR UPDATE`, userID).Scan(&grants).Error; err != nil { return "", err } remaining := requested type usedGrant struct { id uuid.UUID amount int64 } used := make([]usedGrant, 0, len(grants)) for _, grant := range grants { available, parseErr := parsePointCents(grant.AvailableAmount) if parseErr != nil { return "", parseErr } use := available if use > remaining { use = remaining } if use > 0 { used = append(used, usedGrant{id: grant.ID, amount: use}) remaining -= use } if remaining == 0 { break } } if remaining > 0 { return "", ErrInsufficientPoints } for _, allocation := range used { if err := tx.Exec(`UPDATE point_grants SET available_amount=available_amount-?::numeric WHERE id=?`, formatPointCents(allocation.amount), allocation.id).Error; err != nil { return "", err } } if err := tx.Raw(`UPDATE web_users SET point_balance=point_balance-?::numeric WHERE id=? RETURNING point_balance::text`, points, userID).Scan(&balance).Error; err != nil { return "", err } var ledgerID int64 if err := tx.Raw(`INSERT INTO point_ledger(user_id,change_amount,balance_after,business_type,business_id,remark,idempotency_key) VALUES(?,(-?::numeric),?::numeric,?,?,?,?) RETURNING id`, userID, points, balance, businessType, businessID, remark, idempotencyKey).Scan(&ledgerID).Error; err != nil { return "", err } for _, allocation := range used { if err := tx.Exec(`INSERT INTO point_ledger_allocations(ledger_id,grant_id,allocation_type,amount) VALUES(?,?,'consume',?::numeric)`, ledgerID, allocation.id, formatPointCents(allocation.amount)).Error; err != nil { return "", err } } return balance, nil } // RefundDebit 将原扣减记录恢复到对应的永久积分批次,并写入退款流水。 func RefundDebit(tx *gorm.DB, userID uuid.UUID, originalKey, refundKey, businessType, businessID, remark string) (bool, error) { balance, err := lockUser(tx, userID, false) if err != nil { return false, err } var existing int64 if err := tx.Raw("SELECT count(*) FROM point_ledger WHERE idempotency_key=?", refundKey).Scan(&existing).Error; err != nil { return false, err } if existing > 0 { return false, nil } var allocations []pointAllocation if err := tx.Raw(`SELECT a.grant_id,a.amount::text AS amount FROM point_ledger original JOIN point_ledger_allocations a ON a.ledger_id=original.id AND a.allocation_type='consume' JOIN point_grants g ON g.id=a.grant_id WHERE original.idempotency_key=? ORDER BY g.created_at,g.id FOR UPDATE OF g`, originalKey).Scan(&allocations).Error; err != nil { return false, err } if len(allocations) == 0 { return false, errors.New("未找到原积分扣减分配记录") } restored := int64(0) restoredAllocations := make([]pointAllocation, 0, len(allocations)) for _, allocation := range allocations { amount, parseErr := parsePointCents(allocation.Amount) if parseErr != nil { return false, parseErr } result := tx.Exec(`UPDATE point_grants SET available_amount=available_amount+?::numeric WHERE id=?`, allocation.Amount, allocation.GrantID) if result.Error != nil { return false, result.Error } if result.RowsAffected > 0 { restored += amount restoredAllocations = append(restoredAllocations, allocation) } } if restored > 0 { if err := tx.Raw(`UPDATE web_users SET point_balance=point_balance+?::numeric WHERE id=? RETURNING point_balance::text`, formatPointCents(restored), userID).Scan(&balance).Error; err != nil { return false, err } } var refundLedgerID int64 if err := tx.Raw(`INSERT INTO point_ledger(user_id,change_amount,balance_after,business_type,business_id,remark,idempotency_key) VALUES(?,?::numeric,?::numeric,?,?,?,?) RETURNING id`, userID, formatPointCents(restored), balance, businessType, businessID, remark, refundKey).Scan(&refundLedgerID).Error; err != nil { return false, err } for _, allocation := range restoredAllocations { if err := tx.Exec(`INSERT INTO point_ledger_allocations(ledger_id,grant_id,allocation_type,amount) VALUES(?,?,'refund',?::numeric)`, refundLedgerID, allocation.GrantID, allocation.Amount).Error; err != nil { return false, err } } return true, nil }