Use likei profiles for game robots
This commit is contained in:
parent
84bba92611
commit
d2dbc88e5f
@ -23,9 +23,9 @@
|
|||||||
|
|
||||||
## 创建流程
|
## 创建流程
|
||||||
|
|
||||||
1. 运营在后台选择创建数量和昵称语言。
|
1. 运营在后台选择创建数量、国家、性别和语言元数据。
|
||||||
2. admin-server 下载并转存头像。
|
2. admin-server 从 likei 线上 `user_base_info` 随机抽取正常用户的 `user_nickname` 和 `user_avatar`。
|
||||||
3. admin-server 调 user-service 创建 `source=game_robot` 的资料号。
|
3. admin-server 调 user-service 创建 `source=game_robot` 的资料号,昵称和头像直接使用 likei 资料。
|
||||||
4. user-service 写入用户资料、短 ID 和密码身份,但不创建登录 session。
|
4. user-service 写入用户资料、短 ID 和密码身份,但不创建登录 session。
|
||||||
5. admin-server 调 game-service 把这些 user_id 注册到机器人池。
|
5. admin-server 调 game-service 把这些 user_id 注册到机器人池。
|
||||||
6. game-service 后续只从启用状态的机器人中选择补位。
|
6. game-service 后续只从启用状态的机器人中选择补位。
|
||||||
@ -55,7 +55,7 @@
|
|||||||
### 批量创建
|
### 批量创建
|
||||||
|
|
||||||
- 地址:`POST /admin/game/robots/generate`
|
- 地址:`POST /admin/game/robots/generate`
|
||||||
- 参数:`count`(默认 20,最多 1000)、`nicknameLanguage`、`gender`、`country`、`avatarUrls`
|
- 参数:`count`(默认 20,最多 1000)、`nicknameLanguage`(只作为账号语言元数据)、`gender`、`country`
|
||||||
- 返回值:创建数量、机器人列表
|
- 返回值:创建数量、机器人列表
|
||||||
- 相关 IM:无;机器人不导入 IM 登录账号
|
- 相关 IM:无;机器人不导入 IM 登录账号
|
||||||
|
|
||||||
@ -75,7 +75,7 @@
|
|||||||
|
|
||||||
## 游戏补位规则
|
## 游戏补位规则
|
||||||
|
|
||||||
- 自研游戏配置里开启机器人补位后,玩家等待超过配置时间,game-service 会优先选择最久未使用的启用机器人加入对局。
|
- 自研游戏配置里开启机器人补位后,玩家等待超过配置时间,game-service 会从当前 App 下所有启用且未加入本局的机器人里完全随机抽取一个加入对局。
|
||||||
- 机器人被选中后会记录最近使用时间和累计使用次数;只有可用机器人轮过一遍后,才会重新使用较早出现过的机器人。
|
- 机器人被选中后会记录最近使用时间和累计使用次数;这些字段只用于后台观察命中分布,不影响下一次随机抽取。
|
||||||
- 机器人对局资金由游戏奖池结算,不从机器人钱包扣款。
|
- 机器人对局资金由游戏奖池结算,不从机器人钱包扣款。
|
||||||
- 机器人禁用后不会再被新对局选中。
|
- 机器人禁用后不会再被新对局选中。
|
||||||
|
|||||||
@ -149,6 +149,22 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer walletDB.Close()
|
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)
|
store := repository.New(db)
|
||||||
if cfg.MySQLAutoMigrate {
|
if cfg.MySQLAutoMigrate {
|
||||||
if err := store.AutoMigrate(); err != nil {
|
if err := store.AutoMigrate(); err != nil {
|
||||||
@ -242,7 +258,7 @@ func main() {
|
|||||||
gameclient.NewGRPC(gameConn),
|
gameclient.NewGRPC(gameConn),
|
||||||
userclient.NewGRPC(userConn),
|
userclient.NewGRPC(userConn),
|
||||||
auditHandler,
|
auditHandler,
|
||||||
gamemanagementmodule.WithRobotAvatarUploader(objectUploader, cfg.TencentCOS.ObjectPrefix),
|
gamemanagementmodule.WithRobotProfileSource(robotProfileSource),
|
||||||
),
|
),
|
||||||
GiftDiamond: giftdiamondmodule.New(walletDB, auditHandler),
|
GiftDiamond: giftdiamondmodule.New(walletDB, auditHandler),
|
||||||
Health: healthmodule.New(store, redisClient, jobStatus),
|
Health: healthmodule.New(store, redisClient, jobStatus),
|
||||||
|
|||||||
@ -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"
|
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"
|
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"
|
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
|
mysql_auto_migrate: false
|
||||||
migrations:
|
migrations:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@ -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"
|
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"
|
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"
|
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
|
mysql_auto_migrate: true
|
||||||
migrations:
|
migrations:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@ -13,33 +13,34 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ServiceName string `yaml:"service_name"`
|
ServiceName string `yaml:"service_name"`
|
||||||
NodeID string `yaml:"node_id"`
|
NodeID string `yaml:"node_id"`
|
||||||
Environment string `yaml:"environment"`
|
Environment string `yaml:"environment"`
|
||||||
Log logging.Config `yaml:"log"`
|
Log logging.Config `yaml:"log"`
|
||||||
HTTPAddr string `yaml:"http_addr"`
|
HTTPAddr string `yaml:"http_addr"`
|
||||||
MySQLDSN string `yaml:"mysql_dsn"`
|
MySQLDSN string `yaml:"mysql_dsn"`
|
||||||
UserMySQLDSN string `yaml:"user_mysql_dsn"`
|
UserMySQLDSN string `yaml:"user_mysql_dsn"`
|
||||||
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
|
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
|
||||||
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
RobotProfileSource RobotProfileSourceConfig `yaml:"robot_profile_source"`
|
||||||
Migrations MigrationConfig `yaml:"migrations"`
|
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
||||||
JWTSecret string `yaml:"jwt_secret"`
|
Migrations MigrationConfig `yaml:"migrations"`
|
||||||
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
|
JWTSecret string `yaml:"jwt_secret"`
|
||||||
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
|
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
|
||||||
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
|
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
|
||||||
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
|
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
|
||||||
RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"`
|
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
|
||||||
Bootstrap BootstrapConfig `yaml:"bootstrap"`
|
RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"`
|
||||||
BootstrapPassword string `yaml:"bootstrap_password"`
|
Bootstrap BootstrapConfig `yaml:"bootstrap"`
|
||||||
UserService UserServiceConfig `yaml:"user_service"`
|
BootstrapPassword string `yaml:"bootstrap_password"`
|
||||||
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
|
UserService UserServiceConfig `yaml:"user_service"`
|
||||||
Redis RedisConfig `yaml:"redis"`
|
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
|
||||||
Jobs JobsConfig `yaml:"jobs"`
|
Redis RedisConfig `yaml:"redis"`
|
||||||
WalletService WalletServiceConfig `yaml:"wallet_service"`
|
Jobs JobsConfig `yaml:"jobs"`
|
||||||
RoomService RoomServiceConfig `yaml:"room_service"`
|
WalletService WalletServiceConfig `yaml:"wallet_service"`
|
||||||
ActivityService ActivityServiceConfig `yaml:"activity_service"`
|
RoomService RoomServiceConfig `yaml:"room_service"`
|
||||||
GameService GameServiceConfig `yaml:"game_service"`
|
ActivityService ActivityServiceConfig `yaml:"activity_service"`
|
||||||
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
|
GameService GameServiceConfig `yaml:"game_service"`
|
||||||
|
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MigrationConfig struct {
|
type MigrationConfig struct {
|
||||||
@ -78,6 +79,11 @@ type StatisticsServiceConfig struct {
|
|||||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RobotProfileSourceConfig struct {
|
||||||
|
MySQLDSN string `yaml:"mysql_dsn"`
|
||||||
|
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
type TencentCOSConfig struct {
|
type TencentCOSConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
SecretID string `yaml:"secret-id"`
|
SecretID string `yaml:"secret-id"`
|
||||||
@ -115,14 +121,17 @@ type JobsConfig struct {
|
|||||||
|
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
return Config{
|
return Config{
|
||||||
ServiceName: "admin-server",
|
ServiceName: "admin-server",
|
||||||
NodeID: "admin-local",
|
NodeID: "admin-local",
|
||||||
Environment: "local",
|
Environment: "local",
|
||||||
Log: logging.DefaultConfig(),
|
Log: logging.DefaultConfig(),
|
||||||
HTTPAddr: ":13100",
|
HTTPAddr: ":13100",
|
||||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC",
|
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",
|
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",
|
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,
|
MySQLAutoMigrate: true,
|
||||||
Migrations: MigrationConfig{
|
Migrations: MigrationConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -290,6 +299,10 @@ func (cfg *Config) Normalize() {
|
|||||||
if cfg.StatisticsService.RequestTimeout <= 0 {
|
if cfg.StatisticsService.RequestTimeout <= 0 {
|
||||||
cfg.StatisticsService.RequestTimeout = 3 * time.Second
|
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.SecretID = strings.TrimSpace(cfg.TencentCOS.SecretID)
|
||||||
cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey)
|
cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey)
|
||||||
cfg.TencentCOS.BucketName = strings.TrimSpace(cfg.TencentCOS.BucketName)
|
cfg.TencentCOS.BucketName = strings.TrimSpace(cfg.TencentCOS.BucketName)
|
||||||
@ -399,6 +412,12 @@ func (cfg Config) Validate() error {
|
|||||||
if cfg.StatisticsService.RequestTimeout <= 0 {
|
if cfg.StatisticsService.RequestTimeout <= 0 {
|
||||||
return errors.New("statistics_service.request_timeout must be greater than 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.Enabled {
|
||||||
if cfg.TencentCOS.SecretID == "" || cfg.TencentCOS.SecretKey == "" || cfg.TencentCOS.BucketName == "" || cfg.TencentCOS.Region == "" || cfg.TencentCOS.AccessURL == "" {
|
if cfg.TencentCOS.SecretID == "" || cfg.TencentCOS.SecretKey == "" || cfg.TencentCOS.BucketName == "" || cfg.TencentCOS.Region == "" || cfg.TencentCOS.AccessURL == "" {
|
||||||
return errors.New("tencent-cos config is incomplete")
|
return errors.New("tencent-cos config is incomplete")
|
||||||
|
|||||||
@ -14,17 +14,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
game gameclient.Client
|
game gameclient.Client
|
||||||
user userclient.Client
|
user userclient.Client
|
||||||
audit shared.OperationLogger
|
audit shared.OperationLogger
|
||||||
robotAvatar robotAvatarConfig
|
robotProfiles RobotProfileSource
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option 只开放模块初始化时的基础设施注入点,避免 handler 方法里散落环境配置读取。
|
// Option 只开放模块初始化时的基础设施注入点,避免 handler 方法里散落环境配置读取。
|
||||||
type Option func(*Handler)
|
type Option func(*Handler)
|
||||||
|
|
||||||
func New(game gameclient.Client, user userclient.Client, audit shared.OperationLogger, options ...Option) *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 {
|
for _, option := range options {
|
||||||
if option != nil {
|
if option != nil {
|
||||||
option(h)
|
option(h)
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
@ -1,642 +1,22 @@
|
|||||||
package gamemanagement
|
package gamemanagement
|
||||||
|
|
||||||
import (
|
import "strings"
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
robotNicknameLanguageArabic = "arabic"
|
defaultRobotGenerateCount = 20
|
||||||
robotNicknameLanguageEnglish = "english"
|
maxRobotGenerateCount = 1000
|
||||||
robotPresetTargetCount = 1000
|
|
||||||
defaultRobotGenerateCount = 20
|
|
||||||
maxRobotGenerateCount = 1000
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var defaultRobotAvatarSources = []string{
|
func robotAccountLanguage(value string) string {
|
||||||
"https://randomuser.me/api/portraits/men/0.jpg",
|
// likei 昵称和头像是资料事实来源;这里的 language 只保留为账号元数据,兼容后台旧请求传入 ar/en 的使用习惯。
|
||||||
"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 {
|
|
||||||
// 后台只开放英语和阿拉伯语两类昵称池;未知值回退英语,避免历史请求继续落到英阿混合池。
|
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
case "ar", "arabic", "arab":
|
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"
|
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 {
|
func robotGender(index int, requested string) string {
|
||||||
@ -649,13 +29,3 @@ func robotGender(index int, requested string) string {
|
|||||||
}
|
}
|
||||||
return "female"
|
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)]
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -118,41 +118,43 @@ func (h *Handler) GenerateDiceRobots(c *gin.Context) {
|
|||||||
if req.Count > maxRobotGenerateCount {
|
if req.Count > maxRobotGenerateCount {
|
||||||
req.Count = maxRobotGenerateCount
|
req.Count = maxRobotGenerateCount
|
||||||
}
|
}
|
||||||
nicknameLanguage := normalizeRobotNicknameLanguage(req.NicknameLanguage)
|
|
||||||
country := strings.TrimSpace(req.Country)
|
country := strings.TrimSpace(req.Country)
|
||||||
if country == "" {
|
if country == "" {
|
||||||
country = "SA"
|
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)
|
userIDs := make([]int64, 0, req.Count)
|
||||||
requestID := middleware.CurrentRequestID(c)
|
requestID := middleware.CurrentRequestID(c)
|
||||||
actorID := middleware.CurrentUserID(c)
|
accountLanguage := robotAccountLanguage(req.NicknameLanguage)
|
||||||
avatarCache := make(map[string]string)
|
|
||||||
for index := int32(0); index < req.Count; index++ {
|
for index := int32(0); index < req.Count; index++ {
|
||||||
// 批量机器人必须一次性写齐完整资料;同一请求内相同头像源只转存一次,避免 200 个账号重复下载上传同一张图。
|
profile := profiles[index]
|
||||||
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
|
|
||||||
}
|
|
||||||
created, err := h.user.QuickCreateAccount(c.Request.Context(), userclient.QuickCreateAccountRequest{
|
created, err := h.user.QuickCreateAccount(c.Request.Context(), userclient.QuickCreateAccountRequest{
|
||||||
RequestID: requestID,
|
RequestID: requestID,
|
||||||
Caller: "admin-server",
|
Caller: "admin-server",
|
||||||
Password: randomRobotPassword(),
|
Password: randomRobotPassword(),
|
||||||
Username: robotNickname(int(index), nicknameLanguage),
|
Username: profile.Nickname,
|
||||||
Avatar: avatarURL,
|
Avatar: profile.Avatar,
|
||||||
Gender: robotGender(int(index), req.Gender),
|
Gender: robotGender(int(index), req.Gender),
|
||||||
Country: country,
|
Country: country,
|
||||||
DeviceID: "game-robot-" + randomRobotHex(8),
|
DeviceID: "game-robot-" + randomRobotHex(8),
|
||||||
Source: "game_robot",
|
Source: "game_robot",
|
||||||
InstallChannel: "admin",
|
InstallChannel: "admin",
|
||||||
Platform: "android",
|
Platform: "android",
|
||||||
Language: robotAccountLanguage(nicknameLanguage),
|
Language: accountLanguage,
|
||||||
Timezone: "UTC",
|
Timezone: "UTC",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -248,13 +250,6 @@ func randomRobotHex(size int) string {
|
|||||||
return hex.EncodeToString(buf)
|
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) {
|
func parseInt64Param(raw string) (int64, error) {
|
||||||
var value int64
|
var value int64
|
||||||
_, err := fmt.Sscan(strings.TrimSpace(raw), &value)
|
_, err := fmt.Sscan(strings.TrimSpace(raw), &value)
|
||||||
|
|||||||
@ -325,10 +325,10 @@ CREATE TABLE IF NOT EXISTS game_self_game_robots (
|
|||||||
used_count BIGINT NOT NULL DEFAULT 0 COMMENT '累计被匹配补位次数',
|
used_count BIGINT NOT NULL DEFAULT 0 COMMENT '累计被匹配补位次数',
|
||||||
PRIMARY KEY(app_code, game_id, user_id),
|
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_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='自研游戏机器人登记表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏机器人登记表';
|
||||||
|
|
||||||
-- 机器人轮转依赖最近使用时间和累计使用次数;旧表补默认 0,保证存量机器人优先进入第一轮冷却排序。
|
-- 机器人使用时间和累计次数只用于后台观察随机命中分布;旧表补默认 0,避免存量机器人资料读取失败。
|
||||||
SET @ddl := IF(
|
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,
|
(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',
|
'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;
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
SET @ddl := IF(
|
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,
|
(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)',
|
'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'
|
'SELECT 1'
|
||||||
);
|
);
|
||||||
PREPARE stmt FROM @ddl;
|
PREPARE stmt FROM @ddl;
|
||||||
|
|||||||
@ -265,7 +265,7 @@ func (r *Repository) CancelDiceMatch(ctx context.Context, appCode string, matchI
|
|||||||
return r.GetDiceMatch(ctx, app, match.MatchID)
|
return r.GetDiceMatch(ctx, app, match.MatchID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PickActiveDiceRobot 从当前 App 的通用机器人池里挑选一个未加入本局、冷却最久的机器人。
|
// PickActiveDiceRobot 从当前 App 的通用机器人池里完全随机挑选一个未加入本局的机器人。
|
||||||
// game_self_game_robots 仍保留 game_id 字段是为了兼容旧数据和后台接口,但机器人账号本身不再按 dice/rock 隔离;
|
// game_self_game_robots 仍保留 game_id 字段是为了兼容旧数据和后台接口,但机器人账号本身不再按 dice/rock 隔离;
|
||||||
// 只要同一个 app_code 下存在 active 登记,这个真实用户就可以给任意自研游戏补位。
|
// 只要同一个 app_code 下存在 active 登记,这个真实用户就可以给任意自研游戏补位。
|
||||||
func (r *Repository) PickActiveDiceRobot(ctx context.Context, appCode string, gameID string, matchID string, nowMs int64) (dicedomain.Robot, error) {
|
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() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
var robot dicedomain.Robot
|
var robot dicedomain.Robot
|
||||||
// 候选行按“最久未使用、使用次数最少、user_id 最小”稳定排序,并用 SKIP LOCKED 避开其他正在补位的事务。
|
// 用户要求“100 个机器人就是 100 选 1”,因此候选池只过滤 app、启用状态和本局已加入机器人;
|
||||||
// 这样少量连续匹配会先把机器人池轮一遍;并发匹配也不会在同一瞬间都拿到同一条机器人登记。
|
// last_used_at_ms/used_count 只做后台观察和排查,不参与排序,避免冷却或轮转把随机池缩小成少数候选。
|
||||||
|
// SKIP LOCKED 让并发补位跳过其它事务已经锁住的候选行,减少多个等待局同一瞬间抢到同一个机器人。
|
||||||
err = tx.QueryRowContext(ctx,
|
err = tx.QueryRowContext(ctx,
|
||||||
`SELECT r.app_code, ? AS game_id, r.user_id, ? AS status,
|
`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,
|
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
|
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
|
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
|
LIMIT 1
|
||||||
FOR UPDATE SKIP LOCKED`,
|
FOR UPDATE SKIP LOCKED`,
|
||||||
gameID, dicedomain.RobotStatusActive, app, dicedomain.RobotStatusActive, matchID,
|
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
|
return dicedomain.Robot{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选中后立即写入轮转水位。即使后续补位并发失败,这个账号也会短暂进入冷却,避免多个等待局同时反复命中同一个机器人。
|
// 选中后仍记录使用水位,方便后台观察机器人命中分布;水位不反向影响下一次抽取,下一局仍从完整可用池随机。
|
||||||
res, err := tx.ExecContext(ctx,
|
res, err := tx.ExecContext(ctx,
|
||||||
`UPDATE game_self_game_robots
|
`UPDATE game_self_game_robots
|
||||||
SET last_used_at_ms = ?, used_count = used_count + 1
|
SET last_used_at_ms = ?, used_count = used_count + 1
|
||||||
|
|||||||
@ -233,7 +233,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
used_count BIGINT NOT NULL DEFAULT 0,
|
used_count BIGINT NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY(app_code, game_id, user_id),
|
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_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`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
`CREATE TABLE IF NOT EXISTS game_callback_logs (
|
`CREATE TABLE IF NOT EXISTS game_callback_logs (
|
||||||
app_code VARCHAR(32) NOT NULL,
|
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 {
|
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
|
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 err
|
||||||
}
|
}
|
||||||
return nil
|
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 {
|
func (r *Repository) ensureColumn(ctx context.Context, tableName string, columnName string, columnDDL string) error {
|
||||||
var count int
|
var count int
|
||||||
if err := r.db.QueryRowContext(ctx,
|
if err := r.db.QueryRowContext(ctx,
|
||||||
|
|||||||
@ -168,7 +168,7 @@ func TestLevelEventClaimKeyQueryUsesOrderIndexWithoutFilesort(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPickActiveDiceRobotRotatesByCooldownBeforeReusing(t *testing.T) {
|
func TestPickActiveDiceRobotRandomlySamplesWholeActivePool(t *testing.T) {
|
||||||
caller := mysqlschema.CallerFile(t, 1)
|
caller := mysqlschema.CallerFile(t, 1)
|
||||||
schema := mysqlschema.New(t, mysqlschema.Config{
|
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||||
InitDBPath: mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
|
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() })
|
t.Cleanup(func() { _ = repo.Close() })
|
||||||
|
|
||||||
nowMS := int64(1780542672000)
|
nowMS := int64(1780542672000)
|
||||||
userIDs := []int64{101, 102, 103, 104}
|
userIDs := []int64{101, 102}
|
||||||
for index, userID := range userIDs {
|
for index, userID := range userIDs {
|
||||||
|
usedCount := int64(0)
|
||||||
|
if userID == 101 {
|
||||||
|
usedCount = 999
|
||||||
|
}
|
||||||
if _, err := repo.db.ExecContext(ctx,
|
if _, err := repo.db.ExecContext(ctx,
|
||||||
`INSERT INTO game_self_game_robots (
|
`INSERT INTO game_self_game_robots (
|
||||||
app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms,
|
app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms,
|
||||||
last_used_at_ms, used_count
|
last_used_at_ms, used_count
|
||||||
) VALUES (?, ?, ?, ?, 7, ?, ?, 0, 0)`,
|
) VALUES (?, ?, ?, ?, 7, ?, ?, 0, ?)`,
|
||||||
"lalu", dicedomain.DefaultGameID, userID, dicedomain.RobotStatusActive, nowMS+int64(index), nowMS+int64(index),
|
"lalu", dicedomain.DefaultGameID, userID, dicedomain.RobotStatusActive, nowMS+int64(index), nowMS+int64(index), usedCount,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("insert robot %d failed: %v", userID, err)
|
t.Fatalf("insert robot %d failed: %v", userID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for attempt, wantUserID := range userIDs {
|
picked := map[int64]bool{}
|
||||||
robot, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, fmt.Sprintf("match_rotate_%d", attempt), nowMS+100+int64(attempt))
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("pick active dice robot failed: %v", err)
|
t.Fatalf("pick active dice robot failed: %v", err)
|
||||||
}
|
}
|
||||||
if robot.UserID != wantUserID {
|
if !containsInt64(userIDs, robot.UserID) {
|
||||||
t.Fatalf("rotation pick %d mismatch: got %+v want user_id=%d", attempt, robot, wantUserID)
|
t.Fatalf("random pick %d returned unknown robot: %+v", attempt, robot)
|
||||||
}
|
}
|
||||||
if robot.LastUsedAtMS != nowMS+100+int64(attempt) || robot.UsedCount != 1 {
|
picked[robot.UserID] = true
|
||||||
t.Fatalf("picked robot should return updated cooldown fields, got %+v", robot)
|
if robot.LastUsedAtMS != nowMS+100+int64(attempt) {
|
||||||
|
t.Fatalf("picked robot should return updated usage time, got %+v", robot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(picked) != len(userIDs) {
|
||||||
reused, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, "match_rotate_reuse", nowMS+200)
|
t.Fatalf("full random must keep high used_count robots in the candidate pool, got picked=%+v", picked)
|
||||||
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 _, err := repo.db.ExecContext(ctx,
|
if _, err := repo.db.ExecContext(ctx,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user