游戏机器人

This commit is contained in:
zhx 2026-06-16 20:47:26 +08:00
parent 83b82b8e30
commit bb79845b6b
3 changed files with 105 additions and 11 deletions

View File

@ -2,8 +2,10 @@ package gamemanagement
import ( import (
"context" "context"
"crypto/rand"
"database/sql" "database/sql"
"fmt" "fmt"
"math/big"
"net/url" "net/url"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@ -12,6 +14,7 @@ import (
type RobotProfile struct { type RobotProfile struct {
Nickname string Nickname string
Avatar string Avatar string
Gender string
} }
type RobotProfileSource interface { type RobotProfileSource interface {
@ -29,7 +32,7 @@ func NewLikeiRobotProfileSource(db *sql.DB) RobotProfileSource {
return &likeiRobotProfileSource{db: db} return &likeiRobotProfileSource{db: db}
} }
// WithRobotProfileSource 注入机器人资料池;创建机器人时只消费这里返回的线上 likei 用户昵称和头像 // WithRobotProfileSource 注入机器人资料池;创建机器人优先消费线上 likei 用户昵称和头像,未配置时再走本地随机资料兜底
func WithRobotProfileSource(source RobotProfileSource) Option { func WithRobotProfileSource(source RobotProfileSource) Option {
return func(h *Handler) { return func(h *Handler) {
h.robotProfiles = source h.robotProfiles = source
@ -81,6 +84,50 @@ func (s *likeiRobotProfileSource) RandomRobotProfiles(ctx context.Context, count
return profiles, nil return profiles, nil
} }
func robotProfilesForGenerate(ctx context.Context, source RobotProfileSource, count int) ([]RobotProfile, error) {
if source != nil {
// 已配置 likei 时继续使用真实资料池;查询失败不能静默降级,否则线上数据源故障会被批量造号掩盖。
return source.RandomRobotProfiles(ctx, count)
}
profiles := make([]RobotProfile, 0, count)
for index := 0; index < count; index++ {
profiles = append(profiles, randomRobotProfile())
}
return profiles, nil
}
func randomRobotProfile() RobotProfile {
seed := randomRobotHex(8)
nameSeed := randomRobotHex(3)
if seed == "fallback" {
seed = fmt.Sprintf("fallback-%d", randomRobotInt(1000000))
}
// 本地随机头像只保存绝对 URL不在 admin-server 里下载或转存图片user-service 仍负责资料字段校验。
return RobotProfile{
Nickname: fmt.Sprintf("Robot %s", strings.ToUpper(nameSeed)),
Avatar: "https://api.dicebear.com/9.x/adventurer/png?seed=hyapp-robot-" + url.QueryEscape(seed),
Gender: randomRobotGender(),
}
}
func randomRobotGender() string {
if randomRobotInt(2) == 0 {
return "male"
}
return "female"
}
func randomRobotInt(max int64) int64 {
if max <= 0 {
return 0
}
value, err := rand.Int(rand.Reader, big.NewInt(max))
if err != nil {
return 0
}
return value.Int64()
}
func normalizeRobotProfile(profile RobotProfile) (RobotProfile, bool) { func normalizeRobotProfile(profile RobotProfile) (RobotProfile, bool) {
profile.Nickname = strings.TrimSpace(profile.Nickname) profile.Nickname = strings.TrimSpace(profile.Nickname)
profile.Avatar = strings.TrimSpace(profile.Avatar) profile.Avatar = strings.TrimSpace(profile.Avatar)

View File

@ -5,6 +5,7 @@ import (
"context" "context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"hyapp-admin-server/internal/integration/gameclient" "hyapp-admin-server/internal/integration/gameclient"
@ -104,3 +105,46 @@ func TestGenerateDiceRobotsUsesLikeiProfiles(t *testing.T) {
t.Fatalf("registered user ids = %#v", games.registered) t.Fatalf("registered user ids = %#v", games.registered)
} }
} }
func TestGenerateDiceRobotsFallsBackToRandomProfilesWhenLikeiMissing(t *testing.T) {
gin.SetMode(gin.TestMode)
users := &fakeRobotUserClient{}
games := &fakeRobotGameClient{}
handler := New(games, users, nil)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Set(middleware.ContextRequestID, "req-robot-random")
c.Set(middleware.ContextUserID, uint(7))
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/games/self/dice/robots:generate", bytes.NewBufferString(`{
"count": 3,
"country": "TH"
}`))
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 len(users.created) != 3 {
t.Fatalf("created users = %d, want 3", len(users.created))
}
for index, created := range users.created {
if !strings.HasPrefix(created.Username, "Robot ") {
t.Fatalf("created[%d] username should be generated, got %#v", index, created)
}
if !strings.HasPrefix(created.Avatar, "https://api.dicebear.com/9.x/adventurer/png?seed=hyapp-robot-") {
t.Fatalf("created[%d] avatar should be generated, got %#v", index, created)
}
if created.Gender != "male" && created.Gender != "female" {
t.Fatalf("created[%d] gender should be random male/female, got %#v", index, created)
}
if created.Country != "TH" || created.Source != "game_robot" {
t.Fatalf("created[%d] metadata = %#v", index, created)
}
}
if len(games.registered) != 3 || games.registered[0] != 9001 || games.registered[2] != 9003 {
t.Fatalf("registered user ids = %#v", games.registered)
}
}

View File

@ -220,19 +220,14 @@ func (h *Handler) GenerateDiceRobots(c *gin.Context) {
if country == "" { if country == "" {
country = "SA" country = "SA"
} }
if h.robotProfiles == nil { // likei 是优先资料源;测试服或本地未配置时用随机资料兜底,避免运维为了造机器人账号被外部历史库阻塞。
response.ServerError(c, "likei 机器人资料源未配置") profiles, err := robotProfilesForGenerate(c.Request.Context(), h.robotProfiles, int(req.Count))
return
}
// 新机器人资料只从 likei 线上用户库随机抽取;后台不再使用本地昵称池、随机头像站或 COS 转存,
// 这样每次批量创建都面对真实用户资料池,避免少量固定预设反复出现在匹配列表里。
profiles, err := h.robotProfiles.RandomRobotProfiles(c.Request.Context(), int(req.Count))
if err != nil { if err != nil {
response.BadRequest(c, "获取 likei 机器人资料失败: "+err.Error()) response.BadRequest(c, "获取机器人资料失败: "+err.Error())
return return
} }
if len(profiles) < int(req.Count) { if len(profiles) < int(req.Count) {
response.BadRequest(c, "likei 机器人资料数量不足") response.BadRequest(c, "机器人资料数量不足")
return return
} }
userIDs := make([]int64, 0, req.Count) userIDs := make([]int64, 0, req.Count)
@ -240,13 +235,21 @@ func (h *Handler) GenerateDiceRobots(c *gin.Context) {
accountLanguage := robotAccountLanguage(req.NicknameLanguage) accountLanguage := robotAccountLanguage(req.NicknameLanguage)
for index := int32(0); index < req.Count; index++ { for index := int32(0); index < req.Count; index++ {
profile := profiles[index] profile := profiles[index]
gender := strings.TrimSpace(req.Gender)
if gender == "" {
gender = strings.TrimSpace(profile.Gender)
}
if gender == "" {
// likei 当前只提供昵称和头像;没有显式性别时保持旧的均匀分布,随机兜底资料会在 profile 中给出真实随机值。
gender = robotGender(int(index), "")
}
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: profile.Nickname, Username: profile.Nickname,
Avatar: profile.Avatar, Avatar: profile.Avatar,
Gender: robotGender(int(index), req.Gender), Gender: gender,
Country: country, Country: country,
DeviceID: "game-robot-" + randomRobotHex(8), DeviceID: "game-robot-" + randomRobotHex(8),
Source: "game_robot", Source: "game_robot",