346 lines
15 KiB
Go
346 lines
15 KiB
Go
// Package cp 承载 CP/兄弟/姐妹关系的用例编排。
|
||
package cp
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"time"
|
||
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
cpdomain "hyapp/services/user-service/internal/domain/cp"
|
||
)
|
||
|
||
const (
|
||
defaultIntimacyLeaderboardPageSize = int32(20)
|
||
maxIntimacyLeaderboardPageSize = int32(100)
|
||
defaultIntimacyLeaderboardRows = 10000
|
||
maxIntimacyLeaderboardRows = 10000
|
||
)
|
||
|
||
// Repository 描述 CP 关系 service 需要的持久化能力,便于后续单元测试替换 MySQL 实现。
|
||
type Repository interface {
|
||
ListApplications(ctx context.Context, userID int64, direction string, status string, page int32, pageSize int32, nowMs int64) ([]cpdomain.Application, int64, error)
|
||
AcceptApplication(ctx context.Context, userID int64, applicationID string, nowMs int64) (cpdomain.Application, cpdomain.Relationship, error)
|
||
RejectApplication(ctx context.Context, userID int64, applicationID string, reason string, nowMs int64) (cpdomain.Application, error)
|
||
ListRelationships(ctx context.Context, userID int64, relationType string, page int32, pageSize int32) ([]cpdomain.Relationship, int64, error)
|
||
PrepareBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, nowMs int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error)
|
||
ConfirmBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, walletTransactionID string, paidCoinAmount int64, coinBalanceAfter int64, nowMs int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error)
|
||
CancelBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, reason string, nowMs int64) (cpdomain.RelationshipBreakup, error)
|
||
ConsumeGiftEvent(ctx context.Context, event cpdomain.GiftEvent, nowMs int64) (cpdomain.ConsumeResult, error)
|
||
ListActiveIntimacyLeaderboardEntries(ctx context.Context, limit int) ([]cpdomain.IntimacyLeaderboardEntry, error)
|
||
ListWeeklyRankEntries(ctx context.Context, relationType string, startMS int64, endMS int64, limit int) ([]cpdomain.WeeklyRankEntry, error)
|
||
}
|
||
|
||
// IntimacyLeaderboardStore 是 CP 亲密榜 Redis zset 读模型边界。
|
||
type IntimacyLeaderboardStore interface {
|
||
ReplaceIntimacyLeaderboard(ctx context.Context, appCode string, entries []cpdomain.IntimacyLeaderboardEntry) error
|
||
ListIntimacyLeaderboard(ctx context.Context, appCode string, relationType string, page int32, pageSize int32, nowMs int64) (cpdomain.IntimacyLeaderboardPage, error)
|
||
}
|
||
|
||
// AvatarFrameReader 批量读取用户当前佩戴头像框;头像框事实仍归 wallet-service。
|
||
type AvatarFrameReader interface {
|
||
BatchGetAvatarFrames(ctx context.Context, appCode string, userIDs []int64) (map[int64]cpdomain.AvatarFrameSnapshot, error)
|
||
}
|
||
|
||
// Service 只编排 CP 用例,不直接投递 IM;IM 由 user_outbox 下游消费者按事实事件处理。
|
||
type Service struct {
|
||
repo Repository
|
||
leaderboardStore IntimacyLeaderboardStore
|
||
avatarFrames AvatarFrameReader
|
||
clock func() time.Time
|
||
}
|
||
|
||
// Option 按需挂载 CP service 的读模型依赖。
|
||
type Option func(*Service)
|
||
|
||
// WithIntimacyLeaderboardStore 挂载 CP 亲密榜 Redis 读模型。
|
||
func WithIntimacyLeaderboardStore(store IntimacyLeaderboardStore) Option {
|
||
return func(s *Service) {
|
||
s.leaderboardStore = store
|
||
}
|
||
}
|
||
|
||
// WithAvatarFrameReader 挂载头像框批量读取器,刷新榜单时一次性补齐双方头像框快照。
|
||
func WithAvatarFrameReader(reader AvatarFrameReader) Option {
|
||
return func(s *Service) {
|
||
s.avatarFrames = reader
|
||
}
|
||
}
|
||
|
||
// New 创建 CP 关系 service。
|
||
func New(repo Repository, options ...Option) *Service {
|
||
service := &Service{
|
||
repo: repo,
|
||
clock: func() time.Time { return time.Now().UTC() },
|
||
}
|
||
for _, option := range options {
|
||
if option != nil {
|
||
option(service)
|
||
}
|
||
}
|
||
return service
|
||
}
|
||
|
||
// ListApplications 返回用户收到或发出的 CP 申请;过期固化在 repository 中完成。
|
||
func (s *Service) ListApplications(ctx context.Context, userID int64, direction string, status string, page int32, pageSize int32) ([]cpdomain.Application, int64, int64, error) {
|
||
if err := requireUserID(userID); err != nil {
|
||
return nil, 0, 0, err
|
||
}
|
||
nowMs := s.nowMs()
|
||
applications, total, err := s.repo.ListApplications(ctx, userID, normalizeDirection(direction), normalizeStatus(status), page, pageSize, nowMs)
|
||
return applications, total, nowMs, err
|
||
}
|
||
|
||
// AcceptApplication 同意申请并建立关系;同一用户只能有一个 active 关系的互斥由 repository 事务保证。
|
||
func (s *Service) AcceptApplication(ctx context.Context, userID int64, applicationID string) (cpdomain.Application, cpdomain.Relationship, error) {
|
||
if err := requireUserID(userID); err != nil {
|
||
return cpdomain.Application{}, cpdomain.Relationship{}, err
|
||
}
|
||
if strings.TrimSpace(applicationID) == "" {
|
||
return cpdomain.Application{}, cpdomain.Relationship{}, xerr.New(xerr.InvalidArgument, "application_id is required")
|
||
}
|
||
return s.repo.AcceptApplication(ctx, userID, strings.TrimSpace(applicationID), s.nowMs())
|
||
}
|
||
|
||
// RejectApplication 拒绝申请;拒绝不阻断同一对用户其他类型的 pending 申请。
|
||
func (s *Service) RejectApplication(ctx context.Context, userID int64, applicationID string, reason string) (cpdomain.Application, error) {
|
||
if err := requireUserID(userID); err != nil {
|
||
return cpdomain.Application{}, err
|
||
}
|
||
if strings.TrimSpace(applicationID) == "" {
|
||
return cpdomain.Application{}, xerr.New(xerr.InvalidArgument, "application_id is required")
|
||
}
|
||
return s.repo.RejectApplication(ctx, userID, strings.TrimSpace(applicationID), reason, s.nowMs())
|
||
}
|
||
|
||
// ListRelationships 返回用户当前 active 关系;当前产品固定每个用户同时只能有一条关系。
|
||
func (s *Service) ListRelationships(ctx context.Context, userID int64, relationType string, page int32, pageSize int32) ([]cpdomain.Relationship, int64, error) {
|
||
if err := requireUserID(userID); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return s.repo.ListRelationships(ctx, userID, normalizeRelationType(relationType), page, pageSize)
|
||
}
|
||
|
||
// PrepareBreakRelationship 创建解除关系占位;占位成功后 gateway 才能去 wallet-service 扣费。
|
||
func (s *Service) PrepareBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error) {
|
||
if err := requireBreakCommand(userID, relationshipID, commandID); err != nil {
|
||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||
}
|
||
return s.repo.PrepareBreakRelationship(ctx, userID, strings.TrimSpace(relationshipID), strings.TrimSpace(commandID), s.nowMs())
|
||
}
|
||
|
||
// ConfirmBreakRelationship 在 wallet-service 扣费成功后确认解除;paidCoinAmount 必须等于 prepare 时冻结的配置费用。
|
||
func (s *Service) ConfirmBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, walletTransactionID string, paidCoinAmount int64, coinBalanceAfter int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error) {
|
||
if err := requireBreakCommand(userID, relationshipID, commandID); err != nil {
|
||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||
}
|
||
if paidCoinAmount < 0 {
|
||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.InvalidArgument, "paid_coin_amount is invalid")
|
||
}
|
||
if paidCoinAmount > 0 && strings.TrimSpace(walletTransactionID) == "" {
|
||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.InvalidArgument, "wallet_transaction_id is required")
|
||
}
|
||
return s.repo.ConfirmBreakRelationship(ctx, userID, strings.TrimSpace(relationshipID), strings.TrimSpace(commandID), strings.TrimSpace(walletTransactionID), paidCoinAmount, coinBalanceAfter, s.nowMs())
|
||
}
|
||
|
||
// CancelBreakRelationship 只取消 pending 占位;已经 confirmed 的解除不能通过取消接口回滚。
|
||
func (s *Service) CancelBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, reason string) (cpdomain.RelationshipBreakup, error) {
|
||
if err := requireBreakCommand(userID, relationshipID, commandID); err != nil {
|
||
return cpdomain.RelationshipBreakup{}, err
|
||
}
|
||
return s.repo.CancelBreakRelationship(ctx, userID, strings.TrimSpace(relationshipID), strings.TrimSpace(commandID), reason, s.nowMs())
|
||
}
|
||
|
||
// ConsumeGiftEvent 消费 room-service 已扣费并落房间状态的送礼事实。
|
||
func (s *Service) ConsumeGiftEvent(ctx context.Context, event cpdomain.GiftEvent) (cpdomain.ConsumeResult, error) {
|
||
event.CPRelationType = normalizeRelationType(event.CPRelationType)
|
||
// gift_value 是亲密值和周榜分数增量,沿用 room-service 已扣费后的结算值;0 值仍可创建申请,但不会增加已有关联亲密值。
|
||
if event.GiftValue < 0 {
|
||
event.GiftValue = 0
|
||
}
|
||
return s.repo.ConsumeGiftEvent(ctx, event, s.nowMs())
|
||
}
|
||
|
||
// ListWeeklyRankEntries 给 activity-service 的结算链路读取 CP 周榜快照;它不写 Redis,也不发奖。
|
||
func (s *Service) ListWeeklyRankEntries(ctx context.Context, relationType string, startMS int64, endMS int64, limit int32) ([]cpdomain.WeeklyRankEntry, int64, error) {
|
||
if s == nil || s.repo == nil {
|
||
return nil, 0, xerr.New(xerr.Unavailable, "cp repository is not configured")
|
||
}
|
||
if startMS <= 0 || endMS <= startMS {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "weekly rank time window is invalid")
|
||
}
|
||
if limit <= 0 {
|
||
limit = 3
|
||
}
|
||
if limit > 100 {
|
||
limit = 100
|
||
}
|
||
entries, err := s.repo.ListWeeklyRankEntries(ctx, normalizeRelationType(relationType), startMS, endMS, int(limit))
|
||
return entries, s.nowMs(), err
|
||
}
|
||
|
||
// RefreshIntimacyLeaderboard 重建 CP 亲密值排行榜读模型;cron 只触发,关系、用户资料和头像框快照都由 owner 服务读取。
|
||
func (s *Service) RefreshIntimacyLeaderboard(ctx context.Context, limit int) (int, error) {
|
||
// 榜单刷新是定时异步链路:任何核心依赖缺失都必须显式失败,让 cron run 记录失败状态,
|
||
// 不能静默返回空榜,否则 App 会误以为当前没有亲密关系。
|
||
if s == nil || s.repo == nil {
|
||
return 0, xerr.New(xerr.Unavailable, "cp repository is not configured")
|
||
}
|
||
if s.leaderboardStore == nil {
|
||
return 0, xerr.New(xerr.Unavailable, "cp intimacy leaderboard store is not configured")
|
||
}
|
||
if s.avatarFrames == nil {
|
||
return 0, xerr.New(xerr.Unavailable, "cp avatar frame reader is not configured")
|
||
}
|
||
// batch_size 在 cron 配置里表达本轮榜单保留上限;service 再做一次夹紧,避免配置异常导致全表无界扫描。
|
||
limit = normalizeLeaderboardRowLimit(limit)
|
||
entries, err := s.repo.ListActiveIntimacyLeaderboardEntries(ctx, limit)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
// 头像框归 wallet-service 管理,关系和用户资料归 user-service 管理;刷新时批量读钱包快照并固化进 zset member,
|
||
// App 查询榜单时只读 Redis,不再跨服务补资料,避免高频榜单接口放大 wallet-service 压力。
|
||
frames, err := s.avatarFrames.BatchGetAvatarFrames(ctx, appcode.FromContext(ctx), intimacyLeaderboardUserIDs(entries))
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
applyAvatarFrames(entries, frames)
|
||
// Redis 写入采用全量替换语义;关系结束、改名、换头像、换头像框都能在下一轮被覆盖,不需要单独维护删除事件。
|
||
if err := s.leaderboardStore.ReplaceIntimacyLeaderboard(ctx, appcode.FromContext(ctx), entries); err != nil {
|
||
return 0, err
|
||
}
|
||
return len(entries), nil
|
||
}
|
||
|
||
// ListIntimacyLeaderboard 返回 cron 刷新的 CP 亲密值排行榜,避免 App 请求实时扫关系表和跨服务补头像框。
|
||
func (s *Service) ListIntimacyLeaderboard(ctx context.Context, relationType string, page int32, pageSize int32) (cpdomain.IntimacyLeaderboardPage, error) {
|
||
// 查询接口只依赖 Redis 读模型;如果刷新链路没有装配,直接返回 unavailable,避免回退 MySQL 实时扫表。
|
||
if s == nil || s.leaderboardStore == nil {
|
||
return cpdomain.IntimacyLeaderboardPage{}, xerr.New(xerr.Unavailable, "cp intimacy leaderboard store is not configured")
|
||
}
|
||
// relation_type 只允许 cp/brother/sister;空值和非法值都归并到 all 榜,由 Redis store 统一落 key。
|
||
relationType = normalizeRelationType(relationType)
|
||
page, pageSize = normalizeLeaderboardPage(page, pageSize)
|
||
return s.leaderboardStore.ListIntimacyLeaderboard(ctx, appcode.FromContext(ctx), relationType, page, pageSize, s.nowMs())
|
||
}
|
||
|
||
func (s *Service) nowMs() int64 {
|
||
if s == nil || s.clock == nil {
|
||
return time.Now().UTC().UnixMilli()
|
||
}
|
||
return s.clock().UTC().UnixMilli()
|
||
}
|
||
|
||
func requireUserID(userID int64) error {
|
||
if userID <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "user_id is required")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func requireBreakCommand(userID int64, relationshipID string, commandID string) error {
|
||
if err := requireUserID(userID); err != nil {
|
||
return err
|
||
}
|
||
if strings.TrimSpace(relationshipID) == "" {
|
||
return xerr.New(xerr.InvalidArgument, "relationship_id is required")
|
||
}
|
||
if strings.TrimSpace(commandID) == "" {
|
||
return xerr.New(xerr.InvalidArgument, "command_id is required")
|
||
}
|
||
if len(strings.TrimSpace(commandID)) > 128 {
|
||
return xerr.New(xerr.InvalidArgument, "command_id is too long")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizeDirection(value string) string {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "outgoing":
|
||
return "outgoing"
|
||
default:
|
||
return "incoming"
|
||
}
|
||
}
|
||
|
||
func normalizeStatus(value string) string {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case cpdomain.ApplicationStatusPending,
|
||
cpdomain.ApplicationStatusAccepted,
|
||
cpdomain.ApplicationStatusRejected,
|
||
cpdomain.ApplicationStatusExpired,
|
||
cpdomain.ApplicationStatusBlocked:
|
||
return strings.ToLower(strings.TrimSpace(value))
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func normalizeLeaderboardPage(page int32, pageSize int32) (int32, int32) {
|
||
if page <= 0 {
|
||
page = 1
|
||
}
|
||
if pageSize <= 0 {
|
||
pageSize = defaultIntimacyLeaderboardPageSize
|
||
}
|
||
if pageSize > maxIntimacyLeaderboardPageSize {
|
||
pageSize = maxIntimacyLeaderboardPageSize
|
||
}
|
||
return page, pageSize
|
||
}
|
||
|
||
func normalizeLeaderboardRowLimit(limit int) int {
|
||
if limit <= 0 {
|
||
return defaultIntimacyLeaderboardRows
|
||
}
|
||
if limit > maxIntimacyLeaderboardRows {
|
||
return maxIntimacyLeaderboardRows
|
||
}
|
||
return limit
|
||
}
|
||
|
||
func intimacyLeaderboardUserIDs(entries []cpdomain.IntimacyLeaderboardEntry) []int64 {
|
||
seen := make(map[int64]struct{}, len(entries)*2)
|
||
userIDs := make([]int64, 0, len(entries)*2)
|
||
for _, entry := range entries {
|
||
for _, userID := range []int64{entry.UserA.UserID, entry.UserB.UserID} {
|
||
if userID <= 0 {
|
||
continue
|
||
}
|
||
// 同一个用户可能出现在多条兄弟/姐妹关系里,批量读钱包资源前先去重,避免无意义的重复 RPC payload。
|
||
if _, exists := seen[userID]; exists {
|
||
continue
|
||
}
|
||
seen[userID] = struct{}{}
|
||
userIDs = append(userIDs, userID)
|
||
}
|
||
}
|
||
return userIDs
|
||
}
|
||
|
||
func applyAvatarFrames(entries []cpdomain.IntimacyLeaderboardEntry, frames map[int64]cpdomain.AvatarFrameSnapshot) {
|
||
for index := range entries {
|
||
// 没有佩戴头像框或 wallet-service 没返回资源时保持 nil,gateway 会省略 avatar_frame 字段。
|
||
if frame, ok := frames[entries[index].UserA.UserID]; ok && frame.ResourceID > 0 {
|
||
entries[index].UserA.AvatarFrame = &frame
|
||
}
|
||
if frame, ok := frames[entries[index].UserB.UserID]; ok && frame.ResourceID > 0 {
|
||
entries[index].UserB.AvatarFrame = &frame
|
||
}
|
||
}
|
||
}
|
||
|
||
func normalizeRelationType(value string) string {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case cpdomain.RelationTypeCP:
|
||
return cpdomain.RelationTypeCP
|
||
case cpdomain.RelationTypeBrother:
|
||
return cpdomain.RelationTypeBrother
|
||
case cpdomain.RelationTypeSister:
|
||
return cpdomain.RelationTypeSister
|
||
default:
|
||
return ""
|
||
}
|
||
}
|