402 lines
18 KiB
Go
402 lines
18 KiB
Go
// Package user 实现用户主数据、展示短号和临时靓号用例。
|
||
package user
|
||
|
||
import (
|
||
"context"
|
||
"time"
|
||
|
||
roomv1 "hyapp.local/api/proto/room/v1"
|
||
"hyapp/pkg/idgen"
|
||
hostdomain "hyapp/services/user-service/internal/domain/host"
|
||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||
)
|
||
|
||
// UserRepository 隔离用户主数据读写。
|
||
// 它只覆盖 users 主表视角和“创建用户+默认短号”的事务入口,避免查询用例依赖短号变更细节。
|
||
type UserRepository interface {
|
||
// GetUser 查询单个用户主状态。
|
||
GetUser(ctx context.Context, userID int64) (userdomain.User, error)
|
||
// GetMyProfileStats 读取我的页顶部统计 read model。
|
||
GetMyProfileStats(ctx context.Context, userID int64) (userdomain.ProfileStats, error)
|
||
// RecordProfileVisit 记录一次用户主页访问,并更新被访问者计数。
|
||
RecordProfileVisit(ctx context.Context, visitorUserID int64, targetUserID int64, nowMs int64) (bool, userdomain.ProfileStats, error)
|
||
// ListProfileVisitors 分页读取访问当前用户主页的人。
|
||
ListProfileVisitors(ctx context.Context, userID int64, page int32, pageSize int32) ([]userdomain.ProfileVisitRecord, int64, error)
|
||
// FollowUser 创建或恢复单向关注,并更新关注计数。
|
||
FollowUser(ctx context.Context, followerUserID int64, followeeUserID int64, nowMs int64) (userdomain.ProfileStats, error)
|
||
// UnfollowUser 取消单向关注,并更新关注计数。
|
||
UnfollowUser(ctx context.Context, followerUserID int64, followeeUserID int64, nowMs int64) (userdomain.ProfileStats, error)
|
||
// ListFollowing 读取当前用户关注的人;公开列表使用 page/pageSize,房间关系流可用 cursor 做稳定续扫。
|
||
ListFollowing(ctx context.Context, userID int64, page int32, pageSize int32, cursorUpdatedAtMS int64, cursorUserID int64) ([]userdomain.FollowRecord, int64, error)
|
||
// ApplyFriend 创建好友申请;已有好友时不重复建申请。
|
||
ApplyFriend(ctx context.Context, requesterUserID int64, targetUserID int64, nowMs int64) (userdomain.FriendApplication, bool, error)
|
||
// AcceptFriendApplication 同意指定 requester 发来的好友申请,并写双向好友关系。
|
||
AcceptFriendApplication(ctx context.Context, accepterUserID int64, requesterUserID int64, nowMs int64) (userdomain.FriendRecord, error)
|
||
// DeleteFriend 删除双向好友关系。
|
||
DeleteFriend(ctx context.Context, userID int64, friendUserID int64, nowMs int64) (bool, error)
|
||
// ListFriends 读取好友列表;公开列表使用 page/pageSize,房间关系流可用 cursor 做稳定续扫。
|
||
ListFriends(ctx context.Context, userID int64, page int32, pageSize int32, cursorUpdatedAtMS int64, cursorUserID int64) ([]userdomain.FriendRecord, int64, error)
|
||
// ListFriendApplications 分页读取好友申请。
|
||
ListFriendApplications(ctx context.Context, userID int64, direction string, page int32, pageSize int32) ([]userdomain.FriendApplication, int64, error)
|
||
// SubmitReport 持久化 App 用户提交的治理举报单。
|
||
SubmitReport(ctx context.Context, report userdomain.Report) (userdomain.Report, error)
|
||
// BusinessUserLookup 按已授权业务场景查询目标用户,返回字段必须保持最小化。
|
||
BusinessUserLookup(ctx context.Context, keyword string, numericKeyword bool, pageSize int32) ([]userdomain.BusinessUserLookupItem, error)
|
||
// BatchGetUsers 批量查询用户主状态,缺失用户不应返回占位对象。
|
||
BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.User, error)
|
||
// ListUserIDs 按稳定 user_id 游标读取低频后台任务目标用户。
|
||
ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error)
|
||
// CreateUserWithIdentity 原子创建用户和默认短号。
|
||
CreateUserWithIdentity(ctx context.Context, createdUser userdomain.User, identity userdomain.Identity) error
|
||
// UpdateUserProfile 原子修改用户基础资料。
|
||
UpdateUserProfile(ctx context.Context, command userdomain.ProfileUpdateCommand) (userdomain.User, error)
|
||
// CompleteOnboarding 原子写注册页必填资料并标记 profile_completed。
|
||
CompleteOnboarding(ctx context.Context, command userdomain.CompleteOnboardingCommand) (userdomain.User, error)
|
||
// ChangeUserCountry 原子修改用户国家、写变更日志,并返回需要立即失效的 auth session。
|
||
ChangeUserCountry(ctx context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, []string, error)
|
||
}
|
||
|
||
// AppRegistryRepository 负责把客户端包名解析成内部 app_code。
|
||
type AppRegistryRepository interface {
|
||
ResolveApp(ctx context.Context, appCode string, packageName string, platform string) (userdomain.App, error)
|
||
}
|
||
|
||
// CountryRegionRepository 负责把用户选择的 country_code 解析成国家主数据和当前 active 区域。
|
||
// 注册和改国家都依赖这组同步读,避免未知国家码进入 users 主表。
|
||
type CountryRegionRepository interface {
|
||
// ResolveEnabledCountryByCode 按国家码读取 App 可选国家;必须存在且 enabled=true。
|
||
ResolveEnabledCountryByCode(ctx context.Context, countryCode string) (userdomain.Country, bool, error)
|
||
// ListRegistrationCountries 返回 App 注册页可选国家列表。
|
||
ListRegistrationCountries(ctx context.Context) ([]userdomain.Country, error)
|
||
// ResolveActiveRegionByCountry 按国家码读取当前 active 区域;无显式映射时返回 GLOBAL 兜底区域。
|
||
ResolveActiveRegionByCountry(ctx context.Context, countryCode string) (userdomain.Region, bool, error)
|
||
}
|
||
|
||
// CountryAdminRepository 是国家管理 RPC 的持久化边界。
|
||
type CountryAdminRepository interface {
|
||
CountryRegionRepository
|
||
UpdateCountry(ctx context.Context, command userdomain.UpdateCountryCommand) (userdomain.Country, error)
|
||
ListCountries(ctx context.Context, filter userdomain.CountryFilter) ([]userdomain.Country, error)
|
||
}
|
||
|
||
// RegionAdminRepository 是区域管理 RPC 的持久化边界。
|
||
type RegionAdminRepository interface {
|
||
CountryRegionRepository
|
||
UpdateRegion(ctx context.Context, command userdomain.UpdateRegionCommand) (userdomain.Region, error)
|
||
ListRegions(ctx context.Context, filter userdomain.RegionFilter) ([]userdomain.Region, error)
|
||
GetRegion(ctx context.Context, regionID int64) (userdomain.Region, error)
|
||
ReplaceRegionCountries(ctx context.Context, command userdomain.ReplaceRegionCountriesCommand) (userdomain.Region, error)
|
||
}
|
||
|
||
// RegionRebuildRepository 负责消费 user_region_rebuild_tasks 并分页刷新 users.region_id。
|
||
type RegionRebuildRepository interface {
|
||
// ProcessNextRegionRebuildTask 先持久化 claim,再处理一个待重算任务;没有任务时返回 NoTask=true。
|
||
ProcessNextRegionRebuildTask(ctx context.Context, workerID string, nowMs int64, lockTTL time.Duration, batchSize int) (userdomain.RegionRebuildProcessResult, error)
|
||
}
|
||
|
||
// RoleSummaryRepository 提供用户当前业务身份摘要,App 自助改国家必须先基于该事实做权限截断。
|
||
type RoleSummaryRepository interface {
|
||
GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error)
|
||
}
|
||
|
||
// IdentityRepository 隔离 display_user_id 的归属、解析、变更和靓号租约。
|
||
// 用户 service 通过该接口表达短号用例,不直接感知 MySQL 的 identity/change_log/lease 表布局。
|
||
type IdentityRepository interface {
|
||
// GetUserIdentity 按 user_id 读取当前 active display_user_id。
|
||
GetUserIdentity(ctx context.Context, userID int64) (userdomain.Identity, error)
|
||
// ResolveDisplayUserID 把短号解析成 user_id,并按 nowMs 触发懒过期。
|
||
ResolveDisplayUserID(ctx context.Context, displayUserID string, nowMs int64) (userdomain.Identity, error)
|
||
// ChangeDisplayUserID 原子修改默认短号并写变更日志。
|
||
ChangeDisplayUserID(ctx context.Context, command userdomain.DisplayUserIDChangeCommand) (userdomain.Identity, error)
|
||
// ApplyPrettyDisplayUserID 原子创建靓号租约并覆盖当前展示号。
|
||
ApplyPrettyDisplayUserID(ctx context.Context, command userdomain.PrettyDisplayUserIDCommand) (userdomain.Identity, string, error)
|
||
// ExpirePrettyDisplayUserID 原子恢复默认短号。
|
||
ExpirePrettyDisplayUserID(ctx context.Context, command userdomain.ExpirePrettyDisplayUserIDCommand) (userdomain.Identity, error)
|
||
}
|
||
|
||
// DeviceRepository 隔离设备 push token 的持久化。
|
||
// 推送 token 只服务离线触达,不进入认证、房间或钱包主链路。
|
||
type DeviceRepository interface {
|
||
// BindPushToken 原子绑定或替换当前用户设备 token。
|
||
BindPushToken(ctx context.Context, command userdomain.PushTokenCommand) error
|
||
// DeletePushToken 失效当前用户设备 token。
|
||
DeletePushToken(ctx context.Context, command userdomain.DeletePushTokenCommand) (bool, error)
|
||
}
|
||
|
||
// ModerationRepository 持有用户状态和认证 session 的同库事务。
|
||
type ModerationRepository interface {
|
||
SetUserStatus(ctx context.Context, command UserStatusCommand) (UserStatusPersistenceResult, error)
|
||
CreateManagerUserBlock(ctx context.Context, command ManagerUserBlockCommand, target userdomain.User) (ManagerUserBlock, error)
|
||
ListManagerUserBlocks(ctx context.Context, managerUserID int64, status string, pageSize int32) ([]ManagerUserBlock, error)
|
||
ReleaseManagerUserBlock(ctx context.Context, command ReleaseManagerUserBlockCommand) (ManagerUserBlockRelease, error)
|
||
ExpireManagerUserBlocks(ctx context.Context, nowMS int64, limit int32) ([]ManagerUserBlockRelease, error)
|
||
}
|
||
|
||
// SessionDenylist 写入 gateway access token 快速拒绝缓存。
|
||
type SessionDenylist interface {
|
||
SetRevokedSession(ctx context.Context, appCode string, sessionID string, reason string, ttl time.Duration) error
|
||
}
|
||
|
||
// IMLoginKicker 失效用户在腾讯云 IM SDK 的当前登录态。
|
||
type IMLoginKicker interface {
|
||
KickUser(ctx context.Context, userID int64) error
|
||
}
|
||
|
||
// RoomEvictor 通过 room-service 驱逐用户当前所在的 Room Cell。
|
||
type RoomEvictor interface {
|
||
SystemEvictUser(ctx context.Context, req *roomv1.SystemEvictUserRequest) (*roomv1.SystemEvictUserResponse, error)
|
||
}
|
||
|
||
// IDGenerator 生成系统内部不可变 user_id。
|
||
type IDGenerator interface {
|
||
// NewInt64 返回全局唯一 user_id。
|
||
NewInt64() int64
|
||
}
|
||
|
||
// DisplayUserIDAllocator 为新用户生成默认短号候选。
|
||
type DisplayUserIDAllocator interface {
|
||
// NextDisplayUserID 根据 user_id 和重试次数生成候选短号。
|
||
NextDisplayUserID(userID int64, attempt int) string
|
||
}
|
||
|
||
// Option 调整 user service 的运行时策略,测试可注入确定性发号器。
|
||
type Option func(*Service)
|
||
|
||
// Service 承载用户主数据和短号用例。
|
||
// 字段按领域接口拆分,跨表事务仍保留在 repository 方法内,避免 service 层手动拼事务。
|
||
type Service struct {
|
||
// appRegistryRepository 持有 App 注册表读取能力,gateway 解析 app_code 时使用。
|
||
appRegistryRepository AppRegistryRepository
|
||
// userRepository 持有 users 主表视角的读写能力。
|
||
userRepository UserRepository
|
||
// identityRepository 持有短号、靓号和变更日志事务能力。
|
||
identityRepository IdentityRepository
|
||
// countryRegionRepository 持有国家主数据校验和国家到区域映射解析能力。
|
||
countryRegionRepository CountryRegionRepository
|
||
// countryAdminRepository 持有国家管理事务能力。
|
||
countryAdminRepository CountryAdminRepository
|
||
// regionAdminRepository 持有区域管理事务能力。
|
||
regionAdminRepository RegionAdminRepository
|
||
// regionRebuildRepository 持有历史用户区域快照重算能力。
|
||
regionRebuildRepository RegionRebuildRepository
|
||
// roleSummaryRepository 持有用户当前业务身份摘要,避免 App 自助改国家绕过 host/BD/币商等身份边界。
|
||
roleSummaryRepository RoleSummaryRepository
|
||
// deviceRepository 持有设备 push token 绑定能力。
|
||
deviceRepository DeviceRepository
|
||
// moderationRepository 持有用户状态和 session 吊销事务能力。
|
||
moderationRepository ModerationRepository
|
||
// sessionDenylist 让已签发 access token 在 gateway 立即失效。
|
||
sessionDenylist SessionDenylist
|
||
// sessionDenylistTTL 覆盖 access token 剩余有效期,避免 Redis key 先于 JWT 过期。
|
||
sessionDenylistTTL time.Duration
|
||
// imLoginKicker 失效腾讯云 IM 当前登录态。
|
||
imLoginKicker IMLoginKicker
|
||
// roomEvictor 通过 room-service 清理当前房间 presence、麦位和 RTC 连接。
|
||
roomEvictor RoomEvictor
|
||
// idGenerator 生成系统内部不可变 user_id。
|
||
idGenerator IDGenerator
|
||
// displayUserIDAllocator 生成默认短号候选,唯一性由 repository 保证。
|
||
displayUserIDAllocator DisplayUserIDAllocator
|
||
// now 是服务时钟,测试可注入固定时间。
|
||
now func() time.Time
|
||
// allocateMaxAttempts 是默认短号冲突重试上限。
|
||
allocateMaxAttempts int
|
||
// displayUserIDCooldown 是用户修改默认短号的冷却期。
|
||
displayUserIDCooldown time.Duration
|
||
// countryChangeCooldown 是用户修改国家的滚动冷却期。
|
||
countryChangeCooldown time.Duration
|
||
}
|
||
|
||
// New 创建用户服务。
|
||
// 构造函数只强制要求 UserRepository;如果同一实现也支持 IdentityRepository,则自动接入短号用例。
|
||
func New(userRepository UserRepository, options ...Option) *Service {
|
||
// 默认策略可独立跑通本地单测;生产 repository 由 app 层注入。
|
||
svc := &Service{
|
||
userRepository: userRepository,
|
||
idGenerator: idgen.NewInt64Generator(0),
|
||
displayUserIDAllocator: NumericDisplayUserIDAllocator{},
|
||
now: time.Now,
|
||
allocateMaxAttempts: 8,
|
||
displayUserIDCooldown: 30 * 24 * time.Hour,
|
||
countryChangeCooldown: 30 * 24 * time.Hour,
|
||
}
|
||
if repository, ok := userRepository.(ModerationRepository); ok {
|
||
// MySQL 用户 repository 同时持有 auth_sessions 表的同库事务能力。
|
||
svc.moderationRepository = repository
|
||
}
|
||
for _, option := range options {
|
||
// option 只改依赖或策略,不做 IO。
|
||
option(svc)
|
||
}
|
||
|
||
return svc
|
||
}
|
||
|
||
// WithModerationRepository 注入用户治理事务 repository。
|
||
func WithModerationRepository(repository ModerationRepository) Option {
|
||
return func(s *Service) {
|
||
if repository != nil {
|
||
s.moderationRepository = repository
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithSessionDenylist 注入 access session denylist 缓存。
|
||
func WithSessionDenylist(denylist SessionDenylist, ttl time.Duration) Option {
|
||
return func(s *Service) {
|
||
if denylist != nil {
|
||
s.sessionDenylist = denylist
|
||
s.sessionDenylistTTL = ttl
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithIMLoginKicker 注入腾讯云 IM 当前登录态失效边界。
|
||
func WithIMLoginKicker(kicker IMLoginKicker) Option {
|
||
return func(s *Service) {
|
||
if kicker != nil {
|
||
s.imLoginKicker = kicker
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithRoomEvictor 注入当前房间驱逐边界。
|
||
func WithRoomEvictor(evictor RoomEvictor) Option {
|
||
return func(s *Service) {
|
||
if evictor != nil {
|
||
s.roomEvictor = evictor
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithAppRegistryRepository 注入 App 注册表 repository。
|
||
func WithAppRegistryRepository(repository AppRegistryRepository) Option {
|
||
return func(s *Service) {
|
||
if repository != nil {
|
||
s.appRegistryRepository = repository
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithCountryRegionRepository 注入国家/区域解析 repository。
|
||
func WithCountryRegionRepository(repository CountryRegionRepository) Option {
|
||
return func(s *Service) {
|
||
if repository != nil {
|
||
// 注册和改国家不能在缺失主数据校验时静默写入自由文本。
|
||
s.countryRegionRepository = repository
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithCountryAdminRepository 注入国家管理 repository。
|
||
func WithCountryAdminRepository(repository CountryAdminRepository) Option {
|
||
return func(s *Service) {
|
||
if repository != nil {
|
||
s.countryAdminRepository = repository
|
||
s.countryRegionRepository = repository
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithRegionAdminRepository 注入区域管理 repository。
|
||
func WithRegionAdminRepository(repository RegionAdminRepository) Option {
|
||
return func(s *Service) {
|
||
if repository != nil {
|
||
s.regionAdminRepository = repository
|
||
s.countryRegionRepository = repository
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithRegionRebuildRepository 注入历史用户区域重算 repository。
|
||
func WithRegionRebuildRepository(repository RegionRebuildRepository) Option {
|
||
return func(s *Service) {
|
||
if repository != nil {
|
||
s.regionRebuildRepository = repository
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithRoleSummaryRepository 注入角色摘要 repository。
|
||
func WithRoleSummaryRepository(repository RoleSummaryRepository) Option {
|
||
return func(s *Service) {
|
||
if repository != nil {
|
||
s.roleSummaryRepository = repository
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithDeviceRepository 注入设备 push token repository。
|
||
func WithDeviceRepository(repository DeviceRepository) Option {
|
||
return func(s *Service) {
|
||
if repository != nil {
|
||
s.deviceRepository = repository
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithCountryChangeCooldown 配置用户国家修改冷却窗口。
|
||
func WithCountryChangeCooldown(cooldown time.Duration) Option {
|
||
return func(s *Service) {
|
||
if cooldown >= 0 {
|
||
// cooldown 允许为 0,便于本地测试或运营临时关闭限制。
|
||
s.countryChangeCooldown = cooldown
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithIdentityRepository 注入独立短号 repository。
|
||
// 该 option 支持后续把用户主数据和短号 identity 拆成不同实现,同时不改变现有 New(mysqlRepo) 调用。
|
||
func WithIdentityRepository(repository IdentityRepository) Option {
|
||
return func(s *Service) {
|
||
if repository != nil {
|
||
// 允许将用户主数据和短号事务拆到不同实现。
|
||
s.identityRepository = repository
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithIDGenerator 注入 user_id 发号器。
|
||
func WithIDGenerator(generator IDGenerator) Option {
|
||
return func(s *Service) {
|
||
if generator != nil {
|
||
// nil generator 不覆盖默认发号器,避免构造出不可创建用户的 service。
|
||
s.idGenerator = generator
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithDisplayUserIDAllocator 注入短号候选生成器。
|
||
func WithDisplayUserIDAllocator(allocator DisplayUserIDAllocator) Option {
|
||
return func(s *Service) {
|
||
if allocator != nil {
|
||
// allocator 只负责候选生成,不负责唯一性判断。
|
||
s.displayUserIDAllocator = allocator
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithClock 注入时间函数,保证冷却期测试不依赖真实时间。
|
||
func WithClock(now func() time.Time) Option {
|
||
return func(s *Service) {
|
||
if now != nil {
|
||
// 固定时钟用于冷却期、靓号过期和创建时间测试。
|
||
s.now = now
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithDisplayUserIDPolicy 配置分配重试和修改冷却期。
|
||
func WithDisplayUserIDPolicy(maxAttempts int, cooldown time.Duration) Option {
|
||
return func(s *Service) {
|
||
if maxAttempts > 0 {
|
||
// 只接受正数重试次数,避免配置错误导致无法创建用户。
|
||
s.allocateMaxAttempts = maxAttempts
|
||
}
|
||
if cooldown >= 0 {
|
||
// cooldown 允许为 0,便于测试和运营豁免策略。
|
||
s.displayUserIDCooldown = cooldown
|
||
}
|
||
}
|
||
}
|