179 lines
6.4 KiB
Go
179 lines
6.4 KiB
Go
// 本包承载 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
|
||
}
|