package cprelation import ( "context" "database/sql" "errors" "fmt" "slices" "strings" "time" "hyapp-admin-server/internal/integration/activityclient" activityv1 "hyapp.local/api/proto/activity/v1" ) const ( relationTypeCP = "cp" relationTypeBrother = "brother" relationTypeSister = "sister" statusActive = "active" levelCount = 5 defaultExpireHours = 24 ) var ( ErrInvalidArgument = errors.New("invalid argument") relationTypes = []string{relationTypeCP, relationTypeBrother, relationTypeSister} relationLabels = map[string]string{ relationTypeCP: "CP", relationTypeBrother: "兄弟", relationTypeSister: "姐妹", } defaultLevelThresholds = []int64{0, 10_000, 30_000, 60_000, 100_000} ) type DB interface { BeginTx(context.Context, *sql.TxOptions) (*sql.Tx, error) ExecContext(context.Context, string, ...any) (sql.Result, error) QueryContext(context.Context, string, ...any) (*sql.Rows, error) QueryRowContext(context.Context, string, ...any) *sql.Row } type Service struct { db DB activity activityclient.Client } type configDTO struct { AppCode string `json:"appCode"` Relations []relationDTO `json:"relations"` WeeklyRank weeklyRankDTO `json:"weeklyRank"` ServerTimeMS int64 `json:"serverTimeMs"` } type relationDTO struct { RelationType string `json:"relationType"` DisplayName string `json:"displayName"` MaxCountPerUser int32 `json:"maxCountPerUser"` ApplicationExpireHours int32 `json:"applicationExpireHours"` BreakupCostCoins int64 `json:"breakupCostCoins"` Status string `json:"status"` Levels []levelDTO `json:"levels"` UpdatedAtMS int64 `json:"updatedAtMs"` } type levelDTO struct { Level int32 `json:"level"` IntimacyThreshold int64 `json:"intimacyThreshold"` RewardResourceGroupID int64 `json:"rewardResourceGroupId"` LevelIconURL string `json:"levelIconUrl"` Status string `json:"status"` UpdatedAtMS int64 `json:"updatedAtMs"` } type weeklyRankDTO struct { Enabled bool `json:"enabled"` RelationType string `json:"relationType"` TopCount int32 `json:"topCount"` Rewards []weeklyRankRewardDTO `json:"rewards"` UpdatedByAdminID int64 `json:"updatedByAdminId"` CreatedAtMS int64 `json:"createdAtMs"` UpdatedAtMS int64 `json:"updatedAtMs"` } type weeklyRankRewardDTO struct { RankNo int32 `json:"rankNo"` ResourceGroupID int64 `json:"resourceGroupId"` } type updateRequest struct { Relations []relationDTO `json:"relations"` WeeklyRank *weeklyRankDTO `json:"weeklyRank"` } func NewService(db DB, activity activityclient.Client) *Service { return &Service{db: db, activity: activity} } // GetConfig 读取当前 App 的关系容量和等级阈值;缺配置时先落默认值,保证后台首次打开就是可保存状态。 func (s *Service) GetConfig(ctx context.Context, appCode string) (configDTO, error) { nowMs := time.Now().UTC().UnixMilli() if err := s.ensureSchema(ctx); err != nil { return configDTO{}, err } if err := s.ensureDefaultConfig(ctx, appCode, nowMs); err != nil { return configDTO{}, err } relations, err := s.listRelations(ctx, appCode) if err != nil { return configDTO{}, err } weeklyRank, err := s.getWeeklyRankConfig(ctx, appCode) if err != nil { return configDTO{}, err } return configDTO{AppCode: appCode, Relations: relations, WeeklyRank: weeklyRank, ServerTimeMS: nowMs}, nil } // UpdateConfig 覆盖低频配置;写入时按 relation_type + level_no upsert,运行中消费逻辑每次读取最新阈值。 func (s *Service) UpdateConfig(ctx context.Context, appCode string, req updateRequest) (configDTO, error) { nowMs := time.Now().UTC().UnixMilli() if err := s.ensureSchema(ctx); err != nil { return configDTO{}, err } relations, err := normalizeRelations(req.Relations) if err != nil { return configDTO{}, err } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return configDTO{}, err } defer func() { _ = tx.Rollback() }() for _, relation := range relations { if _, err := tx.ExecContext(ctx, ` INSERT INTO user_cp_relation_configs ( app_code, relation_type, max_count_per_user, application_expire_hours, breakup_cost_coins, status, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE max_count_per_user = VALUES(max_count_per_user), application_expire_hours = VALUES(application_expire_hours), breakup_cost_coins = VALUES(breakup_cost_coins), status = VALUES(status), updated_at_ms = VALUES(updated_at_ms)`, appCode, relation.RelationType, relation.MaxCountPerUser, relation.ApplicationExpireHours, relation.BreakupCostCoins, relation.Status, nowMs, nowMs, ); err != nil { return configDTO{}, err } for _, level := range relation.Levels { if _, err := tx.ExecContext(ctx, ` INSERT INTO user_cp_level_rules ( app_code, relation_type, level_no, intimacy_threshold, reward_resource_group_id, level_icon_url, status, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE intimacy_threshold = VALUES(intimacy_threshold), reward_resource_group_id = VALUES(reward_resource_group_id), level_icon_url = VALUES(level_icon_url), status = VALUES(status), updated_at_ms = VALUES(updated_at_ms)`, appCode, relation.RelationType, level.Level, level.IntimacyThreshold, level.RewardResourceGroupID, level.LevelIconURL, level.Status, nowMs, nowMs, ); err != nil { return configDTO{}, err } } } if err := tx.Commit(); err != nil { return configDTO{}, err } if req.WeeklyRank != nil { // CP 关系配置归 user DB,周榜启停和发奖事实归 activity-service;这里在 user 配置提交后再写 activity, // 失败时返回错误让后台明确重试,避免误以为奖励配置已经生效。 if err := s.updateWeeklyRankConfig(ctx, appCode, *req.WeeklyRank); err != nil { return configDTO{}, err } } return s.GetConfig(ctx, appCode) } func (s *Service) getWeeklyRankConfig(ctx context.Context, appCode string) (weeklyRankDTO, error) { if s.activity == nil { return weeklyRankDTO{Enabled: true, RelationType: relationTypeCP, TopCount: 3}, nil } resp, err := s.activity.GetCPWeeklyRankConfig(ctx, &activityv1.GetCPWeeklyRankConfigRequest{Meta: s.activityMeta(appCode)}) if err != nil { return weeklyRankDTO{}, err } return weeklyRankFromProto(resp.GetConfig()), nil } func (s *Service) updateWeeklyRankConfig(ctx context.Context, appCode string, req weeklyRankDTO) error { if s.activity == nil { return errors.New("activity client is not configured") } _, err := s.activity.UpdateCPWeeklyRankConfig(ctx, &activityv1.UpdateCPWeeklyRankConfigRequest{ Meta: s.activityMeta(appCode), Config: weeklyRankToProto(req), OperatorAdminId: req.UpdatedByAdminID, }) return err } func (s *Service) activityMeta(appCode string) *activityv1.RequestMeta { return &activityv1.RequestMeta{ RequestId: "admin-cp-config", Caller: "admin-server", AppCode: appCode, SentAtMs: time.Now().UTC().UnixMilli(), } } func weeklyRankFromProto(item *activityv1.CPWeeklyRankConfig) weeklyRankDTO { if item == nil { return weeklyRankDTO{Enabled: true, RelationType: relationTypeCP, TopCount: 3} } rewards := make([]weeklyRankRewardDTO, 0, len(item.GetRewards())) for _, reward := range item.GetRewards() { rewards = append(rewards, weeklyRankRewardDTO{RankNo: reward.GetRankNo(), ResourceGroupID: reward.GetResourceGroupId()}) } return weeklyRankDTO{ Enabled: item.GetEnabled(), RelationType: item.GetRelationType(), TopCount: item.GetTopCount(), Rewards: rewards, UpdatedByAdminID: item.GetUpdatedByAdminId(), CreatedAtMS: item.GetCreatedAtMs(), UpdatedAtMS: item.GetUpdatedAtMs(), } } func weeklyRankToProto(item weeklyRankDTO) *activityv1.CPWeeklyRankConfig { rewards := make([]*activityv1.CPWeeklyRankReward, 0, len(item.Rewards)) for _, reward := range item.Rewards { rewards = append(rewards, &activityv1.CPWeeklyRankReward{RankNo: reward.RankNo, ResourceGroupId: reward.ResourceGroupID}) } return &activityv1.CPWeeklyRankConfig{ Enabled: item.Enabled, RelationType: relationTypeCP, TopCount: item.TopCount, Rewards: rewards, } } func (s *Service) listRelations(ctx context.Context, appCode string) ([]relationDTO, error) { rows, err := s.db.QueryContext(ctx, ` SELECT relation_type, max_count_per_user, application_expire_hours, breakup_cost_coins, status, updated_at_ms FROM user_cp_relation_configs WHERE app_code = ?`, appCode) if err != nil { return nil, err } defer rows.Close() relationsByType := map[string]relationDTO{} for rows.Next() { var item relationDTO if err := rows.Scan(&item.RelationType, &item.MaxCountPerUser, &item.ApplicationExpireHours, &item.BreakupCostCoins, &item.Status, &item.UpdatedAtMS); err != nil { return nil, err } item.DisplayName = relationLabels[item.RelationType] relationsByType[item.RelationType] = item } if err := rows.Err(); err != nil { return nil, err } levelsByType, err := s.levelsByRelationType(ctx, appCode) if err != nil { return nil, err } for relationType, levels := range levelsByType { item := relationsByType[relationType] item.Levels = levels relationsByType[relationType] = item } relations := make([]relationDTO, 0, len(relationTypes)) for _, relationType := range relationTypes { item := relationsByType[relationType] item.RelationType = relationType item.DisplayName = relationLabels[relationType] item.Levels = normalizeLevelDefaults(item.Levels) relations = append(relations, item) } return relations, nil } // levelsByRelationType 必须把读取失败直接抛回 handler;后台配置页不能用默认等级掩盖坏 SQL 或坏数据。 func (s *Service) levelsByRelationType(ctx context.Context, appCode string) (map[string][]levelDTO, error) { rows, err := s.db.QueryContext(ctx, ` SELECT relation_type, level_no, intimacy_threshold, reward_resource_group_id, level_icon_url, status, updated_at_ms FROM user_cp_level_rules WHERE app_code = ? ORDER BY relation_type ASC, level_no ASC`, appCode) if err != nil { return nil, err } defer rows.Close() result := map[string][]levelDTO{} for rows.Next() { var relationType string var item levelDTO if err := rows.Scan(&relationType, &item.Level, &item.IntimacyThreshold, &item.RewardResourceGroupID, &item.LevelIconURL, &item.Status, &item.UpdatedAtMS); err != nil { return nil, err } result[relationType] = append(result[relationType], item) } if err := rows.Err(); err != nil { return nil, err } return result, nil } func (s *Service) ensureDefaultConfig(ctx context.Context, appCode string, nowMs int64) error { for _, relationType := range relationTypes { maxCount := int32(1) if relationType == relationTypeBrother || relationType == relationTypeSister { maxCount = 5 } if _, err := s.db.ExecContext(ctx, ` INSERT INTO user_cp_relation_configs ( app_code, relation_type, max_count_per_user, application_expire_hours, breakup_cost_coins, status, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, 0, 'active', ?, ?) ON DUPLICATE KEY UPDATE relation_type = VALUES(relation_type)`, appCode, relationType, maxCount, defaultExpireHours, nowMs, nowMs, ); err != nil { return err } for level := int32(1); level <= levelCount; level++ { if _, err := s.db.ExecContext(ctx, ` INSERT INTO user_cp_level_rules ( app_code, relation_type, level_no, intimacy_threshold, reward_resource_group_id, level_icon_url, status, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, 0, '', 'active', ?, ?) ON DUPLICATE KEY UPDATE level_no = VALUES(level_no)`, appCode, relationType, level, defaultLevelThresholds[level-1], nowMs, nowMs, ); err != nil { return err } } } return nil } func (s *Service) ensureSchema(ctx context.Context) error { if s == nil || s.db == nil { return errors.New("user db is not configured") } if _, err := s.db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS user_cp_relation_configs ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', relation_type VARCHAR(32) NOT NULL, max_count_per_user INT NOT NULL, application_expire_hours INT NOT NULL, breakup_cost_coins BIGINT NOT NULL DEFAULT 0, status VARCHAR(32) NOT NULL DEFAULT 'active', created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, relation_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`); err != nil { return err } if err := s.ensureColumn(ctx, "user_cp_relation_configs", "breakup_cost_coins", ` ALTER TABLE user_cp_relation_configs ADD COLUMN breakup_cost_coins BIGINT NOT NULL DEFAULT 0 AFTER application_expire_hours`); err != nil { return err } _, err := s.db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS user_cp_level_rules ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', relation_type VARCHAR(32) NOT NULL, level_no INT NOT NULL, intimacy_threshold BIGINT NOT NULL, reward_resource_group_id BIGINT NOT NULL DEFAULT 0, level_icon_url VARCHAR(512) NOT NULL DEFAULT '', status VARCHAR(32) NOT NULL DEFAULT 'active', created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, relation_type, level_no), KEY idx_user_cp_level_rules_status (app_code, status, relation_type, level_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`) return err } func (s *Service) ensureColumn(ctx context.Context, tableName string, columnName string, alterSQL string) error { var found string err := s.db.QueryRowContext(ctx, ` SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1`, tableName, columnName).Scan(&found) if err == nil { return nil } if !errors.Is(err, sql.ErrNoRows) { return err } _, err = s.db.ExecContext(ctx, alterSQL) return err } func normalizeRelations(input []relationDTO) ([]relationDTO, error) { if len(input) == 0 { return nil, fmt.Errorf("%w: 关系配置不能为空", ErrInvalidArgument) } seen := map[string]bool{} out := make([]relationDTO, 0, len(relationTypes)) for _, relation := range input { relation.RelationType = strings.ToLower(strings.TrimSpace(relation.RelationType)) if !slices.Contains(relationTypes, relation.RelationType) { return nil, fmt.Errorf("%w: 关系类型不正确", ErrInvalidArgument) } if seen[relation.RelationType] { return nil, fmt.Errorf("%w: 关系类型不能重复", ErrInvalidArgument) } seen[relation.RelationType] = true // CP 关系仍是硬性一人一个;后台页面只允许调整兄弟/姐妹数量,接口层也强制收敛防止绕过前端。 if relation.RelationType == relationTypeCP { relation.MaxCountPerUser = 1 } if relation.MaxCountPerUser <= 0 { return nil, fmt.Errorf("%w: %s数量必须大于0", ErrInvalidArgument, relationLabels[relation.RelationType]) } if relation.ApplicationExpireHours <= 0 { relation.ApplicationExpireHours = defaultExpireHours } if relation.BreakupCostCoins < 0 { return nil, fmt.Errorf("%w: %s解除费用不能小于0", ErrInvalidArgument, relationLabels[relation.RelationType]) } relation.Status = defaultString(strings.TrimSpace(relation.Status), statusActive) relation.Levels = normalizeLevelDefaults(relation.Levels) if err := validateLevels(relation.Levels); err != nil { return nil, err } out = append(out, relation) } for _, relationType := range relationTypes { if !seen[relationType] { return nil, fmt.Errorf("%w: 缺少%s配置", ErrInvalidArgument, relationLabels[relationType]) } } slices.SortFunc(out, func(left, right relationDTO) int { return slices.Index(relationTypes, left.RelationType) - slices.Index(relationTypes, right.RelationType) }) return out, nil } func normalizeLevelDefaults(input []levelDTO) []levelDTO { byLevel := map[int32]levelDTO{} for _, level := range input { byLevel[level.Level] = level } levels := make([]levelDTO, 0, levelCount) for levelNo := int32(1); levelNo <= levelCount; levelNo++ { level := byLevel[levelNo] level.Level = levelNo level.Status = defaultString(strings.TrimSpace(level.Status), statusActive) if level.IntimacyThreshold == 0 && levelNo > 1 { level.IntimacyThreshold = defaultLevelThresholds[levelNo-1] } level.LevelIconURL = strings.TrimSpace(level.LevelIconURL) levels = append(levels, level) } return levels } func validateLevels(levels []levelDTO) error { if len(levels) != levelCount { return fmt.Errorf("%w: 等级必须配置1到5级", ErrInvalidArgument) } previous := int64(-1) for _, level := range levels { if level.Level < 1 || level.Level > levelCount { return fmt.Errorf("%w: 等级只能是1到5", ErrInvalidArgument) } if level.IntimacyThreshold < 0 { return fmt.Errorf("%w: 第%d级亲密值不能小于0", ErrInvalidArgument, level.Level) } if level.IntimacyThreshold <= previous { return fmt.Errorf("%w: 等级亲密值必须递增", ErrInvalidArgument) } if level.RewardResourceGroupID < 0 { return fmt.Errorf("%w: 奖励资源组不能小于0", ErrInvalidArgument) } previous = level.IntimacyThreshold } return nil } func defaultString(value string, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return strings.TrimSpace(value) }