251 lines
8.7 KiB
Go
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
|
|
}
|