Use likei profiles for game robots

This commit is contained in:
zhx 2026-06-12 01:21:33 +08:00
parent 84bba92611
commit d2dbc88e5f
16 changed files with 383 additions and 1039 deletions

View File

@ -23,9 +23,9 @@
## 创建流程
1. 运营在后台选择创建数量和昵称语言。
2. admin-server 下载并转存头像
3. admin-server 调 user-service 创建 `source=game_robot` 的资料号。
1. 运营在后台选择创建数量、国家、性别和语言元数据
2. admin-server 从 likei 线上 `user_base_info` 随机抽取正常用户的 `user_nickname``user_avatar`
3. admin-server 调 user-service 创建 `source=game_robot` 的资料号,昵称和头像直接使用 likei 资料
4. user-service 写入用户资料、短 ID 和密码身份,但不创建登录 session。
5. admin-server 调 game-service 把这些 user_id 注册到机器人池。
6. game-service 后续只从启用状态的机器人中选择补位。
@ -55,7 +55,7 @@
### 批量创建
- 地址:`POST /admin/game/robots/generate`
- 参数:`count`(默认 20最多 1000`nicknameLanguage``gender``country``avatarUrls`
- 参数:`count`(默认 20最多 1000`nicknameLanguage`(只作为账号语言元数据)`gender``country`
- 返回值:创建数量、机器人列表
- 相关 IM机器人不导入 IM 登录账号
@ -75,7 +75,7 @@
## 游戏补位规则
- 自研游戏配置里开启机器人补位后玩家等待超过配置时间game-service 会优先选择最久未使用的启用机器人加入对局。
- 机器人被选中后会记录最近使用时间和累计使用次数;只有可用机器人轮过一遍后,才会重新使用较早出现过的机器人
- 自研游戏配置里开启机器人补位后玩家等待超过配置时间game-service 会从当前 App 下所有启用且未加入本局的机器人里完全随机抽取一个加入对局。
- 机器人被选中后会记录最近使用时间和累计使用次数;这些字段只用于后台观察命中分布,不影响下一次随机抽取
- 机器人对局资金由游戏奖池结算,不从机器人钱包扣款。
- 机器人禁用后不会再被新对局选中。

View File

@ -149,6 +149,22 @@ func main() {
}
defer walletDB.Close()
var robotProfileSource gamemanagementmodule.RobotProfileSource
if strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) != "" {
robotProfileDB, err := sql.Open("mysql", cfg.RobotProfileSource.MySQLDSN)
if err != nil {
fatalRuntime("connect_robot_profile_mysql_failed", err)
}
robotProfileCtx, robotProfileCancel := context.WithTimeout(context.Background(), cfg.RobotProfileSource.RequestTimeout)
if err := robotProfileDB.PingContext(robotProfileCtx); err != nil {
robotProfileCancel()
fatalRuntime("ping_robot_profile_mysql_failed", err)
}
robotProfileCancel()
defer robotProfileDB.Close()
robotProfileSource = gamemanagementmodule.NewLikeiRobotProfileSource(robotProfileDB)
}
store := repository.New(db)
if cfg.MySQLAutoMigrate {
if err := store.AutoMigrate(); err != nil {
@ -242,7 +258,7 @@ func main() {
gameclient.NewGRPC(gameConn),
userclient.NewGRPC(userConn),
auditHandler,
gamemanagementmodule.WithRobotAvatarUploader(objectUploader, cfg.TencentCOS.ObjectPrefix),
gamemanagementmodule.WithRobotProfileSource(robotProfileSource),
),
GiftDiamond: giftdiamondmodule.New(walletDB, auditHandler),
Health: healthmodule.New(store, redisClient, jobStatus),

View File

@ -11,6 +11,9 @@ http_addr: "127.0.0.1:13100"
mysql_dsn: "admin_user:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
user_mysql_dsn: "user_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC"
wallet_mysql_dsn: "wallet_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
robot_profile_source:
mysql_dsn: "likei_reader:REPLACE_ME@tcp(10.2.21.3:3306)/likei?parseTime=true&charset=utf8mb4&loc=UTC"
request_timeout: "5s"
mysql_auto_migrate: false
migrations:
enabled: true

View File

@ -11,6 +11,9 @@ http_addr: ":13100"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
user_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC"
wallet_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
robot_profile_source:
mysql_dsn: ""
request_timeout: "5s"
mysql_auto_migrate: true
migrations:
enabled: true

View File

@ -13,33 +13,34 @@ import (
)
type Config struct {
ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
Log logging.Config `yaml:"log"`
HTTPAddr string `yaml:"http_addr"`
MySQLDSN string `yaml:"mysql_dsn"`
UserMySQLDSN string `yaml:"user_mysql_dsn"`
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Migrations MigrationConfig `yaml:"migrations"`
JWTSecret string `yaml:"jwt_secret"`
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"`
Bootstrap BootstrapConfig `yaml:"bootstrap"`
BootstrapPassword string `yaml:"bootstrap_password"`
UserService UserServiceConfig `yaml:"user_service"`
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
Redis RedisConfig `yaml:"redis"`
Jobs JobsConfig `yaml:"jobs"`
WalletService WalletServiceConfig `yaml:"wallet_service"`
RoomService RoomServiceConfig `yaml:"room_service"`
ActivityService ActivityServiceConfig `yaml:"activity_service"`
GameService GameServiceConfig `yaml:"game_service"`
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
Log logging.Config `yaml:"log"`
HTTPAddr string `yaml:"http_addr"`
MySQLDSN string `yaml:"mysql_dsn"`
UserMySQLDSN string `yaml:"user_mysql_dsn"`
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
RobotProfileSource RobotProfileSourceConfig `yaml:"robot_profile_source"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Migrations MigrationConfig `yaml:"migrations"`
JWTSecret string `yaml:"jwt_secret"`
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"`
Bootstrap BootstrapConfig `yaml:"bootstrap"`
BootstrapPassword string `yaml:"bootstrap_password"`
UserService UserServiceConfig `yaml:"user_service"`
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
Redis RedisConfig `yaml:"redis"`
Jobs JobsConfig `yaml:"jobs"`
WalletService WalletServiceConfig `yaml:"wallet_service"`
RoomService RoomServiceConfig `yaml:"room_service"`
ActivityService ActivityServiceConfig `yaml:"activity_service"`
GameService GameServiceConfig `yaml:"game_service"`
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
}
type MigrationConfig struct {
@ -78,6 +79,11 @@ type StatisticsServiceConfig struct {
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type RobotProfileSourceConfig struct {
MySQLDSN string `yaml:"mysql_dsn"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type TencentCOSConfig struct {
Enabled bool `yaml:"enabled"`
SecretID string `yaml:"secret-id"`
@ -115,14 +121,17 @@ type JobsConfig struct {
func Default() Config {
return Config{
ServiceName: "admin-server",
NodeID: "admin-local",
Environment: "local",
Log: logging.DefaultConfig(),
HTTPAddr: ":13100",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC",
UserMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC",
WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC",
ServiceName: "admin-server",
NodeID: "admin-local",
Environment: "local",
Log: logging.DefaultConfig(),
HTTPAddr: ":13100",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC",
UserMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC",
WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC",
RobotProfileSource: RobotProfileSourceConfig{
RequestTimeout: 5 * time.Second,
},
MySQLAutoMigrate: true,
Migrations: MigrationConfig{
Enabled: true,
@ -290,6 +299,10 @@ func (cfg *Config) Normalize() {
if cfg.StatisticsService.RequestTimeout <= 0 {
cfg.StatisticsService.RequestTimeout = 3 * time.Second
}
cfg.RobotProfileSource.MySQLDSN = strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN)
if cfg.RobotProfileSource.RequestTimeout <= 0 {
cfg.RobotProfileSource.RequestTimeout = 5 * time.Second
}
cfg.TencentCOS.SecretID = strings.TrimSpace(cfg.TencentCOS.SecretID)
cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey)
cfg.TencentCOS.BucketName = strings.TrimSpace(cfg.TencentCOS.BucketName)
@ -399,6 +412,12 @@ func (cfg Config) Validate() error {
if cfg.StatisticsService.RequestTimeout <= 0 {
return errors.New("statistics_service.request_timeout must be greater than 0")
}
if isLockedEnvironment(cfg.Environment) && strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) == "" {
return errors.New("robot_profile_source.mysql_dsn is required outside local/dev")
}
if cfg.RobotProfileSource.RequestTimeout <= 0 {
return errors.New("robot_profile_source.request_timeout must be greater than 0")
}
if cfg.TencentCOS.Enabled {
if cfg.TencentCOS.SecretID == "" || cfg.TencentCOS.SecretKey == "" || cfg.TencentCOS.BucketName == "" || cfg.TencentCOS.Region == "" || cfg.TencentCOS.AccessURL == "" {
return errors.New("tencent-cos config is incomplete")

View File

@ -14,17 +14,17 @@ import (
)
type Handler struct {
game gameclient.Client
user userclient.Client
audit shared.OperationLogger
robotAvatar robotAvatarConfig
game gameclient.Client
user userclient.Client
audit shared.OperationLogger
robotProfiles RobotProfileSource
}
// Option 只开放模块初始化时的基础设施注入点,避免 handler 方法里散落环境配置读取。
type Option func(*Handler)
func New(game gameclient.Client, user userclient.Client, audit shared.OperationLogger, options ...Option) *Handler {
h := &Handler{game: game, user: user, audit: audit, robotAvatar: defaultRobotAvatarConfig()}
h := &Handler{game: game, user: user, audit: audit}
for _, option := range options {
if option != nil {
option(h)

View File

@ -1,167 +0,0 @@
package gamemanagement
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"hyapp-admin-server/internal/platform/idgen"
)
const (
robotAvatarDownloadTimeout = 10 * time.Second
robotAvatarMaxBytes = 5 << 20
robotAvatarSniffBytes = 512
)
// ObjectUploader 是游戏管理模块需要的对象存储最小能力,实际生产实现由后台 COS 平台层提供。
type ObjectUploader interface {
PutObject(ctx context.Context, key string, reader io.Reader, sizeBytes int64, contentType string) (string, error)
}
type robotAvatarConfig struct {
uploader ObjectUploader
objectPrefix string
httpClient *http.Client
}
func defaultRobotAvatarConfig() robotAvatarConfig {
return robotAvatarConfig{
objectPrefix: "admin",
httpClient: &http.Client{Timeout: robotAvatarDownloadTimeout},
}
}
// WithRobotAvatarUploader 注入后台已装配的 COS 上传器,机器人创建只拿这个薄接口,不直接依赖 COS SDK。
func WithRobotAvatarUploader(uploader ObjectUploader, objectPrefix string) Option {
return func(h *Handler) {
h.robotAvatar.uploader = uploader
h.robotAvatar.objectPrefix = cleanRobotAvatarObjectPrefix(objectPrefix)
}
}
// WithRobotAvatarHTTPClient 只给测试替换下载客户端;生产默认使用带超时的标准 HTTP client。
func WithRobotAvatarHTTPClient(client *http.Client) Option {
return func(h *Handler) {
if client != nil {
h.robotAvatar.httpClient = client
}
}
}
func (h *Handler) uploadRobotAvatarFromSource(ctx context.Context, actorID uint, sourceURL string) (string, error) {
sourceURL = strings.TrimSpace(sourceURL)
if sourceURL == "" {
return "", fmt.Errorf("robot avatar source url is empty")
}
if h.robotAvatar.uploader == nil {
return "", fmt.Errorf("robot avatar cos uploader is not configured")
}
data, contentType, err := h.downloadRobotAvatar(ctx, sourceURL)
if err != nil {
return "", err
}
// 机器人头像先进入后台 COS 前缀再写入用户资料App 端只依赖自有 CDN URL不直接依赖第三方头像站。
key := buildRobotAvatarObjectKey(h.robotAvatar.objectPrefix, actorID, contentType, time.Now().UTC())
return h.robotAvatar.uploader.PutObject(ctx, key, bytes.NewReader(data), int64(len(data)), contentType)
}
func (h *Handler) downloadRobotAvatar(ctx context.Context, sourceURL string) ([]byte, string, error) {
if !validRobotAvatarSourceURL(sourceURL) {
return nil, "", fmt.Errorf("robot avatar source url is invalid")
}
client := h.robotAvatar.httpClient
if client == nil {
client = &http.Client{Timeout: robotAvatarDownloadTimeout}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return nil, "", fmt.Errorf("build robot avatar request: %w", err)
}
req.Header.Set("User-Agent", "hyapp-admin-server/robot-avatar")
resp, err := client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("download robot avatar: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, "", fmt.Errorf("download robot avatar returned status %d", resp.StatusCode)
}
if resp.ContentLength > robotAvatarMaxBytes {
return nil, "", fmt.Errorf("robot avatar is too large")
}
data, err := io.ReadAll(io.LimitReader(resp.Body, robotAvatarMaxBytes+1))
if err != nil {
return nil, "", fmt.Errorf("read robot avatar: %w", err)
}
if len(data) == 0 || len(data) > robotAvatarMaxBytes {
return nil, "", fmt.Errorf("robot avatar size is invalid")
}
sniffSize := len(data)
if sniffSize > robotAvatarSniffBytes {
sniffSize = robotAvatarSniffBytes
}
contentType, ok := normalizeRobotAvatarContentType(http.DetectContentType(data[:sniffSize]), resp.Header.Get("Content-Type"))
if !ok {
return nil, "", fmt.Errorf("robot avatar content type is unsupported")
}
return data, contentType, nil
}
func validRobotAvatarSourceURL(raw string) bool {
u, err := url.ParseRequestURI(strings.TrimSpace(raw))
if err != nil {
return false
}
return (u.Scheme == "https" || u.Scheme == "http") && strings.TrimSpace(u.Host) != ""
}
func normalizeRobotAvatarContentType(values ...string) (string, bool) {
for _, value := range values {
contentType := strings.ToLower(strings.TrimSpace(strings.Split(value, ";")[0]))
switch contentType {
case "image/jpeg", "image/jpg":
return "image/jpeg", true
case "image/png":
return "image/png", true
case "image/webp":
return "image/webp", true
}
}
return "", false
}
func buildRobotAvatarObjectKey(objectPrefix string, actorID uint, contentType string, now time.Time) string {
extension := ".jpg"
switch contentType {
case "image/png":
extension = ".png"
case "image/webp":
extension = ".webp"
}
return fmt.Sprintf(
"%s/game-robots/avatars/%d/%s/%s%s",
cleanRobotAvatarObjectPrefix(objectPrefix),
actorID,
now.UTC().Format("20060102"),
idgen.New("avatar"),
extension,
)
}
func cleanRobotAvatarObjectPrefix(value string) string {
value = strings.Trim(strings.TrimSpace(value), "/")
if value == "" {
return "admin"
}
return value
}

View File

@ -1,133 +0,0 @@
package gamemanagement
import (
"context"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode"
)
type fakeRobotAvatarUploader struct {
key string
contentType string
data []byte
}
func (u *fakeRobotAvatarUploader) PutObject(_ context.Context, key string, reader io.Reader, _ int64, contentType string) (string, error) {
data, err := io.ReadAll(reader)
if err != nil {
return "", err
}
u.key = key
u.contentType = contentType
u.data = data
return "https://media.example.com/" + key, nil
}
func TestUploadRobotAvatarFromSourceUploadsDownloadedImageToCOS(t *testing.T) {
imageBytes, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=")
if err != nil {
t.Fatalf("decode png: %v", err)
}
avatarServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(imageBytes)
}))
defer avatarServer.Close()
uploader := &fakeRobotAvatarUploader{}
handler := New(nil, nil, nil, WithRobotAvatarUploader(uploader, "admin"), WithRobotAvatarHTTPClient(avatarServer.Client()))
got, err := handler.uploadRobotAvatarFromSource(t.Context(), 42, avatarServer.URL+"/avatar.png")
if err != nil {
t.Fatalf("uploadRobotAvatarFromSource failed: %v", err)
}
if !strings.HasPrefix(got, "https://media.example.com/admin/game-robots/avatars/42/") {
t.Fatalf("uploaded url = %q", got)
}
if uploader.contentType != "image/png" {
t.Fatalf("contentType = %q, want image/png", uploader.contentType)
}
if string(uploader.data) != string(imageBytes) {
t.Fatalf("uploaded bytes mismatch")
}
}
func TestRobotNicknameUsesSingleLanguagePresetWithoutDigits(t *testing.T) {
// 预设池是批量创建机器人的基础数据,数量变化会直接影响后台一次创建 1000 个机器人时的去重效果。
if len(defaultArabicRobotNicknames) != robotPresetTargetCount {
t.Fatalf("arabic nickname preset count = %d, want %d", len(defaultArabicRobotNicknames), robotPresetTargetCount)
}
if len(defaultEnglishRobotNicknames) != robotPresetTargetCount {
t.Fatalf("english nickname preset count = %d, want %d", len(defaultEnglishRobotNicknames), robotPresetTargetCount)
}
if len(defaultRobotAvatarSources) != robotPresetTargetCount {
t.Fatalf("avatar preset count = %d, want %d", len(defaultRobotAvatarSources), robotPresetTargetCount)
}
if uniqueCount(defaultRobotAvatarSources) != robotPresetTargetCount {
t.Fatalf("avatar preset must keep %d unique source urls", robotPresetTargetCount)
}
englishNames := make([]string, 0, robotPresetTargetCount)
arabicNames := make([]string, 0, robotPresetTargetCount)
for index := 0; index < robotPresetTargetCount; index++ {
english := robotNickname(index, "english")
if !isPureEnglishNickname(english) {
t.Fatalf("english nickname[%d] = %q, want letters only", index, english)
}
englishNames = append(englishNames, english)
arabic := robotNickname(index, "arabic")
if !isPureArabicNickname(arabic) {
t.Fatalf("arabic nickname[%d] = %q, want arabic letters only", index, arabic)
}
arabicNames = append(arabicNames, arabic)
}
if uniqueCount(englishNames) != len(englishNames) {
t.Fatalf("english nickname presets and overflow combinations must keep %d unique names", robotPresetTargetCount)
}
if uniqueCount(arabicNames) != len(arabicNames) {
t.Fatalf("arabic nickname presets and overflow combinations must keep %d unique names", robotPresetTargetCount)
}
if got := robotNickname(robotPresetTargetCount, "english"); !isPureEnglishNickname(got) {
t.Fatalf("english overflow nickname = %q, want pure english combined name", got)
}
if got := robotNickname(robotPresetTargetCount, "arabic"); !isPureArabicNickname(got) {
t.Fatalf("arabic overflow nickname = %q, want pure arabic combined name", got)
}
}
func isPureEnglishNickname(value string) bool {
if value == "" {
return false
}
for _, item := range value {
if (item < 'A' || item > 'Z') && (item < 'a' || item > 'z') {
return false
}
}
return true
}
func isPureArabicNickname(value string) bool {
if value == "" {
return false
}
for _, item := range value {
if !unicode.Is(unicode.Arabic, item) {
return false
}
}
return true
}
func uniqueCount(values []string) int {
seen := make(map[string]struct{}, len(values))
for _, value := range values {
seen[value] = struct{}{}
}
return len(seen)
}

View File

@ -1,642 +1,22 @@
package gamemanagement
import (
"fmt"
"strings"
"unicode"
)
import "strings"
const (
robotNicknameLanguageArabic = "arabic"
robotNicknameLanguageEnglish = "english"
robotPresetTargetCount = 1000
defaultRobotGenerateCount = 20
maxRobotGenerateCount = 1000
defaultRobotGenerateCount = 20
maxRobotGenerateCount = 1000
)
var defaultRobotAvatarSources = []string{
"https://randomuser.me/api/portraits/men/0.jpg",
"https://randomuser.me/api/portraits/men/1.jpg",
"https://randomuser.me/api/portraits/men/2.jpg",
"https://randomuser.me/api/portraits/men/3.jpg",
"https://randomuser.me/api/portraits/men/4.jpg",
"https://randomuser.me/api/portraits/men/5.jpg",
"https://randomuser.me/api/portraits/men/6.jpg",
"https://randomuser.me/api/portraits/men/7.jpg",
"https://randomuser.me/api/portraits/men/8.jpg",
"https://randomuser.me/api/portraits/men/9.jpg",
"https://randomuser.me/api/portraits/men/10.jpg",
"https://randomuser.me/api/portraits/men/11.jpg",
"https://randomuser.me/api/portraits/men/12.jpg",
"https://randomuser.me/api/portraits/men/13.jpg",
"https://randomuser.me/api/portraits/men/14.jpg",
"https://randomuser.me/api/portraits/men/15.jpg",
"https://randomuser.me/api/portraits/men/16.jpg",
"https://randomuser.me/api/portraits/men/17.jpg",
"https://randomuser.me/api/portraits/men/18.jpg",
"https://randomuser.me/api/portraits/men/19.jpg",
"https://randomuser.me/api/portraits/men/20.jpg",
"https://randomuser.me/api/portraits/men/21.jpg",
"https://randomuser.me/api/portraits/men/22.jpg",
"https://randomuser.me/api/portraits/men/23.jpg",
"https://randomuser.me/api/portraits/men/24.jpg",
"https://randomuser.me/api/portraits/men/25.jpg",
"https://randomuser.me/api/portraits/men/26.jpg",
"https://randomuser.me/api/portraits/men/27.jpg",
"https://randomuser.me/api/portraits/men/28.jpg",
"https://randomuser.me/api/portraits/men/29.jpg",
"https://randomuser.me/api/portraits/men/30.jpg",
"https://randomuser.me/api/portraits/men/31.jpg",
"https://randomuser.me/api/portraits/men/32.jpg",
"https://randomuser.me/api/portraits/men/33.jpg",
"https://randomuser.me/api/portraits/men/34.jpg",
"https://randomuser.me/api/portraits/men/35.jpg",
"https://randomuser.me/api/portraits/men/36.jpg",
"https://randomuser.me/api/portraits/men/37.jpg",
"https://randomuser.me/api/portraits/men/38.jpg",
"https://randomuser.me/api/portraits/men/39.jpg",
"https://randomuser.me/api/portraits/men/40.jpg",
"https://randomuser.me/api/portraits/men/41.jpg",
"https://randomuser.me/api/portraits/men/42.jpg",
"https://randomuser.me/api/portraits/men/43.jpg",
"https://randomuser.me/api/portraits/men/44.jpg",
"https://randomuser.me/api/portraits/men/45.jpg",
"https://randomuser.me/api/portraits/men/46.jpg",
"https://randomuser.me/api/portraits/men/47.jpg",
"https://randomuser.me/api/portraits/men/48.jpg",
"https://randomuser.me/api/portraits/men/49.jpg",
"https://randomuser.me/api/portraits/men/50.jpg",
"https://randomuser.me/api/portraits/men/51.jpg",
"https://randomuser.me/api/portraits/men/52.jpg",
"https://randomuser.me/api/portraits/men/53.jpg",
"https://randomuser.me/api/portraits/men/54.jpg",
"https://randomuser.me/api/portraits/men/55.jpg",
"https://randomuser.me/api/portraits/men/56.jpg",
"https://randomuser.me/api/portraits/men/57.jpg",
"https://randomuser.me/api/portraits/men/58.jpg",
"https://randomuser.me/api/portraits/men/59.jpg",
"https://randomuser.me/api/portraits/men/60.jpg",
"https://randomuser.me/api/portraits/men/61.jpg",
"https://randomuser.me/api/portraits/men/62.jpg",
"https://randomuser.me/api/portraits/men/63.jpg",
"https://randomuser.me/api/portraits/men/64.jpg",
"https://randomuser.me/api/portraits/men/65.jpg",
"https://randomuser.me/api/portraits/men/66.jpg",
"https://randomuser.me/api/portraits/men/67.jpg",
"https://randomuser.me/api/portraits/men/68.jpg",
"https://randomuser.me/api/portraits/men/69.jpg",
"https://randomuser.me/api/portraits/men/70.jpg",
"https://randomuser.me/api/portraits/men/71.jpg",
"https://randomuser.me/api/portraits/men/72.jpg",
"https://randomuser.me/api/portraits/men/73.jpg",
"https://randomuser.me/api/portraits/men/74.jpg",
"https://randomuser.me/api/portraits/men/75.jpg",
"https://randomuser.me/api/portraits/men/76.jpg",
"https://randomuser.me/api/portraits/men/77.jpg",
"https://randomuser.me/api/portraits/men/78.jpg",
"https://randomuser.me/api/portraits/men/79.jpg",
"https://randomuser.me/api/portraits/men/80.jpg",
"https://randomuser.me/api/portraits/men/81.jpg",
"https://randomuser.me/api/portraits/men/82.jpg",
"https://randomuser.me/api/portraits/men/83.jpg",
"https://randomuser.me/api/portraits/men/84.jpg",
"https://randomuser.me/api/portraits/men/85.jpg",
"https://randomuser.me/api/portraits/men/86.jpg",
"https://randomuser.me/api/portraits/men/87.jpg",
"https://randomuser.me/api/portraits/men/88.jpg",
"https://randomuser.me/api/portraits/men/89.jpg",
"https://randomuser.me/api/portraits/men/90.jpg",
"https://randomuser.me/api/portraits/men/91.jpg",
"https://randomuser.me/api/portraits/men/92.jpg",
"https://randomuser.me/api/portraits/men/93.jpg",
"https://randomuser.me/api/portraits/men/94.jpg",
"https://randomuser.me/api/portraits/men/95.jpg",
"https://randomuser.me/api/portraits/men/96.jpg",
"https://randomuser.me/api/portraits/men/97.jpg",
"https://randomuser.me/api/portraits/men/98.jpg",
"https://randomuser.me/api/portraits/men/99.jpg",
"https://randomuser.me/api/portraits/women/0.jpg",
"https://randomuser.me/api/portraits/women/1.jpg",
"https://randomuser.me/api/portraits/women/2.jpg",
"https://randomuser.me/api/portraits/women/3.jpg",
"https://randomuser.me/api/portraits/women/4.jpg",
"https://randomuser.me/api/portraits/women/5.jpg",
"https://randomuser.me/api/portraits/women/6.jpg",
"https://randomuser.me/api/portraits/women/7.jpg",
"https://randomuser.me/api/portraits/women/8.jpg",
"https://randomuser.me/api/portraits/women/9.jpg",
"https://randomuser.me/api/portraits/women/10.jpg",
"https://randomuser.me/api/portraits/women/11.jpg",
"https://randomuser.me/api/portraits/women/12.jpg",
"https://randomuser.me/api/portraits/women/13.jpg",
"https://randomuser.me/api/portraits/women/14.jpg",
"https://randomuser.me/api/portraits/women/15.jpg",
"https://randomuser.me/api/portraits/women/16.jpg",
"https://randomuser.me/api/portraits/women/17.jpg",
"https://randomuser.me/api/portraits/women/18.jpg",
"https://randomuser.me/api/portraits/women/19.jpg",
"https://randomuser.me/api/portraits/women/20.jpg",
"https://randomuser.me/api/portraits/women/21.jpg",
"https://randomuser.me/api/portraits/women/22.jpg",
"https://randomuser.me/api/portraits/women/23.jpg",
"https://randomuser.me/api/portraits/women/24.jpg",
"https://randomuser.me/api/portraits/women/25.jpg",
"https://randomuser.me/api/portraits/women/26.jpg",
"https://randomuser.me/api/portraits/women/27.jpg",
"https://randomuser.me/api/portraits/women/28.jpg",
"https://randomuser.me/api/portraits/women/29.jpg",
"https://randomuser.me/api/portraits/women/30.jpg",
"https://randomuser.me/api/portraits/women/31.jpg",
"https://randomuser.me/api/portraits/women/32.jpg",
"https://randomuser.me/api/portraits/women/33.jpg",
"https://randomuser.me/api/portraits/women/34.jpg",
"https://randomuser.me/api/portraits/women/35.jpg",
"https://randomuser.me/api/portraits/women/36.jpg",
"https://randomuser.me/api/portraits/women/37.jpg",
"https://randomuser.me/api/portraits/women/38.jpg",
"https://randomuser.me/api/portraits/women/39.jpg",
"https://randomuser.me/api/portraits/women/40.jpg",
"https://randomuser.me/api/portraits/women/41.jpg",
"https://randomuser.me/api/portraits/women/42.jpg",
"https://randomuser.me/api/portraits/women/43.jpg",
"https://randomuser.me/api/portraits/women/44.jpg",
"https://randomuser.me/api/portraits/women/45.jpg",
"https://randomuser.me/api/portraits/women/46.jpg",
"https://randomuser.me/api/portraits/women/47.jpg",
"https://randomuser.me/api/portraits/women/48.jpg",
"https://randomuser.me/api/portraits/women/49.jpg",
"https://randomuser.me/api/portraits/women/50.jpg",
"https://randomuser.me/api/portraits/women/51.jpg",
"https://randomuser.me/api/portraits/women/52.jpg",
"https://randomuser.me/api/portraits/women/53.jpg",
"https://randomuser.me/api/portraits/women/54.jpg",
"https://randomuser.me/api/portraits/women/55.jpg",
"https://randomuser.me/api/portraits/women/56.jpg",
"https://randomuser.me/api/portraits/women/57.jpg",
"https://randomuser.me/api/portraits/women/58.jpg",
"https://randomuser.me/api/portraits/women/59.jpg",
"https://randomuser.me/api/portraits/women/60.jpg",
"https://randomuser.me/api/portraits/women/61.jpg",
"https://randomuser.me/api/portraits/women/62.jpg",
"https://randomuser.me/api/portraits/women/63.jpg",
"https://randomuser.me/api/portraits/women/64.jpg",
"https://randomuser.me/api/portraits/women/65.jpg",
"https://randomuser.me/api/portraits/women/66.jpg",
"https://randomuser.me/api/portraits/women/67.jpg",
"https://randomuser.me/api/portraits/women/68.jpg",
"https://randomuser.me/api/portraits/women/69.jpg",
"https://randomuser.me/api/portraits/women/70.jpg",
"https://randomuser.me/api/portraits/women/71.jpg",
"https://randomuser.me/api/portraits/women/72.jpg",
"https://randomuser.me/api/portraits/women/73.jpg",
"https://randomuser.me/api/portraits/women/74.jpg",
"https://randomuser.me/api/portraits/women/75.jpg",
"https://randomuser.me/api/portraits/women/76.jpg",
"https://randomuser.me/api/portraits/women/77.jpg",
"https://randomuser.me/api/portraits/women/78.jpg",
"https://randomuser.me/api/portraits/women/79.jpg",
"https://randomuser.me/api/portraits/women/80.jpg",
"https://randomuser.me/api/portraits/women/81.jpg",
"https://randomuser.me/api/portraits/women/82.jpg",
"https://randomuser.me/api/portraits/women/83.jpg",
"https://randomuser.me/api/portraits/women/84.jpg",
"https://randomuser.me/api/portraits/women/85.jpg",
"https://randomuser.me/api/portraits/women/86.jpg",
"https://randomuser.me/api/portraits/women/87.jpg",
"https://randomuser.me/api/portraits/women/88.jpg",
"https://randomuser.me/api/portraits/women/89.jpg",
"https://randomuser.me/api/portraits/women/90.jpg",
"https://randomuser.me/api/portraits/women/91.jpg",
"https://randomuser.me/api/portraits/women/92.jpg",
"https://randomuser.me/api/portraits/women/93.jpg",
"https://randomuser.me/api/portraits/women/94.jpg",
"https://randomuser.me/api/portraits/women/95.jpg",
"https://randomuser.me/api/portraits/women/96.jpg",
"https://randomuser.me/api/portraits/women/97.jpg",
"https://randomuser.me/api/portraits/women/98.jpg",
"https://randomuser.me/api/portraits/women/99.jpg",
}
var defaultArabicRobotNicknames = []string{
"أمير",
"سيف",
"مالك",
"فارس",
"ليث",
"بدر",
"ماهر",
"رامي",
"كريم",
"سامي",
"عمر",
"يوسف",
"آدم",
"ريان",
"زيد",
"نور",
"ياسين",
"أنس",
"خالد",
"طارق",
"حازم",
"نادر",
"وسيم",
"جواد",
"مروان",
"إياد",
"نبيل",
"هلال",
"تميم",
"سلمان",
"راشد",
"جاسم",
"ماجد",
"سلطان",
"عادل",
"هاني",
"بلال",
"يزن",
"غيث",
"قصي",
"سليم",
"ريان_الليل",
"نجم",
"ملك_اللعبة",
"صقر",
"ذيب",
"أسد",
"الزعيم",
"القناص",
"الأسطورة",
"ظل",
"برق",
"رعد",
"موج",
"نمر",
"شهاب",
"فهد",
"نايف",
"نوف",
"ليان",
"ريم",
"سارة",
"مريم",
"لينا",
"هنا",
"جود",
"تالا",
"دانا",
"لارا",
"ياسمين",
"نوران",
"رؤى",
"سما",
"ملك",
"جنى",
"آية",
"ندى",
"فرح",
"أمل",
"هدى",
"سلمى",
"ليلى",
"رنا",
"نورا",
"ديما",
"مايا",
"ميس",
"رهف",
"شهد",
"غلا",
"لولو",
"وردة",
"قمر",
"نجمة",
"لؤلؤة",
"الملكة",
"أميرة",
"روح",
"حلم",
"بسمة",
}
var defaultEnglishRobotNicknames = []string{
"Alex",
"Jordan",
"Taylor",
"Morgan",
"Riley",
"Nova",
"Phoenix",
"Hunter",
"Luna",
"Mia",
"Noah",
"Liam",
"Ava",
"Emma",
"Oliver",
"Elijah",
"James",
"William",
"Benjamin",
"Lucas",
"Mason",
"Ethan",
"Logan",
"Jackson",
"Levi",
"Sebastian",
"Mateo",
"Jack",
"Owen",
"Theodore",
"Aiden",
"Samuel",
"Joseph",
"John",
"David",
"Wyatt",
"Matthew",
"Luke",
"Asher",
"Carter",
"Julian",
"Grayson",
"Leo",
"Jayden",
"Gabriel",
"Isaac",
"Lincoln",
"Anthony",
"Hudson",
"Dylan",
"Ezra",
"Thomas",
"Charles",
"Christopher",
"Jaxon",
"Maverick",
"Josiah",
"Isaiah",
"Andrew",
"Elias",
"Joshua",
"Nathan",
"Caleb",
"Ryan",
"Adrian",
"Miles",
"Eli",
"Nolan",
"Christian",
"Aaron",
"Cameron",
"Ezekiel",
"Colton",
"Luca",
"Landon",
"Hannah",
"Sofia",
"Isabella",
"Amelia",
"Olivia",
"Charlotte",
"Harper",
"Evelyn",
"Abigail",
"Ella",
"Scarlett",
"Grace",
"Chloe",
"Victoria",
"Aria",
"Penelope",
"Layla",
"Zoey",
"Nora",
"Lily",
"Ellie",
"Violet",
"Aurora",
"Stella",
"Hazel",
}
var defaultEnglishRobotNicknameSuffixes = []string{
"River",
"Sky",
"Star",
"Moon",
"Storm",
"Stone",
"Vale",
"Lake",
"Light",
"Wave",
"Cloud",
"Flame",
"Forest",
"Dream",
"Shadow",
"Bloom",
"Quest",
"Spark",
"North",
"South",
"East",
"West",
"Dawn",
"Dusk",
"Peak",
}
var defaultArabicRobotNicknameSuffixes = []string{
"نور",
"قمر",
"نجم",
"سيف",
"بحر",
"مجد",
"حلم",
"روح",
"ورد",
"برق",
"رعد",
"شمس",
"ليل",
"فجر",
"درر",
"موج",
"فرح",
"أمل",
"غيم",
"ندى",
"سحر",
"لؤلؤ",
"ذهب",
"عز",
"وفا",
}
func init() {
defaultRobotAvatarSources = expandRobotAvatarSources(defaultRobotAvatarSources, robotPresetTargetCount)
defaultEnglishRobotNicknames = expandRobotNicknamePool(defaultEnglishRobotNicknames, defaultEnglishRobotNicknameSuffixes, robotPresetTargetCount, robotNicknameLanguageEnglish)
defaultArabicRobotNicknames = expandRobotNicknamePool(defaultArabicRobotNicknames, defaultArabicRobotNicknameSuffixes, robotPresetTargetCount, robotNicknameLanguageArabic)
}
func expandRobotAvatarSources(base []string, target int) []string {
// 头像池必须是固定顺序,不能运行时随机;后台批量生成 1000 个时按 index 取值,重复请求也能得到同一批源图。
if target <= 0 {
return nil
}
result := make([]string, 0, target)
seen := make(map[string]struct{}, target)
add := func(source string) bool {
source = strings.TrimSpace(source)
if source == "" {
return len(result) >= target
}
if _, ok := seen[source]; ok {
return len(result) >= target
}
seen[source] = struct{}{}
result = append(result, source)
return len(result) >= target
}
for _, source := range base {
if add(source) {
return result
}
}
for index := 1; len(result) < target; index++ {
// DiceBear 用 seed 生成确定性头像URL 唯一且返回 image/png这里只存源 URL真正头像仍会先转存到后台 COS。
add(fmt.Sprintf("https://api.dicebear.com/9.x/thumbs/png?seed=hyapp-robot-%04d", index))
}
return result
}
func expandRobotNicknamePool(base []string, suffixes []string, target int, language string) []string {
// 名称池也保持固定顺序:先保留已有预设名,再用同语言后缀组合扩容,避免数字后缀和英阿混排进入用户资料。
if target <= 0 {
return nil
}
result := make([]string, 0, target)
seen := make(map[string]struct{}, target)
add := func(name string) bool {
name = sanitizeRobotNickname(name, language)
if name == "" {
return len(result) >= target
}
if _, ok := seen[name]; ok {
return len(result) >= target
}
seen[name] = struct{}{}
result = append(result, name)
return len(result) >= target
}
for _, name := range base {
if add(name) {
return result
}
}
cleanBase := append([]string(nil), result...)
cleanSuffixes := make([]string, 0, len(suffixes))
for _, suffix := range suffixes {
suffix = sanitizeRobotNickname(suffix, language)
if suffix != "" {
cleanSuffixes = append(cleanSuffixes, suffix)
}
}
for _, name := range cleanBase {
for _, suffix := range cleanSuffixes {
if add(name + suffix) {
return result
}
}
}
for offset := 1; len(result) < target; offset++ {
for index, name := range cleanBase {
next := cleanBase[(index+offset)%len(cleanBase)]
if add(name + next) {
return result
}
}
}
return result
}
func normalizeRobotNicknameLanguage(value string) string {
// 后台只开放英语和阿拉伯语两类昵称池;未知值回退英语,避免历史请求继续落到英阿混合池。
func robotAccountLanguage(value string) string {
// likei 昵称和头像是资料事实来源;这里的 language 只保留为账号元数据,兼容后台旧请求传入 ar/en 的使用习惯。
switch strings.ToLower(strings.TrimSpace(value)) {
case "ar", "arabic", "arab":
return robotNicknameLanguageArabic
case "en", "english":
return robotNicknameLanguageEnglish
default:
return robotNicknameLanguageEnglish
}
}
func robotAccountLanguage(nicknameLanguage string) string {
// 用户资料里的 language 跟昵称池保持一致,后续客户端或运营筛选机器人时不用再反推昵称字符集。
if normalizeRobotNicknameLanguage(nicknameLanguage) == robotNicknameLanguageArabic {
return "ar"
case "en", "english":
return "en"
default:
return "en"
}
return "en"
}
func robotNickname(index int, nicknameLanguage string) string {
// 机器人昵称只从单一语言池取值,不能再拼随机 hex否则会出现数字、下划线和英阿混排。
language := normalizeRobotNicknameLanguage(nicknameLanguage)
if language == robotNicknameLanguageArabic {
return robotNicknameFromPool(index, defaultArabicRobotNicknames, "لاعب", language)
}
return robotNicknameFromPool(index, defaultEnglishRobotNicknames, "Player", language)
}
func robotNicknameFromPool(index int, pool []string, fallback string, language string) string {
// 前 1000 个直接使用固定预设池;超过池大小时仍用同语言两个预设名拼接,保证手工超界调用也不带数字后缀。
if index < 0 {
index = 0
}
if len(pool) == 0 {
return fallback
}
base := sanitizeRobotNickname(pool[index%len(pool)], language)
if base == "" {
base = fallback
}
if index < len(pool) {
return base
}
next := sanitizeRobotNickname(pool[(index%len(pool)+1)%len(pool)], language)
if next == "" || next == base {
next = sanitizeRobotNickname(pool[(index+index/len(pool)+2)%len(pool)], language)
}
if next == "" || next == base {
return base
}
return base + next
}
func sanitizeRobotNickname(value string, language string) string {
// 预设里可能有旧的分隔符或非目标语言字符;写库前统一剔除,保证“英语纯英语、阿拉伯语纯阿拉伯语”。
var builder strings.Builder
for _, item := range strings.TrimSpace(value) {
switch language {
case robotNicknameLanguageArabic:
if unicode.Is(unicode.Arabic, item) {
builder.WriteRune(item)
}
default:
if (item >= 'A' && item <= 'Z') || (item >= 'a' && item <= 'z') {
builder.WriteRune(item)
}
}
}
return builder.String()
}
func robotGender(index int, requested string) string {
@ -649,13 +29,3 @@ func robotGender(index int, requested string) string {
}
return "female"
}
func robotAvatarSource(index int, requested []string) string {
if source := pickString(requested, index); source != "" {
return source
}
if len(defaultRobotAvatarSources) == 0 {
return ""
}
return defaultRobotAvatarSources[index%len(defaultRobotAvatarSources)]
}

View File

@ -0,0 +1,101 @@
package gamemanagement
import (
"context"
"database/sql"
"fmt"
"net/url"
"strings"
"unicode/utf8"
)
type RobotProfile struct {
Nickname string
Avatar 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 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
}

View File

@ -0,0 +1,106 @@
package gamemanagement
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"hyapp-admin-server/internal/integration/gameclient"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
gamev1 "hyapp.local/api/proto/game/v1"
"github.com/gin-gonic/gin"
)
type fakeRobotProfileSource struct {
profiles []RobotProfile
requested int
}
func (s *fakeRobotProfileSource) RandomRobotProfiles(_ context.Context, count int) ([]RobotProfile, error) {
s.requested = count
return s.profiles[:count], nil
}
type fakeRobotUserClient struct {
userclient.Client
created []userclient.QuickCreateAccountRequest
}
func (c *fakeRobotUserClient) QuickCreateAccount(_ context.Context, req userclient.QuickCreateAccountRequest) (*userclient.QuickCreateAccountResult, error) {
c.created = append(c.created, req)
return &userclient.QuickCreateAccountResult{UserID: int64(9000 + len(c.created)), DisplayUserID: "robot"}, nil
}
func (c *fakeRobotUserClient) BatchGetUsers(_ context.Context, _ userclient.BatchGetUsersRequest) (map[int64]*userclient.User, error) {
users := make(map[int64]*userclient.User, len(c.created))
for index, created := range c.created {
userID := int64(9001 + index)
users[userID] = &userclient.User{UserID: userID, DisplayUserID: "robot", Username: created.Username, Avatar: created.Avatar}
}
return users, nil
}
type fakeRobotGameClient struct {
gameclient.Client
registered []int64
}
func (c *fakeRobotGameClient) RegisterDiceRobots(_ context.Context, req *gamev1.RegisterDiceRobotsRequest) (*gamev1.RegisterDiceRobotsResponse, error) {
c.registered = append(c.registered, req.GetUserIds()...)
robots := make([]*gamev1.DiceRobot, 0, len(req.GetUserIds()))
for _, userID := range req.GetUserIds() {
robots = append(robots, &gamev1.DiceRobot{AppCode: "lalu", GameId: "dice", UserId: userID, Status: "active"})
}
return &gamev1.RegisterDiceRobotsResponse{Robots: robots}, nil
}
func TestGenerateDiceRobotsUsesLikeiProfiles(t *testing.T) {
gin.SetMode(gin.TestMode)
profiles := &fakeRobotProfileSource{profiles: []RobotProfile{
{Nickname: "likei-one", Avatar: "https://media.haiyihy.com/avatar/one.jpg"},
{Nickname: "likei-two", Avatar: "https://media.haiyihy.com/avatar/two.jpg"},
}}
users := &fakeRobotUserClient{}
games := &fakeRobotGameClient{}
handler := New(games, users, nil, WithRobotProfileSource(profiles))
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Set(middleware.ContextRequestID, "req-robot-test")
c.Set(middleware.ContextUserID, uint(7))
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/games/self/dice/robots:generate", bytes.NewBufferString(`{
"count": 2,
"nicknameLanguage": "ar",
"avatarUrls": ["https://randomuser.me/api/portraits/men/1.jpg"],
"country": "SA"
}`))
c.Request.Header.Set("Content-Type", "application/json")
handler.GenerateDiceRobots(c)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
if profiles.requested != 2 {
t.Fatalf("profile source requested %d profiles, want 2", profiles.requested)
}
if len(users.created) != 2 {
t.Fatalf("created users = %d, want 2", len(users.created))
}
if users.created[0].Username != "likei-one" || users.created[0].Avatar != "https://media.haiyihy.com/avatar/one.jpg" {
t.Fatalf("first robot profile = %#v", users.created[0])
}
if users.created[1].Username != "likei-two" || users.created[1].Avatar != "https://media.haiyihy.com/avatar/two.jpg" {
t.Fatalf("second robot profile = %#v", users.created[1])
}
if users.created[0].Language != "ar" || users.created[0].Source != "game_robot" || users.created[0].Country != "SA" {
t.Fatalf("robot account metadata = %#v", users.created[0])
}
if len(games.registered) != 2 || games.registered[0] != 9001 || games.registered[1] != 9002 {
t.Fatalf("registered user ids = %#v", games.registered)
}
}

View File

@ -118,41 +118,43 @@ func (h *Handler) GenerateDiceRobots(c *gin.Context) {
if req.Count > maxRobotGenerateCount {
req.Count = maxRobotGenerateCount
}
nicknameLanguage := normalizeRobotNicknameLanguage(req.NicknameLanguage)
country := strings.TrimSpace(req.Country)
if country == "" {
country = "SA"
}
if h.robotProfiles == nil {
response.ServerError(c, "likei 机器人资料源未配置")
return
}
// 新机器人资料只从 likei 线上用户库随机抽取;后台不再使用本地昵称池、随机头像站或 COS 转存,
// 这样每次批量创建都面对真实用户资料池,避免少量固定预设反复出现在匹配列表里。
profiles, err := h.robotProfiles.RandomRobotProfiles(c.Request.Context(), int(req.Count))
if err != nil {
response.BadRequest(c, "获取 likei 机器人资料失败: "+err.Error())
return
}
if len(profiles) < int(req.Count) {
response.BadRequest(c, "likei 机器人资料数量不足")
return
}
userIDs := make([]int64, 0, req.Count)
requestID := middleware.CurrentRequestID(c)
actorID := middleware.CurrentUserID(c)
avatarCache := make(map[string]string)
accountLanguage := robotAccountLanguage(req.NicknameLanguage)
for index := int32(0); index < req.Count; index++ {
// 批量机器人必须一次性写齐完整资料;同一请求内相同头像源只转存一次,避免 200 个账号重复下载上传同一张图。
avatarSource := robotAvatarSource(int(index), req.AvatarURLs)
avatarURL := avatarCache[avatarSource]
if avatarURL == "" {
var err error
avatarURL, err = h.uploadRobotAvatarFromSource(c.Request.Context(), actorID, avatarSource)
if err != nil {
response.BadRequest(c, "上传机器人头像失败: "+err.Error())
return
}
avatarCache[avatarSource] = avatarURL
}
profile := profiles[index]
created, err := h.user.QuickCreateAccount(c.Request.Context(), userclient.QuickCreateAccountRequest{
RequestID: requestID,
Caller: "admin-server",
Password: randomRobotPassword(),
Username: robotNickname(int(index), nicknameLanguage),
Avatar: avatarURL,
Username: profile.Nickname,
Avatar: profile.Avatar,
Gender: robotGender(int(index), req.Gender),
Country: country,
DeviceID: "game-robot-" + randomRobotHex(8),
Source: "game_robot",
InstallChannel: "admin",
Platform: "android",
Language: robotAccountLanguage(nicknameLanguage),
Language: accountLanguage,
Timezone: "UTC",
})
if err != nil {
@ -248,13 +250,6 @@ func randomRobotHex(size int) string {
return hex.EncodeToString(buf)
}
func pickString(values []string, index int) string {
if len(values) == 0 {
return ""
}
return strings.TrimSpace(values[index%len(values)])
}
func parseInt64Param(raw string) (int64, error) {
var value int64
_, err := fmt.Sscan(strings.TrimSpace(raw), &value)

View File

@ -325,10 +325,10 @@ CREATE TABLE IF NOT EXISTS game_self_game_robots (
used_count BIGINT NOT NULL DEFAULT 0 COMMENT '累计被匹配补位次数',
PRIMARY KEY(app_code, game_id, user_id),
KEY idx_game_robot_status(app_code, game_id, status, updated_at_ms, user_id),
KEY idx_game_robot_rotation(app_code, status, last_used_at_ms, used_count, user_id)
KEY idx_game_robot_random_pool(app_code, status, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏机器人登记表';
-- 机器人轮转依赖最近使用时间和累计使用次数;旧表补默认 0保证存量机器人优先进入第一轮冷却排序
-- 机器人使用时间和累计次数只用于后台观察随机命中分布;旧表补默认 0避免存量机器人资料读取失败
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_self_game_robots' AND COLUMN_NAME = 'last_used_at_ms') = 0,
'ALTER TABLE game_self_game_robots ADD COLUMN last_used_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''最近一次被匹配补位时间UTC epoch ms'' AFTER updated_at_ms',
@ -348,8 +348,17 @@ EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_self_game_robots' AND INDEX_NAME = 'idx_game_robot_rotation') = 0,
'ALTER TABLE game_self_game_robots ADD INDEX idx_game_robot_rotation (app_code, status, last_used_at_ms, used_count, user_id)',
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_self_game_robots' AND INDEX_NAME = 'idx_game_robot_rotation') > 0,
'ALTER TABLE game_self_game_robots DROP INDEX idx_game_robot_rotation',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_self_game_robots' AND INDEX_NAME = 'idx_game_robot_random_pool') = 0,
'ALTER TABLE game_self_game_robots ADD INDEX idx_game_robot_random_pool (app_code, status, user_id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;

View File

@ -265,7 +265,7 @@ func (r *Repository) CancelDiceMatch(ctx context.Context, appCode string, matchI
return r.GetDiceMatch(ctx, app, match.MatchID)
}
// PickActiveDiceRobot 从当前 App 的通用机器人池里挑选一个未加入本局、冷却最久的机器人。
// PickActiveDiceRobot 从当前 App 的通用机器人池里完全随机挑选一个未加入本局的机器人。
// game_self_game_robots 仍保留 game_id 字段是为了兼容旧数据和后台接口,但机器人账号本身不再按 dice/rock 隔离;
// 只要同一个 app_code 下存在 active 登记,这个真实用户就可以给任意自研游戏补位。
func (r *Repository) PickActiveDiceRobot(ctx context.Context, appCode string, gameID string, matchID string, nowMs int64) (dicedomain.Robot, error) {
@ -279,8 +279,9 @@ func (r *Repository) PickActiveDiceRobot(ctx context.Context, appCode string, ga
defer func() { _ = tx.Rollback() }()
var robot dicedomain.Robot
// 候选行按“最久未使用、使用次数最少、user_id 最小”稳定排序,并用 SKIP LOCKED 避开其他正在补位的事务。
// 这样少量连续匹配会先把机器人池轮一遍;并发匹配也不会在同一瞬间都拿到同一条机器人登记。
// 用户要求“100 个机器人就是 100 选 1”因此候选池只过滤 app、启用状态和本局已加入机器人
// last_used_at_ms/used_count 只做后台观察和排查,不参与排序,避免冷却或轮转把随机池缩小成少数候选。
// SKIP LOCKED 让并发补位跳过其它事务已经锁住的候选行,减少多个等待局同一瞬间抢到同一个机器人。
err = tx.QueryRowContext(ctx,
`SELECT r.app_code, ? AS game_id, r.user_id, ? AS status,
r.created_by_admin_id, r.created_at_ms, r.updated_at_ms,
@ -291,7 +292,7 @@ func (r *Repository) PickActiveDiceRobot(ctx context.Context, appCode string, ga
SELECT 1 FROM game_dice_participants p
WHERE p.app_code = r.app_code AND p.match_id = ? AND p.user_id = r.user_id
)
ORDER BY r.last_used_at_ms ASC, r.used_count ASC, r.user_id ASC
ORDER BY RAND()
LIMIT 1
FOR UPDATE SKIP LOCKED`,
gameID, dicedomain.RobotStatusActive, app, dicedomain.RobotStatusActive, matchID,
@ -306,7 +307,7 @@ func (r *Repository) PickActiveDiceRobot(ctx context.Context, appCode string, ga
return dicedomain.Robot{}, err
}
// 选中后立即写入轮转水位。即使后续补位并发失败,这个账号也会短暂进入冷却,避免多个等待局同时反复命中同一个机器人
// 选中后仍记录使用水位,方便后台观察机器人命中分布;水位不反向影响下一次抽取,下一局仍从完整可用池随机
res, err := tx.ExecContext(ctx,
`UPDATE game_self_game_robots
SET last_used_at_ms = ?, used_count = used_count + 1

View File

@ -233,7 +233,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
used_count BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY(app_code, game_id, user_id),
KEY idx_game_robot_status(app_code, game_id, status, updated_at_ms, user_id),
KEY idx_game_robot_rotation(app_code, status, last_used_at_ms, used_count, user_id)
KEY idx_game_robot_random_pool(app_code, status, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_callback_logs (
app_code VARCHAR(32) NOT NULL,
@ -395,12 +395,32 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureIndexDefinition(ctx, "game_dice_participants", "idx_game_dice_participant_status", []string{"app_code", "match_id", "status", "seat_no"}, "ALTER TABLE game_dice_participants ADD INDEX idx_game_dice_participant_status(app_code, match_id, status, seat_no)"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_self_game_robots", "idx_game_robot_rotation", []string{"app_code", "status", "last_used_at_ms", "used_count", "user_id"}, "ALTER TABLE game_self_game_robots ADD INDEX idx_game_robot_rotation(app_code, status, last_used_at_ms, used_count, user_id)"); err != nil {
if err := r.dropIndexIfExists(ctx, "game_self_game_robots", "idx_game_robot_rotation"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_self_game_robots", "idx_game_robot_random_pool", []string{"app_code", "status", "user_id"}, "ALTER TABLE game_self_game_robots ADD INDEX idx_game_robot_random_pool(app_code, status, user_id)"); err != nil {
return err
}
return nil
}
func (r *Repository) dropIndexIfExists(ctx context.Context, tableName string, indexName string) error {
var count int
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?`,
tableName, indexName,
).Scan(&count); err != nil {
return err
}
if count == 0 {
return nil
}
_, err := r.db.ExecContext(ctx, `ALTER TABLE `+tableName+` DROP INDEX `+indexName)
return err
}
func (r *Repository) ensureColumn(ctx context.Context, tableName string, columnName string, columnDDL string) error {
var count int
if err := r.db.QueryRowContext(ctx,

View File

@ -168,7 +168,7 @@ func TestLevelEventClaimKeyQueryUsesOrderIndexWithoutFilesort(t *testing.T) {
}
}
func TestPickActiveDiceRobotRotatesByCooldownBeforeReusing(t *testing.T) {
func TestPickActiveDiceRobotRandomlySamplesWholeActivePool(t *testing.T) {
caller := mysqlschema.CallerFile(t, 1)
schema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
@ -183,38 +183,39 @@ func TestPickActiveDiceRobotRotatesByCooldownBeforeReusing(t *testing.T) {
t.Cleanup(func() { _ = repo.Close() })
nowMS := int64(1780542672000)
userIDs := []int64{101, 102, 103, 104}
userIDs := []int64{101, 102}
for index, userID := range userIDs {
usedCount := int64(0)
if userID == 101 {
usedCount = 999
}
if _, err := repo.db.ExecContext(ctx,
`INSERT INTO game_self_game_robots (
app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms,
last_used_at_ms, used_count
) VALUES (?, ?, ?, ?, 7, ?, ?, 0, 0)`,
"lalu", dicedomain.DefaultGameID, userID, dicedomain.RobotStatusActive, nowMS+int64(index), nowMS+int64(index),
) VALUES (?, ?, ?, ?, 7, ?, ?, 0, ?)`,
"lalu", dicedomain.DefaultGameID, userID, dicedomain.RobotStatusActive, nowMS+int64(index), nowMS+int64(index), usedCount,
); err != nil {
t.Fatalf("insert robot %d failed: %v", userID, err)
}
}
for attempt, wantUserID := range userIDs {
robot, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, fmt.Sprintf("match_rotate_%d", attempt), nowMS+100+int64(attempt))
picked := map[int64]bool{}
for attempt := 0; attempt < 48; attempt++ {
robot, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, fmt.Sprintf("match_random_%d", attempt), nowMS+100+int64(attempt))
if err != nil {
t.Fatalf("pick active dice robot failed: %v", err)
}
if robot.UserID != wantUserID {
t.Fatalf("rotation pick %d mismatch: got %+v want user_id=%d", attempt, robot, wantUserID)
if !containsInt64(userIDs, robot.UserID) {
t.Fatalf("random pick %d returned unknown robot: %+v", attempt, robot)
}
if robot.LastUsedAtMS != nowMS+100+int64(attempt) || robot.UsedCount != 1 {
t.Fatalf("picked robot should return updated cooldown fields, got %+v", robot)
picked[robot.UserID] = true
if robot.LastUsedAtMS != nowMS+100+int64(attempt) {
t.Fatalf("picked robot should return updated usage time, got %+v", robot)
}
}
reused, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, "match_rotate_reuse", nowMS+200)
if err != nil {
t.Fatalf("pick reusable dice robot failed: %v", err)
}
if reused.UserID != userIDs[0] || reused.UsedCount != 2 {
t.Fatalf("after every robot has cooled once, rotation should reuse the oldest robot first, got %+v", reused)
if len(picked) != len(userIDs) {
t.Fatalf("full random must keep high used_count robots in the candidate pool, got picked=%+v", picked)
}
if _, err := repo.db.ExecContext(ctx,