package gamemanagement import ( "context" "crypto/rand" "database/sql" "fmt" "math/big" "net/url" "strings" "unicode/utf8" ) type RobotProfile struct { Nickname string Avatar string Gender string } type RobotProfileSource interface { RandomRobotProfiles(ctx context.Context, count int) ([]RobotProfile, error) } type likeiRobotProfileSource struct { db *sql.DB } func NewLikeiRobotProfileSource(db *sql.DB) RobotProfileSource { if db == nil { return nil } return &likeiRobotProfileSource{db: db} } // WithRobotProfileSource 注入机器人资料池;创建机器人优先消费线上 likei 用户昵称和头像,未配置时再走本地随机资料兜底。 func WithRobotProfileSource(source RobotProfileSource) Option { return func(h *Handler) { h.robotProfiles = source } } func (s *likeiRobotProfileSource) RandomRobotProfiles(ctx context.Context, count int) ([]RobotProfile, error) { if s == nil || s.db == nil { return nil, fmt.Errorf("likei robot profile source is not configured") } if count <= 0 { return nil, nil } rows, err := s.db.QueryContext(ctx, ` SELECT TRIM(user_nickname), TRIM(user_avatar) FROM user_base_info WHERE account_status = 'NORMAL' AND is_del = 0 AND origin_sys = 'LIKEI' AND user_avatar IS NOT NULL AND TRIM(user_avatar) <> '' AND user_nickname IS NOT NULL AND TRIM(user_nickname) <> '' AND CHAR_LENGTH(TRIM(user_nickname)) <= 64 AND CHAR_LENGTH(TRIM(user_avatar)) <= 512 AND (TRIM(user_avatar) LIKE 'http://%' OR TRIM(user_avatar) LIKE 'https://%') ORDER BY RAND() LIMIT ? `, count) if err != nil { return nil, fmt.Errorf("query likei robot profiles: %w", err) } defer rows.Close() profiles := make([]RobotProfile, 0, count) for rows.Next() { var profile RobotProfile if err := rows.Scan(&profile.Nickname, &profile.Avatar); err != nil { return nil, fmt.Errorf("scan likei robot profile: %w", err) } if normalized, ok := normalizeRobotProfile(profile); ok { profiles = append(profiles, normalized) } } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate likei robot profiles: %w", err) } if len(profiles) < count { return nil, fmt.Errorf("likei robot profile pool returned %d profiles, need %d", len(profiles), count) } return profiles, nil } func robotProfilesForGenerate(ctx context.Context, source RobotProfileSource, count int) ([]RobotProfile, error) { if source != nil { // 已配置 likei 时继续使用真实资料池;查询失败不能静默降级,否则线上数据源故障会被批量造号掩盖。 return source.RandomRobotProfiles(ctx, count) } profiles := make([]RobotProfile, 0, count) for index := 0; index < count; index++ { profiles = append(profiles, randomRobotProfile()) } return profiles, nil } func randomRobotProfile() RobotProfile { seed := randomRobotHex(8) nameSeed := randomRobotHex(3) if seed == "fallback" { seed = fmt.Sprintf("fallback-%d", randomRobotInt(1000000)) } // 本地随机头像只保存绝对 URL,不在 admin-server 里下载或转存图片;user-service 仍负责资料字段校验。 return RobotProfile{ Nickname: fmt.Sprintf("Robot %s", strings.ToUpper(nameSeed)), Avatar: "https://api.dicebear.com/9.x/adventurer/png?seed=hyapp-robot-" + url.QueryEscape(seed), Gender: randomRobotGender(), } } func randomRobotGender() string { if randomRobotInt(2) == 0 { return "male" } return "female" } func randomRobotInt(max int64) int64 { if max <= 0 { return 0 } value, err := rand.Int(rand.Reader, big.NewInt(max)) if err != nil { return 0 } return value.Int64() } func normalizeRobotProfile(profile RobotProfile) (RobotProfile, bool) { profile.Nickname = strings.TrimSpace(profile.Nickname) profile.Avatar = strings.TrimSpace(profile.Avatar) if profile.Nickname == "" || profile.Avatar == "" { return RobotProfile{}, false } if utf8.RuneCountInString(profile.Nickname) > 64 || utf8.RuneCountInString(profile.Avatar) > 512 { return RobotProfile{}, false } parsed, err := url.Parse(profile.Avatar) if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { return RobotProfile{}, false } if strings.ContainsAny(profile.Avatar, " \t\r\n") { return RobotProfile{}, false } return profile, true }