398 lines
18 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.

// 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)
}
// 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
}
}
}