Files
JuYou/API/internal/billing/points.go
T
2026-08-25 17:59:42 +08:00

251 lines
8.7 KiB
Go

// 积分账务模块,负责积分入账、扣减、退款及批次分配记录维护。
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
}