// 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 }