2026-06-30 13:05:13 +08:00

248 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 本包承载 user-service 内 Host/Agency/BD 的 MySQL 持久化。
// 父级 mysql.Repository 负责连接池;本包负责领域行锁、命令幂等记录和事件箱写入。
package host
import (
"context"
"database/sql"
"errors"
"fmt"
"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 (
coinSellerProfileColumns = `
user_id, status, merchant_asset_type,
created_by_user_id, created_at_ms, updated_at_ms`
)
var (
// 列清单集中维护,保证查询函数和结果回放使用同一组投影字段。
// 可空外键在存储边界统一转成 0让领域结构体保持普通值类型。
// 角色表不再保存区域;领域读取统一从对应 users 行投影当前区域。
hostProfileColumns = fmt.Sprintf(`
user_id, status, %s, 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`, userRegionProjection("host_profiles", "user_id"))
agencyColumns = fmt.Sprintf(`
agency_id, owner_user_id, %s, parent_bd_user_id, name, avatar, status,
join_enabled, max_hosts, created_by_user_id, created_at_ms, updated_at_ms,
COALESCE((
SELECT COUNT(*)
FROM agency_memberships active_member
WHERE active_member.app_code = agencies.app_code
AND active_member.agency_id = agencies.agency_id
AND active_member.status = '%s'
), 0)`, userRegionProjection("agencies", "owner_user_id"), hostdomain.MembershipStatusActive)
bdProfileColumns = fmt.Sprintf(`
user_id, role, %s, COALESCE(parent_leader_user_id, 0), status,
created_by_user_id, created_at_ms, updated_at_ms`, userRegionProjection("bd_profiles", "user_id"))
bdLeaderProfileColumns = fmt.Sprintf(`
user_id, 'bd_leader', %s, 0, status,
created_by_user_id, created_at_ms, updated_at_ms`, userRegionProjection("bd_leader_profiles", "user_id"))
managerProfileColumns = fmt.Sprintf(`
user_id, %s, status, contact_info, created_by_admin_id, created_at_ms, updated_at_ms`, userRegionProjection("manager_profiles", "user_id"))
agencyMembershipColumns = fmt.Sprintf(`
membership_id, agency_id, host_user_id, %s, 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`, userRegionProjection("agency_memberships", "host_user_id"))
agencyApplicationColumns = fmt.Sprintf(`
application_id, command_id, applicant_user_id, agency_id, %s, status,
COALESCE(reviewed_by_user_id, 0), review_reason, COALESCE(reviewed_at_ms, 0),
created_at_ms, updated_at_ms`, userRegionProjection("agency_applications", "applicant_user_id"))
roleInvitationColumns = fmt.Sprintf(`
invitation_id, command_id, invitation_type, status, inviter_user_id,
inviter_bd_user_id, target_user_id, %s, 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`, userRegionProjection("role_invitations", "target_user_id"))
)
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 userRegionProjection(tableName string, userIDColumn string) string {
return fmt.Sprintf(`COALESCE((
SELECT u.region_id
FROM users u
WHERE u.app_code = %s.app_code
AND u.user_id = %s.%s
LIMIT 1
), 0)`, tableName, tableName, userIDColumn)
}
func userRegionValue(ctx context.Context, q sqlQueryer, userID int64, lockClause string) (int64, error) {
// 只读取 users 当前区域,不校验用户状态;用于历史邀请的发起人区域对齐,
// 避免旧库残留的邀请区域快照继续参与判断,又不额外改变“邀请已发出后可处理”的状态语义。
query := "SELECT COALESCE(region_id, 0) FROM users WHERE app_code = ? AND user_id = ?"
if strings.TrimSpace(lockClause) != "" {
query += " " + lockClause
}
var regionID int64
err := q.QueryRowContext(ctx, query, appcode.FromContext(ctx), userID).Scan(&regionID)
if err == sql.ErrNoRows {
return 0, xerr.New(xerr.NotFound, "user not found")
}
return regionID, err
}
func defaultAgencyProfileFromOwner(ctx context.Context, tx *sql.Tx, ownerUserID int64, explicitName string) (string, string, error) {
snapshot, err := roleInvitationUserSnapshotByID(ctx, tx, ownerUserID)
if err != nil {
return "", "", err
}
name := strings.TrimSpace(explicitName)
if name == "" {
// Agency 的默认资料只在创建身份这一刻从 owner 用户资料复制一次;
// 后续用户资料和 Agency 资料完全分离,不能再在查询层动态兜底。
name = firstNonBlank(snapshot.Username, snapshot.DisplayUserID, fmt.Sprintf("%d", ownerUserID))
}
return name, strings.TrimSpace(snapshot.Avatar), nil
}
func firstNonBlank(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func currentUserRegion(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(&regionID, &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) userRegion(ctx context.Context, q sqlQueryer, userID int64, lockClause string) (int64, error) {
return currentUserRegion(ctx, q, userID, lockClause)
}
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
}