Use likei profiles for game robots
This commit is contained in:
parent
84bba92611
commit
d2dbc88e5f
@ -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 下所有启用且未加入本局的机器人里完全随机抽取一个加入对局。
|
||||
- 机器人被选中后会记录最近使用时间和累计使用次数;这些字段只用于后台观察命中分布,不影响下一次随机抽取。
|
||||
- 机器人对局资金由游戏奖池结算,不从机器人钱包扣款。
|
||||
- 机器人禁用后不会再被新对局选中。
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
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)]
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user