// 本包承载 user-service 内 Host/Agency/BD 的 MySQL 持久化。 // 父级 mysql.Repository 负责连接池;本包负责领域行锁、命令幂等记录和事件箱写入。 package host import ( "context" "database/sql" "errors" "strings" mysqldriver "github.com/go-sql-driver/mysql" "hyapp/pkg/appcode" "hyapp/pkg/xerr" hostdomain "hyapp/services/user-service/internal/domain/host" userdomain "hyapp/services/user-service/internal/domain/user" ) // 存储对象是 Host 领域在 user-service MySQL 上的持久化实现。 type Repository struct { // 数据库连接由父级 user-service MySQL 存储对象持有,本包只复用连接池。 db *sql.DB } // 基于 user-service 共享连接池创建 Host 领域存储对象。 func New(db *sql.DB) *Repository { return &Repository{db: db} } const ( // 列清单集中维护,保证查询函数和结果回放使用同一组投影字段。 // 可空外键在存储边界统一转成 0,让领域结构体保持普通值类型。 hostProfileColumns = ` user_id, status, region_id, COALESCE(current_agency_id, 0), COALESCE(current_membership_id, 0), COALESCE(( SELECT owner_user_id FROM agencies WHERE agencies.app_code = host_profiles.app_code AND agencies.agency_id = host_profiles.current_agency_id AND agencies.status = 'active' LIMIT 1 ), 0), source, first_became_host_at_ms, created_at_ms, updated_at_ms` agencyColumns = ` agency_id, owner_user_id, region_id, parent_bd_user_id, name, status, join_enabled, max_hosts, created_by_user_id, created_at_ms, updated_at_ms` bdProfileColumns = ` user_id, role, region_id, COALESCE(parent_leader_user_id, 0), status, created_by_user_id, created_at_ms, updated_at_ms` coinSellerProfileColumns = ` user_id, status, merchant_asset_type, created_by_user_id, created_at_ms, updated_at_ms` agencyMembershipColumns = ` membership_id, agency_id, host_user_id, region_id, membership_type, status, joined_at_ms, COALESCE(ended_at_ms, 0), COALESCE(ended_by_user_id, 0), ended_reason, created_at_ms, updated_at_ms` agencyApplicationColumns = ` application_id, command_id, applicant_user_id, agency_id, region_id, status, COALESCE(reviewed_by_user_id, 0), review_reason, COALESCE(reviewed_at_ms, 0), created_at_ms, updated_at_ms` roleInvitationColumns = ` invitation_id, command_id, invitation_type, status, inviter_user_id, inviter_bd_user_id, target_user_id, region_id, agency_name, parent_bd_user_id, COALESCE(parent_leader_user_id, 0), COALESCE(processed_by_user_id, 0), process_reason, COALESCE(processed_at_ms, 0), created_at_ms, updated_at_ms` ) type sqlQueryer interface { QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) } type rowScanner interface { Scan(dest ...any) error } // nullableRegionID 把领域层“0 表示缺失”的约定转换为 SQL NULL。 // 这个函数也用于可选用户 ID、成员关系 ID,不只用于区域 ID。 func nullableRegionID(value int64) any { if value <= 0 { return nil } return value } func (r *Repository) userRegion(ctx context.Context, q sqlQueryer, userID int64, lockClause string) (int64, error) { // Host 领域不拥有 users 表,但所有角色流转都依赖用户当前有效区域。 // 调用方需要串行化用户身份变化时传入 FOR UPDATE 来锁住 users 行。 query := "SELECT COALESCE(region_id, 0), status FROM users WHERE app_code = ? AND user_id = ?" if strings.TrimSpace(lockClause) != "" { query += " " + lockClause } var regionID int64 var status string err := q.QueryRowContext(ctx, query, appcode.FromContext(ctx), userID).Scan(®ionID, &status) if err == sql.ErrNoRows { return 0, xerr.New(xerr.NotFound, "user not found") } if err != nil { return 0, err } if status != "active" { return 0, xerr.New(xerr.UserDisabled, "user is not active") } return regionID, nil } func (r *Repository) userCountry(ctx context.Context, q sqlQueryer, userID int64, lockClause string) (string, error) { // App 侧 Agency 推荐按用户当前国家收敛;国家来自用户资料事实,不能由客户端传入。 query := "SELECT COALESCE(country, ''), status FROM users WHERE app_code = ? AND user_id = ?" if strings.TrimSpace(lockClause) != "" { query += " " + lockClause } var country string var status string err := q.QueryRowContext(ctx, query, appcode.FromContext(ctx), userID).Scan(&country, &status) if err == sql.ErrNoRows { return "", xerr.New(xerr.NotFound, "user not found") } if err != nil { return "", err } if status != "active" { return "", xerr.New(xerr.UserDisabled, "user is not active") } return strings.TrimSpace(country), nil } func requireActiveRegion(ctx context.Context, q sqlQueryer, regionID int64) error { if regionID <= 0 { return xerr.New(xerr.InvalidArgument, "region_id is required") } var status string err := q.QueryRowContext(ctx, ` SELECT status FROM regions WHERE app_code = ? AND region_id = ? `, appcode.FromContext(ctx), regionID).Scan(&status) if err == sql.ErrNoRows { return xerr.New(xerr.RegionNotFound, "region not found") } if err != nil { return err } if status != userdomain.RegionStatusActive { return xerr.New(xerr.RegionDisabled, "region is disabled") } return nil } func appScopedClause(ctx context.Context, clause string, args ...any) (string, []any) { trimmed := strings.TrimSpace(clause) if strings.HasPrefix(strings.ToUpper(trimmed), "WHERE ") { return "WHERE app_code = ? AND " + strings.TrimSpace(trimmed[len("WHERE "):]), append([]any{appcode.FromContext(ctx)}, args...) } if trimmed == "" { return "WHERE app_code = ?", []any{appcode.FromContext(ctx)} } return clause, args } func mapHostDuplicateError(err error) error { // 唯一键是显式行锁之后的最终并发兜底。 // 这里统一转成领域冲突,避免业务层/传输层感知 MySQL 错误码。 var mysqlErr *mysqldriver.MySQLError if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { return xerr.New(xerr.Conflict, "host command or relationship already exists") } return err } func requireCommandResult(result hostdomain.CommandResult, commandType string, resultType string) error { if result.CommandType != commandType || result.ResultType != resultType { // 同一个 command_id 不能跨动作复用,否则调用方无法判断重试语义。 return xerr.New(xerr.Conflict, "command_id was used by another host command") } return nil }