2026-07-22 13:55:55 +08:00

389 lines
17 KiB
Go
Raw 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 mysql
import (
"context"
"database/sql"
"errors"
"strconv"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
)
const (
pointWithdrawalChannelCoinSeller = "coin_seller"
pointWithdrawalChannelPlatform = "platform"
withdrawalLimitPeriodDay = "day"
withdrawalLimitPeriodWeek = "week"
withdrawalLimitPeriodMonth = "month"
)
type hostPolicyWithdrawalLimit struct {
PolicyID uint64
PolicyVersion uint64
Period string
Limit int64
PeriodKey string
AllowedDays string
}
type missingHostPolicyMode uint8
const (
// 默认值必须失败关闭,避免新增调用点遗漏参数时意外绕过 POINT 的政策约束。
requireHostPolicy missingHostPolicyMode = iota
// Host/Agency 旧工资余额可能早于政策发布;缺少本月及紧邻上月政策时继续沿用原无限次规则。
allowLegacyWithoutHostPolicy
)
// consumeHostPolicyWithdrawalLimit 在真实账变事务内解析当前 UTC 月绑定政策并锁定一条计数主键。
// 调用方必须先完成 wallet transaction 幂等命中检查;这样重放直接返回旧回执,不会重复消耗次数。
// consumeHostPolicyWithdrawalLimitForPolicyType 允许永久积分余额继续使用其最后一份已发布
// POINT_DIAMOND 快照中的限制;政策切回工资型后,旧积分不能因为当前月类型改变而失去退出能力。
func (r *Repository) consumeHostPolicyWithdrawalLimitForPolicyType(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, channel string, commandID string, withdrawalRef string, nowMS int64, sourceAssetType string, policyType string, missingPolicyMode missingHostPolicyMode) error {
if userID <= 0 || regionID < 0 || (channel != pointWithdrawalChannelCoinSeller && channel != pointWithdrawalChannelPlatform) {
return xerr.New(xerr.InvalidArgument, "point withdrawal limit target is invalid")
}
sourceAssetType = strings.ToUpper(strings.TrimSpace(sourceAssetType))
if sourceAssetType == "" {
return xerr.New(xerr.InvalidArgument, "withdrawal source asset type is required")
}
if missingPolicyMode != requireHostPolicy && missingPolicyMode != allowLegacyWithoutHostPolicy {
return xerr.New(xerr.Internal, "host policy missing mode is invalid")
}
limit, found, err := r.resolveHostPolicyWithdrawalLimitForPolicyType(ctx, tx, regionID, channel, nowMS, policyType)
if err != nil {
return err
}
if !found && missingPolicyMode == requireHostPolicy {
// POINT 只有后台政策才能定义提现口径;不存在政策时禁止按工资余额兼容规则放行。
return xerr.New(xerr.PermissionDenied, "host salary policy is not configured for withdrawal region")
}
if channel == pointWithdrawalChannelPlatform && strings.TrimSpace(withdrawalRef) == "" {
// 平台先冻结再建申请;没有稳定引用就无法在建单失败时精确撤销这一次 reservation。
return xerr.New(xerr.InvalidArgument, "withdrawal_ref is required for platform withdrawal")
}
if found && channel == pointWithdrawalChannelPlatform {
allowed, err := platformWithdrawalAllowedOnUTCDate(limit.AllowedDays, time.UnixMilli(nowMS).UTC())
if err != nil {
return err
}
if !allowed {
return xerr.NewWithMetadata(xerr.PointWithdrawalDateNotAllowed, "point withdrawal date is not allowed", map[string]string{
"allowed_days": limit.AllowedDays,
"current_day": strconv.Itoa(time.UnixMilli(nowMS).UTC().Day()),
"timezone": "UTC",
})
}
}
appCode := appcode.FromContext(ctx)
if found && limit.Limit > 0 {
if _, err := tx.ExecContext(ctx, `
INSERT INTO point_withdrawal_policy_counters (
app_code, source_asset_type, user_id, channel, period, period_key,
withdrawal_count, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE period_key = VALUES(period_key)`,
appCode, sourceAssetType, userID, channel, limit.Period, limit.PeriodKey, nowMS, nowMS,
); err != nil {
return err
}
var current int64
if err := tx.QueryRowContext(ctx, `
SELECT withdrawal_count
FROM point_withdrawal_policy_counters
WHERE app_code = ? AND source_asset_type = ? AND user_id = ? AND channel = ? AND period = ? AND period_key = ?
FOR UPDATE`, appCode, sourceAssetType, userID, channel, limit.Period, limit.PeriodKey,
).Scan(&current); err != nil {
return err
}
if current >= limit.Limit {
return xerr.NewWithMetadata(xerr.PointWithdrawalLimitReached, "point withdrawal limit reached", map[string]string{
"channel": channel,
"period": limit.Period,
"limit": strconv.FormatInt(limit.Limit, 10),
})
}
if _, err := tx.ExecContext(ctx, `
UPDATE point_withdrawal_policy_counters
SET withdrawal_count = withdrawal_count + 1, updated_at_ms = ?
WHERE app_code = ? AND source_asset_type = ? AND user_id = ? AND channel = ? AND period = ? AND period_key = ?`,
nowMS, appCode, sourceAssetType, userID, channel, limit.Period, limit.PeriodKey,
); err != nil {
return err
}
}
if channel != pointWithdrawalChannelPlatform {
// 转币商是单事务账变;缺政策时无需 reservation直接保留旧工资余额的无限次转出行为。
return nil
}
// 即使 Host/Agency 没有政策也写 counted=false reservation它不是次数限制而是保证建单失败回滚后
// 原 freeze command 不能重放旧成功回执。policy_id/version=0 明确表示兼容旧工资余额而非命中政策。
_, err = tx.ExecContext(ctx, `
INSERT INTO point_withdrawal_limit_reservations (
app_code, source_asset_type, withdrawal_ref, freeze_command_id, user_id, channel,
policy_id, policy_version, period, period_key, counted, status, created_at_ms, released_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, 0)`,
appCode, sourceAssetType, strings.TrimSpace(withdrawalRef), strings.TrimSpace(commandID), userID, channel,
limit.PolicyID, limit.PolicyVersion, limit.Period, limit.PeriodKey, found && limit.Limit > 0, nowMS,
)
if isMySQLDuplicateError(err) {
return xerr.New(xerr.IdempotencyConflict, "point withdrawal reservation already belongs to another command")
}
return err
}
func (r *Repository) resolveHostPolicyWithdrawalLimit(ctx context.Context, tx *sql.Tx, regionID int64, channel string, nowMS int64) (hostPolicyWithdrawalLimit, bool, error) {
return r.resolveHostPolicyWithdrawalLimitForPolicyType(ctx, tx, regionID, channel, nowMS, "")
}
func (r *Repository) resolveHostPolicyWithdrawalLimitForPolicyType(ctx context.Context, tx *sql.Tx, regionID int64, channel string, nowMS int64, policyType string) (hostPolicyWithdrawalLimit, bool, error) {
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
now := time.UnixMilli(nowMS).UTC()
cycleKey := now.Format("2006-01")
// 无政策兼容 reservation 仍需要稳定且可审计的周期字段;不计数,因此不会占用该自然月额度。
limit := hostPolicyWithdrawalLimit{Period: withdrawalLimitPeriodMonth, PeriodKey: cycleKey}
if regionID == 0 {
// 没有区域时不能猜测某个区域的 Host 政策;调用方必须显式决定失败关闭还是使用工资兼容语义。
return limit, false, nil
}
var policy ledger.HostSalaryPolicy
var found bool
var err error
if strings.EqualFold(strings.TrimSpace(policyType), ledger.HostPolicyTypePointDiamond) {
// 永久积分按照最新一份历史 POINT_DIAMOND 绑定继续提现;当前政策改回工资型不影响既有余额。
policy, err = r.resolvePointDiamondHostPolicy(ctx, tx, regionID, nowMS)
if err != nil {
if xerr.CodeOf(err) == xerr.PermissionDenied {
return limit, false, nil
}
return hostPolicyWithdrawalLimit{}, false, err
}
found = true
} else {
// 工资与历史 POINT 复用当前周期绑定,保留原有“仅紧邻上月复制”的限制语义。
policy, found, err = r.queryHostSalaryPolicyByCycle(ctx, tx, regionID, cycleKey, "", "")
if err != nil {
return hostPolicyWithdrawalLimit{}, false, err
}
if !found {
return limit, false, nil
}
}
resolved, resolveErr := hostPolicyWithdrawalLimitFromPolicy(policy, channel, now)
if resolveErr != nil {
return hostPolicyWithdrawalLimit{}, false, resolveErr
}
return resolved, true, nil
}
// hostPolicyWithdrawalLimitFromPolicy 让执行校验与 overview 复用同一份已发布政策快照和 UTC 周期算法。
func hostPolicyWithdrawalLimitFromPolicy(policy ledger.HostSalaryPolicy, channel string, now time.Time) (hostPolicyWithdrawalLimit, error) {
limit := hostPolicyWithdrawalLimit{
PolicyID: policy.PolicyID, PolicyVersion: policy.PolicyVersion,
Period: withdrawalLimitPeriodMonth, PeriodKey: now.UTC().Format("2006-01"),
}
switch channel {
case pointWithdrawalChannelCoinSeller:
limit.Period, limit.Limit = policy.CoinSellerWithdrawalLimitPeriod, policy.CoinSellerWithdrawalLimitCount
case pointWithdrawalChannelPlatform:
limit.Period, limit.Limit = policy.PlatformWithdrawalLimitPeriod, policy.PlatformWithdrawalLimitCount
limit.AllowedDays = strings.TrimSpace(policy.PlatformWithdrawalAllowedDays)
default:
return hostPolicyWithdrawalLimit{}, xerr.New(xerr.InvalidArgument, "host policy withdrawal channel is invalid")
}
limit.Period = strings.ToLower(strings.TrimSpace(limit.Period))
if limit.Limit < 0 {
return hostPolicyWithdrawalLimit{}, xerr.New(xerr.Internal, "host policy withdrawal limit is invalid")
}
if limit.Period == "" {
limit.Period = withdrawalLimitPeriodMonth
}
switch limit.Period {
case withdrawalLimitPeriodDay:
limit.PeriodKey = now.Format("2006-01-02")
case withdrawalLimitPeriodWeek:
// 周期固定为 UTC 周一 00:00 起;保存周一日期避免跨年 ISO 周编号歧义。
mondayOffset := (int(now.Weekday()) + 6) % 7
limit.PeriodKey = now.AddDate(0, 0, -mondayOffset).Format("2006-01-02")
case withdrawalLimitPeriodMonth:
limit.PeriodKey = now.UTC().Format("2006-01")
default:
return hostPolicyWithdrawalLimit{}, xerr.New(xerr.Internal, "host policy withdrawal period is invalid")
}
return limit, nil
}
// platformWithdrawalAllowedOnUTCDate 精确匹配 UTC 日历日;例如 30 号在二月不存在时不会自动改成月底。
// 空串兼容迁移前政策为每天可提现,异常快照则返回内部错误,不能静默放开限制。
func platformWithdrawalAllowedOnUTCDate(raw string, now time.Time) (bool, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return true, nil
}
currentDay := now.UTC().Day()
seen := make(map[int]struct{})
allowed := false
for _, part := range strings.Split(raw, ",") {
day, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil || day < 1 || day > 31 {
return false, xerr.New(xerr.Internal, "point withdrawal allowed days policy is invalid")
}
if _, duplicate := seen[day]; duplicate {
return false, xerr.New(xerr.Internal, "point withdrawal allowed days policy is invalid")
}
seen[day] = struct{}{}
if day == currentDay {
allowed = true
}
}
return allowed, nil
}
// pointWithdrawalActionAvailability 只读命中 counter 主键,不加锁也不预占次数;实际动作仍会在
// 账变事务内锁行并重新校验,因此 overview 只是准确的当前快照,不承担授权事实。
func (r *Repository) pointWithdrawalActionAvailability(ctx context.Context, tx *sql.Tx, userID int64, sourceAssetType string, channel string, limit hostPolicyWithdrawalLimit, now time.Time) (ledger.PointWithdrawalActionAvailability, error) {
availability := ledger.PointWithdrawalActionAvailability{
Allowed: true, LimitPeriod: limit.Period, LimitCount: limit.Limit,
RemainingCount: -1, AllowedDays: limit.AllowedDays,
}
if channel == pointWithdrawalChannelPlatform {
allowed, err := platformWithdrawalAllowedOnUTCDate(limit.AllowedDays, now.UTC())
if err != nil {
return ledger.PointWithdrawalActionAvailability{}, err
}
if !allowed {
availability.Allowed = false
availability.BlockReason = "date_not_allowed"
}
}
if limit.Limit <= 0 {
return availability, nil
}
var used int64
err := tx.QueryRowContext(ctx, `
SELECT withdrawal_count
FROM point_withdrawal_policy_counters
WHERE app_code = ? AND source_asset_type = ? AND user_id = ?
AND channel = ? AND period = ? AND period_key = ?`,
appcode.FromContext(ctx), sourceAssetType, userID, channel, limit.Period, limit.PeriodKey,
).Scan(&used)
if errors.Is(err, sql.ErrNoRows) {
used = 0
} else if err != nil {
return ledger.PointWithdrawalActionAvailability{}, err
}
availability.UsedCount = used
availability.RemainingCount = limit.Limit - used
if availability.RemainingCount < 0 {
availability.RemainingCount = 0
}
if used >= limit.Limit {
availability.Allowed = false
availability.BlockReason = "limit_reached"
}
return availability, nil
}
// releaseHostPolicyWithdrawalLimitReservation 只服务 gateway 建单失败补偿。
// application_id 非空的人工驳回不会调用这里,因此驳回申请仍占用原限制周期的一次次数。
func (r *Repository) releaseHostPolicyWithdrawalLimitReservation(ctx context.Context, tx *sql.Tx, userID int64, sourceAssetType string, withdrawalRef string, rollbackCommandID string, nowMS int64) error {
withdrawalRef = strings.TrimSpace(withdrawalRef)
sourceAssetType = strings.ToUpper(strings.TrimSpace(sourceAssetType))
appCode := appcode.FromContext(ctx)
var reservedUserID int64
var channel string
var period string
var periodKey string
var counted bool
var status string
queryByRef := withdrawalRef != ""
var err error
if queryByRef {
err = tx.QueryRowContext(ctx, `
SELECT withdrawal_ref, user_id, channel, period, period_key, counted, status
FROM point_withdrawal_limit_reservations
WHERE app_code = ? AND source_asset_type = ? AND withdrawal_ref = ?
FOR UPDATE`, appCode, sourceAssetType, withdrawalRef,
).Scan(&withdrawalRef, &reservedUserID, &channel, &period, &periodKey, &counted, &status)
}
if !queryByRef || errors.Is(err, sql.ErrNoRows) {
// HTTP 重试会生成新的 request_id但 freeze command_id 是客户端稳定幂等键;按 :rollback 约定回退到唯一命令索引。
freezeCommandID := strings.TrimSuffix(strings.TrimSpace(rollbackCommandID), ":rollback")
if freezeCommandID == strings.TrimSpace(rollbackCommandID) || freezeCommandID == "" {
if errors.Is(err, sql.ErrNoRows) || !queryByRef {
return nil
}
return err
}
err = tx.QueryRowContext(ctx, `
SELECT withdrawal_ref, user_id, channel, period, period_key, counted, status
FROM point_withdrawal_limit_reservations
WHERE app_code = ? AND source_asset_type = ? AND freeze_command_id = ?
FOR UPDATE`, appCode, sourceAssetType, freezeCommandID,
).Scan(&withdrawalRef, &reservedUserID, &channel, &period, &periodKey, &counted, &status)
}
if errors.Is(err, sql.ErrNoRows) {
// 旧冻结记录没有 reservation释放余额仍按原兼容行为继续。
return nil
}
if err != nil {
return err
}
if reservedUserID != userID || channel != pointWithdrawalChannelPlatform {
return xerr.New(xerr.IdempotencyConflict, "withdrawal reservation does not match rollback")
}
if status == "released" {
return nil
}
if status != "active" {
return xerr.New(xerr.Internal, "withdrawal reservation status is invalid")
}
if counted {
// policy_id/version 只保留为命中政策审计;自然周期计数跨区域和同月显式政策切换连续,释放也必须命中共享周期键。
result, err := tx.ExecContext(ctx, `
UPDATE point_withdrawal_policy_counters
SET withdrawal_count = withdrawal_count - 1, updated_at_ms = ?
WHERE app_code = ? AND source_asset_type = ? AND user_id = ? AND channel = ? AND period = ? AND period_key = ?
AND withdrawal_count > 0`, nowMS, appCode, sourceAssetType, userID, channel, period, periodKey)
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
if err != nil {
return err
}
return xerr.New(xerr.Internal, "withdrawal reservation counter is missing")
}
}
_, err = tx.ExecContext(ctx, `
UPDATE point_withdrawal_limit_reservations
SET status = 'released', released_at_ms = ?
WHERE app_code = ? AND source_asset_type = ? AND withdrawal_ref = ? AND status = 'active'`, nowMS, appCode, sourceAssetType, withdrawalRef)
return err
}
// hostPolicyWithdrawalReservationWasReleased 防止已补偿冻结的 command_id 重放旧成功回执。
// reservation 与冻结交易同事务落库,因此 active/不存在仍可按原幂等语义返回released 必须要求客户端换新命令。
func (r *Repository) hostPolicyWithdrawalReservationWasReleased(ctx context.Context, tx *sql.Tx, sourceAssetType string, commandID string) (bool, error) {
var status string
err := tx.QueryRowContext(ctx, `
SELECT status
FROM point_withdrawal_limit_reservations
WHERE app_code = ? AND source_asset_type = ? AND freeze_command_id = ?`, appcode.FromContext(ctx), strings.ToUpper(strings.TrimSpace(sourceAssetType)), strings.TrimSpace(commandID)).Scan(&status)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return status == "released", nil
}