2026-05-12 09:53:20 +08:00

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
}