533 lines
20 KiB
Go
533 lines
20 KiB
Go
// Package invite stores invitation codes, relations, counters and recharge progress in MySQL.
|
|
package invite
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
mysqldriver "github.com/go-sql-driver/mysql"
|
|
"hyapp/pkg/appcode"
|
|
"hyapp/pkg/idgen"
|
|
"hyapp/pkg/xerr"
|
|
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
|
"hyapp/services/user-service/internal/storage/mysql/shared"
|
|
)
|
|
|
|
const generatedCodeLength = 8
|
|
|
|
const inviteCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
|
|
// Repository owns invitation persistence inside the user-service database.
|
|
type Repository struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// New creates an invitation repository backed by the shared user-service MySQL pool.
|
|
func New(db *sql.DB) *Repository {
|
|
return &Repository{db: db}
|
|
}
|
|
|
|
// EnsurePrimaryCodeForUser creates the current user's own shareable invite code inside the caller transaction.
|
|
func EnsurePrimaryCodeForUser(ctx context.Context, tx *sql.Tx, appCode string, ownerUserID int64, nowMs int64) (string, error) {
|
|
code, ok, err := primaryCodeForUpdate(ctx, tx, appCode, ownerUserID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if ok {
|
|
return code, nil
|
|
}
|
|
|
|
for range 16 {
|
|
candidate := generateInviteCode(generatedCodeLength)
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO user_invite_codes (app_code, owner_user_id, code, code_type, status, created_at_ms, updated_at_ms)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, appcode.Normalize(appCode), ownerUserID, candidate, invitedomain.CodeTypePrimary, invitedomain.CodeStatusActive, nowMs, nowMs)
|
|
if err == nil {
|
|
return candidate, nil
|
|
}
|
|
if !isDuplicate(err) {
|
|
return "", err
|
|
}
|
|
// Owner-level duplicate means another code was created earlier in this transaction path; reuse it.
|
|
if code, ok, lookupErr := primaryCodeForUpdate(ctx, tx, appCode, ownerUserID); lookupErr != nil {
|
|
return "", lookupErr
|
|
} else if ok {
|
|
return code, nil
|
|
}
|
|
}
|
|
|
|
return "", xerr.New(xerr.Internal, "invite code allocation failed")
|
|
}
|
|
|
|
// BindRelation validates an optional invite code and binds the invited user once inside the caller transaction.
|
|
func BindRelation(ctx context.Context, tx *sql.Tx, command invitedomain.BindCommand) (invitedomain.Binding, error) {
|
|
command.AppCode = appcode.Normalize(command.AppCode)
|
|
command.InviteCode = invitedomain.NormalizeCode(command.InviteCode)
|
|
command.Source = strings.TrimSpace(command.Source)
|
|
command.RequestID = strings.TrimSpace(command.RequestID)
|
|
if command.InviteCode == "" {
|
|
return invitedomain.Binding{}, nil
|
|
}
|
|
if command.InvitedUserID <= 0 || !invitedomain.ValidCode(command.InviteCode) {
|
|
return invitedomain.Binding{}, invalidInviteCode()
|
|
}
|
|
if command.BoundAtMs <= 0 {
|
|
command.BoundAtMs = time.Now().UnixMilli()
|
|
}
|
|
|
|
code, err := resolveActiveCodeForUpdate(ctx, tx, command.AppCode, command.InviteCode)
|
|
if err != nil {
|
|
return invitedomain.Binding{}, err
|
|
}
|
|
if code.OwnerUserID == command.InvitedUserID || code.OwnerStatus != "active" {
|
|
return invitedomain.Binding{}, invalidInviteCode()
|
|
}
|
|
|
|
existing, exists, err := relationForInvitedForUpdate(ctx, tx, command.AppCode, command.InvitedUserID)
|
|
if err != nil {
|
|
return invitedomain.Binding{}, err
|
|
}
|
|
if exists {
|
|
// 邀请归因只能首次绑定;重复提交不改写原邀请人,也不重复增加计数。
|
|
return invitedomain.Binding{InviteCode: existing.InviteCode, InviterUserID: existing.InviterUserID}, nil
|
|
}
|
|
|
|
policy, err := currentPolicyForUpdate(ctx, tx, command.AppCode, command.InvitedRegionID, command.BoundAtMs)
|
|
if err != nil {
|
|
return invitedomain.Binding{}, err
|
|
}
|
|
|
|
_, err = tx.ExecContext(ctx, `
|
|
INSERT INTO user_invite_relations (
|
|
app_code, invited_user_id, inviter_user_id, invite_code_id, invite_code, source, request_id, bound_at_ms, created_at_ms, updated_at_ms
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, command.AppCode, command.InvitedUserID, code.OwnerUserID, code.InviteCodeID, code.Code, command.Source, command.RequestID, command.BoundAtMs, command.BoundAtMs, command.BoundAtMs)
|
|
if err != nil {
|
|
if isDuplicate(err) {
|
|
existing, exists, lookupErr := relationForInvitedForUpdate(ctx, tx, command.AppCode, command.InvitedUserID)
|
|
if lookupErr != nil {
|
|
return invitedomain.Binding{}, lookupErr
|
|
}
|
|
if exists {
|
|
return invitedomain.Binding{InviteCode: existing.InviteCode, InviterUserID: existing.InviterUserID}, nil
|
|
}
|
|
return invitedomain.Binding{}, nil
|
|
}
|
|
return invitedomain.Binding{}, err
|
|
}
|
|
if err := insertProgress(ctx, tx, command, code.OwnerUserID, policy); err != nil {
|
|
return invitedomain.Binding{}, err
|
|
}
|
|
if err := incrementInviteCount(ctx, tx, command.AppCode, code.OwnerUserID, command.BoundAtMs); err != nil {
|
|
return invitedomain.Binding{}, err
|
|
}
|
|
if err := insertUserOutbox(ctx, tx, command.AppCode, invitedomain.EventTypeUserInvited, "user_invite_relation", command.InvitedUserID, command.BoundAtMs, map[string]any{
|
|
"invited_user_id": command.InvitedUserID,
|
|
"inviter_user_id": code.OwnerUserID,
|
|
"invite_code": code.Code,
|
|
"request_id": command.RequestID,
|
|
"source": command.Source,
|
|
"bound_at_ms": command.BoundAtMs,
|
|
}); err != nil {
|
|
return invitedomain.Binding{}, err
|
|
}
|
|
|
|
return invitedomain.Binding{Bound: true, InviteCode: code.Code, InviterUserID: code.OwnerUserID}, nil
|
|
}
|
|
|
|
// GetOverview returns the precomputed invite read model for my-page aggregation.
|
|
func (r *Repository) GetOverview(ctx context.Context, appCode string, userID int64, regionID int64, nowMs int64) (invitedomain.Overview, error) {
|
|
if r == nil || r.db == nil {
|
|
return invitedomain.Overview{}, xerr.New(xerr.Unavailable, "invite repository is not configured")
|
|
}
|
|
if nowMs <= 0 {
|
|
nowMs = time.Now().UnixMilli()
|
|
}
|
|
|
|
var overview invitedomain.Overview
|
|
err := r.db.QueryRowContext(ctx, `
|
|
SELECT code
|
|
FROM user_invite_codes
|
|
WHERE app_code = ? AND owner_user_id = ? AND code_type = ? AND status = ?
|
|
ORDER BY invite_code_id DESC
|
|
LIMIT 1
|
|
`, appcode.Normalize(appCode), userID, invitedomain.CodeTypePrimary, invitedomain.CodeStatusActive).Scan(&overview.MyInviteCode)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
return invitedomain.Overview{}, err
|
|
}
|
|
overview.InviteEnabled = overview.MyInviteCode != ""
|
|
|
|
err = r.db.QueryRowContext(ctx, `
|
|
SELECT invite_count, valid_invite_count
|
|
FROM user_invite_counters
|
|
WHERE app_code = ? AND user_id = ?
|
|
`, appcode.Normalize(appCode), userID).Scan(&overview.InviteCount, &overview.ValidInviteCount)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
return invitedomain.Overview{}, err
|
|
}
|
|
|
|
policy, err := currentPolicy(ctx, r.db, appCode, regionID, nowMs)
|
|
if err == nil {
|
|
overview.ValidInviteThresholdCoin = policy.RequiredRechargeCoinAmount
|
|
} else if err != sql.ErrNoRows {
|
|
return invitedomain.Overview{}, err
|
|
}
|
|
|
|
return overview, nil
|
|
}
|
|
|
|
// ApplyRechargeEvent consumes one wallet recharge fact and advances invite validity idempotently.
|
|
func (r *Repository) ApplyRechargeEvent(ctx context.Context, event invitedomain.RechargeEvent) (invitedomain.RechargeApplyResult, error) {
|
|
if r == nil || r.db == nil {
|
|
return invitedomain.RechargeApplyResult{}, xerr.New(xerr.Unavailable, "invite repository is not configured")
|
|
}
|
|
event.AppCode = appcode.Normalize(event.AppCode)
|
|
event.EventID = strings.TrimSpace(event.EventID)
|
|
event.EventType = strings.TrimSpace(event.EventType)
|
|
event.TransactionID = strings.TrimSpace(event.TransactionID)
|
|
event.AssetType = strings.TrimSpace(event.AssetType)
|
|
event.RechargeType = strings.TrimSpace(event.RechargeType)
|
|
if event.RechargeType == "" {
|
|
event.RechargeType = invitedomain.RechargeTypeCoinSellerTransfer
|
|
}
|
|
if event.EventID == "" || event.TransactionID == "" || event.InvitedUserID <= 0 {
|
|
return invitedomain.RechargeApplyResult{}, xerr.New(xerr.InvalidArgument, "wallet recharge event identity is required")
|
|
}
|
|
if event.OccurredAtMs <= 0 {
|
|
event.OccurredAtMs = time.Now().UnixMilli()
|
|
}
|
|
|
|
tx, err := r.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return invitedomain.RechargeApplyResult{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if err := insertEventConsumption(ctx, tx, event); err != nil {
|
|
if isDuplicate(err) {
|
|
return invitedomain.RechargeApplyResult{Skipped: true, Reason: "already_consumed"}, nil
|
|
}
|
|
return invitedomain.RechargeApplyResult{}, err
|
|
}
|
|
if event.EventType != invitedomain.RechargeEventWalletRecorded || event.AssetType != "COIN" || event.RechargeCoinAmount <= 0 {
|
|
if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "skipped", "not_eligible_wallet_event", event.OccurredAtMs); err != nil {
|
|
return invitedomain.RechargeApplyResult{}, err
|
|
}
|
|
return invitedomain.RechargeApplyResult{Skipped: true, Reason: "not_eligible_wallet_event"}, tx.Commit()
|
|
}
|
|
|
|
progress, exists, err := progressForInvitedForUpdate(ctx, tx, event.AppCode, event.InvitedUserID)
|
|
if err != nil {
|
|
return invitedomain.RechargeApplyResult{}, err
|
|
}
|
|
if !exists {
|
|
if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "skipped", "no_invite_relation", event.OccurredAtMs); err != nil {
|
|
return invitedomain.RechargeApplyResult{}, err
|
|
}
|
|
return invitedomain.RechargeApplyResult{Skipped: true, Reason: "no_invite_relation"}, tx.Commit()
|
|
}
|
|
if progress.Status == invitedomain.ProgressStatusValid {
|
|
if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "skipped", "already_valid", event.OccurredAtMs); err != nil {
|
|
return invitedomain.RechargeApplyResult{}, err
|
|
}
|
|
return invitedomain.RechargeApplyResult{Skipped: true, Reason: "already_valid"}, tx.Commit()
|
|
}
|
|
if !eligibleRechargeType(progress.EligibleRechargeTypes, event.RechargeType) {
|
|
if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "skipped", "ineligible_recharge_type", event.OccurredAtMs); err != nil {
|
|
return invitedomain.RechargeApplyResult{}, err
|
|
}
|
|
return invitedomain.RechargeApplyResult{Skipped: true, Reason: "ineligible_recharge_type"}, tx.Commit()
|
|
}
|
|
|
|
newAccumulated := progress.AccumulatedRechargeCoinAmount + event.RechargeCoinAmount
|
|
becameValid := newAccumulated >= progress.RequiredRechargeCoinAmount
|
|
status := invitedomain.ProgressStatusPending
|
|
var validAt any
|
|
if becameValid {
|
|
status = invitedomain.ProgressStatusValid
|
|
validAt = event.OccurredAtMs
|
|
}
|
|
_, err = tx.ExecContext(ctx, `
|
|
UPDATE user_invite_recharge_progress
|
|
SET accumulated_recharge_coin_amount = ?, status = ?, valid_at_ms = ?, updated_at_ms = ?
|
|
WHERE app_code = ? AND invited_user_id = ?
|
|
`, newAccumulated, status, validAt, event.OccurredAtMs, event.AppCode, event.InvitedUserID)
|
|
if err != nil {
|
|
return invitedomain.RechargeApplyResult{}, err
|
|
}
|
|
if becameValid {
|
|
if err := incrementValidInviteCount(ctx, tx, event.AppCode, progress.InviterUserID, event.OccurredAtMs); err != nil {
|
|
return invitedomain.RechargeApplyResult{}, err
|
|
}
|
|
if err := insertUserOutbox(ctx, tx, event.AppCode, invitedomain.EventTypeUserInviteBecameValid, "user_invite_relation", event.InvitedUserID, event.OccurredAtMs, map[string]any{
|
|
"invited_user_id": event.InvitedUserID,
|
|
"inviter_user_id": progress.InviterUserID,
|
|
"transaction_id": event.TransactionID,
|
|
"accumulated_recharge_coin_amount": newAccumulated,
|
|
"required_recharge_coin_amount": progress.RequiredRechargeCoinAmount,
|
|
"valid_at_ms": event.OccurredAtMs,
|
|
}); err != nil {
|
|
return invitedomain.RechargeApplyResult{}, err
|
|
}
|
|
}
|
|
if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "consumed", "", event.OccurredAtMs); err != nil {
|
|
return invitedomain.RechargeApplyResult{}, err
|
|
}
|
|
|
|
return invitedomain.RechargeApplyResult{Consumed: true, Valid: becameValid}, tx.Commit()
|
|
}
|
|
|
|
type inviteCodeRow struct {
|
|
InviteCodeID int64
|
|
Code string
|
|
OwnerUserID int64
|
|
OwnerStatus string
|
|
}
|
|
|
|
type relationRow struct {
|
|
InviterUserID int64
|
|
InviteCode string
|
|
}
|
|
|
|
type policyRow struct {
|
|
PolicyID int64
|
|
PolicyVersion string
|
|
RequiredRechargeCoinAmount int64
|
|
EligibleRechargeTypes []string
|
|
}
|
|
|
|
type progressRow struct {
|
|
InviterUserID int64
|
|
PolicyID int64
|
|
PolicyVersion string
|
|
RequiredRechargeCoinAmount int64
|
|
AccumulatedRechargeCoinAmount int64
|
|
Status string
|
|
EligibleRechargeTypes []string
|
|
}
|
|
|
|
func primaryCodeForUpdate(ctx context.Context, tx *sql.Tx, appCode string, ownerUserID int64) (string, bool, error) {
|
|
var code string
|
|
err := tx.QueryRowContext(ctx, `
|
|
SELECT code
|
|
FROM user_invite_codes
|
|
WHERE app_code = ? AND owner_user_id = ? AND code_type = ? AND status = ?
|
|
ORDER BY invite_code_id DESC
|
|
LIMIT 1
|
|
FOR UPDATE
|
|
`, appcode.Normalize(appCode), ownerUserID, invitedomain.CodeTypePrimary, invitedomain.CodeStatusActive).Scan(&code)
|
|
if err == sql.ErrNoRows {
|
|
return "", false, nil
|
|
}
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
return code, true, nil
|
|
}
|
|
|
|
func resolveActiveCodeForUpdate(ctx context.Context, tx *sql.Tx, appCode string, code string) (inviteCodeRow, error) {
|
|
var row inviteCodeRow
|
|
err := tx.QueryRowContext(ctx, `
|
|
SELECT c.invite_code_id, c.code, c.owner_user_id, u.status
|
|
FROM user_invite_codes c
|
|
INNER JOIN users u ON u.app_code = c.app_code AND u.user_id = c.owner_user_id
|
|
WHERE c.app_code = ? AND c.code = ? AND c.status = ?
|
|
LIMIT 1
|
|
FOR UPDATE
|
|
`, appCode, code, invitedomain.CodeStatusActive).Scan(&row.InviteCodeID, &row.Code, &row.OwnerUserID, &row.OwnerStatus)
|
|
if err == sql.ErrNoRows {
|
|
return inviteCodeRow{}, invalidInviteCode()
|
|
}
|
|
if err != nil {
|
|
return inviteCodeRow{}, err
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
func relationForInvitedForUpdate(ctx context.Context, tx *sql.Tx, appCode string, invitedUserID int64) (relationRow, bool, error) {
|
|
var row relationRow
|
|
err := tx.QueryRowContext(ctx, `
|
|
SELECT inviter_user_id, invite_code
|
|
FROM user_invite_relations
|
|
WHERE app_code = ? AND invited_user_id = ?
|
|
FOR UPDATE
|
|
`, appCode, invitedUserID).Scan(&row.InviterUserID, &row.InviteCode)
|
|
if err == sql.ErrNoRows {
|
|
return relationRow{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return relationRow{}, false, err
|
|
}
|
|
return row, true, nil
|
|
}
|
|
|
|
func currentPolicyForUpdate(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, nowMs int64) (policyRow, error) {
|
|
policy, err := currentPolicy(ctx, tx, appCode, regionID, nowMs)
|
|
if err == sql.ErrNoRows {
|
|
return policyRow{}, xerr.New(xerr.Internal, "invite validity policy is not configured")
|
|
}
|
|
return policy, err
|
|
}
|
|
|
|
func currentPolicy(ctx context.Context, q shared.Queryer, appCode string, regionID int64, nowMs int64) (policyRow, error) {
|
|
var policy policyRow
|
|
var eligibleJSON string
|
|
err := q.QueryRowContext(ctx, `
|
|
SELECT policy_id, policy_version, required_recharge_coin_amount, CAST(eligible_recharge_types AS CHAR)
|
|
FROM user_invite_validity_policies
|
|
WHERE app_code = ?
|
|
AND status = 'active'
|
|
AND (region_id = ? OR region_id = 0)
|
|
AND effective_from_ms <= ?
|
|
AND (effective_to_ms IS NULL OR effective_to_ms > ?)
|
|
ORDER BY CASE WHEN region_id = ? THEN 0 ELSE 1 END, effective_from_ms DESC, policy_id DESC
|
|
LIMIT 1
|
|
`, appcode.Normalize(appCode), nullableRegion(regionID), nowMs, nowMs, nullableRegion(regionID)).Scan(&policy.PolicyID, &policy.PolicyVersion, &policy.RequiredRechargeCoinAmount, &eligibleJSON)
|
|
if err != nil {
|
|
return policyRow{}, err
|
|
}
|
|
policy.EligibleRechargeTypes = parseEligibleTypes(eligibleJSON)
|
|
return policy, nil
|
|
}
|
|
|
|
func insertProgress(ctx context.Context, tx *sql.Tx, command invitedomain.BindCommand, inviterUserID int64, policy policyRow) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO user_invite_recharge_progress (
|
|
app_code, invited_user_id, inviter_user_id, policy_id, policy_version, required_recharge_coin_amount,
|
|
accumulated_recharge_coin_amount, status, valid_at_ms, created_at_ms, updated_at_ms
|
|
) VALUES (?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?)
|
|
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms
|
|
`, command.AppCode, command.InvitedUserID, inviterUserID, policy.PolicyID, policy.PolicyVersion, policy.RequiredRechargeCoinAmount, invitedomain.ProgressStatusPending, command.BoundAtMs, command.BoundAtMs)
|
|
return err
|
|
}
|
|
|
|
func incrementInviteCount(ctx context.Context, tx *sql.Tx, appCode string, inviterUserID int64, nowMs int64) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO user_invite_counters (app_code, user_id, invite_count, valid_invite_count, updated_at_ms)
|
|
VALUES (?, ?, 1, 0, ?)
|
|
ON DUPLICATE KEY UPDATE invite_count = invite_count + 1, updated_at_ms = VALUES(updated_at_ms)
|
|
`, appCode, inviterUserID, nowMs)
|
|
return err
|
|
}
|
|
|
|
func incrementValidInviteCount(ctx context.Context, tx *sql.Tx, appCode string, inviterUserID int64, nowMs int64) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO user_invite_counters (app_code, user_id, invite_count, valid_invite_count, updated_at_ms)
|
|
VALUES (?, ?, 0, 1, ?)
|
|
ON DUPLICATE KEY UPDATE valid_invite_count = valid_invite_count + 1, updated_at_ms = VALUES(updated_at_ms)
|
|
`, appCode, inviterUserID, nowMs)
|
|
return err
|
|
}
|
|
|
|
func insertUserOutbox(ctx context.Context, tx *sql.Tx, appCode string, eventType string, aggregateType string, aggregateID int64, nowMs int64, payload any) error {
|
|
payloadJSON, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.ExecContext(ctx, `
|
|
INSERT IGNORE INTO user_outbox (app_code, event_id, event_type, aggregate_type, aggregate_id, status, payload_json, created_at_ms, updated_at_ms)
|
|
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?)
|
|
`, appcode.Normalize(appCode), inviteEventID(eventType, aggregateID), eventType, aggregateType, aggregateID, string(payloadJSON), nowMs, nowMs)
|
|
return err
|
|
}
|
|
|
|
func insertEventConsumption(ctx context.Context, tx *sql.Tx, event invitedomain.RechargeEvent) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO user_invite_event_consumption (
|
|
app_code, event_id, event_type, transaction_id, invited_user_id, status, skip_reason, consumed_at_ms, created_at_ms, updated_at_ms
|
|
) VALUES (?, ?, ?, ?, ?, 'processing', '', NULL, ?, ?)
|
|
`, event.AppCode, event.EventID, event.EventType, event.TransactionID, event.InvitedUserID, event.OccurredAtMs, event.OccurredAtMs)
|
|
return err
|
|
}
|
|
|
|
func finishConsumption(ctx context.Context, tx *sql.Tx, appCode string, eventID string, status string, reason string, nowMs int64) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
UPDATE user_invite_event_consumption
|
|
SET status = ?, skip_reason = ?, consumed_at_ms = ?, updated_at_ms = ?
|
|
WHERE app_code = ? AND event_id = ?
|
|
`, status, reason, nowMs, nowMs, appCode, eventID)
|
|
return err
|
|
}
|
|
|
|
func progressForInvitedForUpdate(ctx context.Context, tx *sql.Tx, appCode string, invitedUserID int64) (progressRow, bool, error) {
|
|
var row progressRow
|
|
var eligibleJSON string
|
|
err := tx.QueryRowContext(ctx, `
|
|
SELECT p.inviter_user_id, p.policy_id, p.policy_version, p.required_recharge_coin_amount,
|
|
p.accumulated_recharge_coin_amount, p.status, CAST(pol.eligible_recharge_types AS CHAR)
|
|
FROM user_invite_recharge_progress p
|
|
INNER JOIN user_invite_validity_policies pol ON pol.app_code = p.app_code AND pol.policy_id = p.policy_id
|
|
WHERE p.app_code = ? AND p.invited_user_id = ?
|
|
FOR UPDATE
|
|
`, appCode, invitedUserID).Scan(&row.InviterUserID, &row.PolicyID, &row.PolicyVersion, &row.RequiredRechargeCoinAmount, &row.AccumulatedRechargeCoinAmount, &row.Status, &eligibleJSON)
|
|
if err == sql.ErrNoRows {
|
|
return progressRow{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return progressRow{}, false, err
|
|
}
|
|
row.EligibleRechargeTypes = parseEligibleTypes(eligibleJSON)
|
|
return row, true, nil
|
|
}
|
|
|
|
func parseEligibleTypes(raw string) []string {
|
|
var types []string
|
|
if err := json.Unmarshal([]byte(raw), &types); err != nil || len(types) == 0 {
|
|
return []string{invitedomain.RechargeTypeCoinSellerTransfer}
|
|
}
|
|
for index := range types {
|
|
types[index] = strings.TrimSpace(types[index])
|
|
}
|
|
return types
|
|
}
|
|
|
|
func eligibleRechargeType(types []string, eventType string) bool {
|
|
return slices.Contains(types, eventType)
|
|
}
|
|
|
|
func inviteEventID(eventType string, aggregateID int64) string {
|
|
return fmt.Sprintf("%s:%d", eventType, aggregateID)
|
|
}
|
|
|
|
func invalidInviteCode() error {
|
|
return xerr.New(xerr.InvalidInviteCode, "invalid invite code")
|
|
}
|
|
|
|
func nullableRegion(regionID int64) int64 {
|
|
if regionID <= 0 {
|
|
return 0
|
|
}
|
|
return regionID
|
|
}
|
|
|
|
func generateInviteCode(length int) string {
|
|
if length <= 0 {
|
|
length = generatedCodeLength
|
|
}
|
|
buf := make([]byte, length)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
fallback := strings.ToUpper(strings.ReplaceAll(idgen.New("INV"), "_", ""))
|
|
if len(fallback) >= length {
|
|
return fallback[len(fallback)-length:]
|
|
}
|
|
return fmt.Sprintf("%0*s", length, fallback)
|
|
}
|
|
for index, value := range buf {
|
|
buf[index] = inviteCodeAlphabet[int(value)%len(inviteCodeAlphabet)]
|
|
}
|
|
return string(buf)
|
|
}
|
|
|
|
func isDuplicate(err error) bool {
|
|
var mysqlErr *mysqldriver.MySQLError
|
|
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
|
|
}
|