package main import ( "bytes" "context" "database/sql" "encoding/json" "errors" "fmt" "io" "net/http" "os" "strconv" "strings" "time" _ "github.com/go-sql-driver/mysql" ) const ( defaultAdminBaseURL = "http://127.0.0.1:13100/api/v1" defaultAdminUser = "admin" defaultAdminPass = "admin123" defaultMySQLDSN = "hyapp:hyapp@tcp(127.0.0.1:23306)/%s?parseTime=true&charset=utf8mb4&loc=UTC" appCode = "lalu" roomScene = "voice" normalGiftID = "robot_room_test_normal" luckyGiftID = "robot_room_test_lucky" robotUserStart = int64(920001) robotUserCount = 12 ) type apiBody struct { Code int `json:"code"` Message string `json:"message"` Data json.RawMessage `json:"data"` } type loginData struct { AccessToken string `json:"accessToken"` } type availableRobot struct { UserID string `json:"userId"` } type robotRoom struct { RoomID string `json:"roomId"` Status string `json:"status"` OwnerRobotUserID string `json:"ownerRobotUserId"` RobotUserIDs []string `json:"robotUserIds"` GiftRule struct { GiftIDs []string `json:"giftIds"` LuckyGiftIDs []string `json:"luckyGiftIds"` NormalGiftIntervalMS int64 `json:"normalGiftIntervalMs"` LuckyComboMin int64 `json:"luckyComboMin"` LuckyComboMax int64 `json:"luckyComboMax"` LuckyPauseMinMS int64 `json:"luckyPauseMinMs"` LuckyPauseMaxMS int64 `json:"luckyPauseMaxMs"` } `json:"giftRule"` } type pageData struct { Items []robotRoom `json:"items"` Total int64 `json:"total"` } func main() { ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() baseURL := env("ROBOT_ROOM_ADMIN_BASE_URL", defaultAdminBaseURL) mysqlDSN := env("ROBOT_ROOM_MYSQL_DSN", defaultMySQLDSN) adminUser := env("ROBOT_ROOM_ADMIN_USERNAME", defaultAdminUser) adminPass := env("ROBOT_ROOM_ADMIN_PASSWORD", defaultAdminPass) dbs, err := openDBs(ctx, mysqlDSN) if err != nil { fail(err) } defer dbs.close() if err := seed(ctx, dbs); err != nil { fail(fmt.Errorf("seed local data: %w", err)) } fmt.Println("seed: ok") client := &http.Client{Timeout: 10 * time.Second} token, err := login(ctx, client, baseURL, adminUser, adminPass) if err != nil { fail(err) } fmt.Println("login: ok") available, err := getJSON[[]availableRobot](ctx, client, baseURL+"/admin/rooms/robot-rooms/available-robots", token) if err != nil { fail(err) } if len(available) < 10 { fail(fmt.Errorf("available robots = %d, want at least 10", len(available))) } fmt.Printf("available robots: %d\n", len(available)) payload := map[string]any{ "ownerRobotUserId": robotUserStart, "candidateRobotUserIds": robotIDs(), "minRobotCount": 6, "maxRobotCount": 8, "roomName": "本地接口测试机器人房", "visibleRegionId": 0, "giftIds": []string{normalGiftID}, "luckyGiftIds": []string{luckyGiftID}, "normalGiftIntervalMs": 60000, "luckyComboMin": 2, "luckyComboMax": 3, "luckyPauseMinMs": 600000, "luckyPauseMaxMs": 600000, } created, err := postJSON[robotRoom](ctx, client, baseURL+"/admin/rooms/robot-rooms", token, payload) if err != nil { fail(err) } if created.RoomID == "" || created.Status != "active" { fail(fmt.Errorf("created room invalid: room_id=%q status=%q", created.RoomID, created.Status)) } if len(created.RobotUserIDs) < 6 || len(created.RobotUserIDs) > 8 { fail(fmt.Errorf("created robot count = %d, want 6-8", len(created.RobotUserIDs))) } fmt.Printf("create robot room: %s robots=%d\n", created.RoomID, len(created.RobotUserIDs)) list, err := getJSON[pageData](ctx, client, baseURL+"/admin/rooms/robot-rooms?status=active&page=1&page_size=20", token) if err != nil { fail(err) } if !containsRoom(list.Items, created.RoomID) { fail(fmt.Errorf("created room %s not found in active list", created.RoomID)) } fmt.Printf("list active robot rooms: total=%d\n", list.Total) stopped, err := postJSON[robotRoom](ctx, client, baseURL+"/admin/rooms/robot-rooms/"+created.RoomID+"/stop", token, nil) if err != nil { fail(err) } if stopped.Status != "stopped" { fail(fmt.Errorf("stop status = %q, want stopped", stopped.Status)) } fmt.Println("stop robot room: ok") started, err := postJSON[robotRoom](ctx, client, baseURL+"/admin/rooms/robot-rooms/"+created.RoomID+"/start", token, nil) if err != nil { fail(err) } if started.Status != "active" { fail(fmt.Errorf("start status = %q, want active", started.Status)) } fmt.Println("start robot room: ok") if _, err := postJSON[robotRoom](ctx, client, baseURL+"/admin/rooms/robot-rooms/"+created.RoomID+"/stop", token, nil); err != nil { fail(err) } if err := verifyDB(ctx, dbs, created.RoomID); err != nil { fail(err) } fmt.Println("db verify: ok") fmt.Println("PASS robot room admin API smoke") } type dbs struct { user *sql.DB wallet *sql.DB robot *sql.DB room *sql.DB } func openDBs(ctx context.Context, dsnTemplate string) (dbs, error) { open := func(name string) (*sql.DB, error) { dsn := fmt.Sprintf(dsnTemplate, name) if !strings.Contains(dsnTemplate, "%s") { dsn = dsnTemplate } db, err := sql.Open("mysql", dsn) if err != nil { return nil, err } if err := db.PingContext(ctx); err != nil { _ = db.Close() return nil, fmt.Errorf("%s: %w", name, err) } return db, nil } userDB, err := open("hyapp_user") if err != nil { return dbs{}, err } walletDB, err := open("hyapp_wallet") if err != nil { _ = userDB.Close() return dbs{}, err } robotDB, err := open("hyapp_robot") if err != nil { _ = userDB.Close() _ = walletDB.Close() return dbs{}, err } roomDB, err := open("hyapp_room") if err != nil { _ = userDB.Close() _ = walletDB.Close() _ = robotDB.Close() return dbs{}, err } return dbs{user: userDB, wallet: walletDB, robot: robotDB, room: roomDB}, nil } func (d dbs) close() { _ = d.user.Close() _ = d.wallet.Close() _ = d.robot.Close() _ = d.room.Close() } func seed(ctx context.Context, dbs dbs) error { now := time.Now().UnixMilli() ids := robotIDs() args := make([]any, 0, len(ids)+1) args = append(args, appCode) in := make([]string, 0, len(ids)) for _, id := range ids { in = append(in, "?") args = append(args, id) } idClause := strings.Join(in, ",") for _, statement := range []struct { db *sql.DB sql string args []any }{ {dbs.room, "DELETE FROM room_command_log WHERE app_code = ? AND (room_id LIKE 'robot\\_%' OR command_id LIKE 'admin-robot-room:%')", []any{appCode}}, {dbs.room, "DELETE FROM room_snapshots WHERE app_code = ? AND room_id LIKE 'robot\\_%'", []any{appCode}}, {dbs.room, "DELETE FROM room_outbox WHERE app_code = ? AND room_id LIKE 'robot\\_%'", []any{appCode}}, {dbs.room, "DELETE FROM room_robot_rooms WHERE app_code = ? AND owner_robot_user_id IN (" + idClause + ")", args}, {dbs.room, "DELETE FROM rooms WHERE app_code = ? AND owner_user_id IN (" + idClause + ")", args}, {dbs.room, "DELETE FROM room_list_entries WHERE app_code = ? AND owner_user_id IN (" + idClause + ")", args}, {dbs.room, "DELETE FROM room_user_presence WHERE app_code = ? AND user_id IN (" + idClause + ")", args}, } { if _, err := statement.db.ExecContext(ctx, statement.sql, statement.args...); err != nil { return err } } for _, id := range ids { display := strconv.FormatInt(id, 10) if _, err := dbs.user.ExecContext(ctx, ` INSERT INTO users ( app_code, user_id, default_display_user_id, current_display_user_id, current_display_user_id_kind, username, gender, country, region_id, avatar, profile_completed, onboarding_status, status, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, 'default', ?, 'unknown', 'SA', 0, '', 1, 'completed', 'active', ?, ?) ON DUPLICATE KEY UPDATE username = VALUES(username), current_display_user_id = VALUES(current_display_user_id), status = 'active', updated_at_ms = VALUES(updated_at_ms)`, appCode, id, display, display, fmt.Sprintf("机器人%d", id), now, now, ); err != nil { return err } if _, err := dbs.robot.ExecContext(ctx, ` INSERT INTO robot_room_robots ( app_code, room_scene, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms, last_used_at_ms, used_count ) VALUES (?, ?, ?, 'active', 1, ?, ?, 0, 0) ON DUPLICATE KEY UPDATE status = 'active', updated_at_ms = VALUES(updated_at_ms)`, appCode, roomScene, id, now, now, ); err != nil { return err } } if err := seedGift(ctx, dbs.wallet, normalGiftID, "机器人普通测试礼物", 1001, 1, now); err != nil { return err } if err := seedGift(ctx, dbs.wallet, luckyGiftID, "机器人幸运测试礼物", 1002, 2, now); err != nil { return err } if _, err := dbs.wallet.ExecContext(ctx, ` INSERT INTO gift_diamond_ratio_configs ( app_code, region_id, gift_type_code, status, ratio_percent, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms ) VALUES (?, 0, 'normal', 'active', 100.00, 1, 1, ?, ?) ON DUPLICATE KEY UPDATE status = 'active', ratio_percent = VALUES(ratio_percent), updated_at_ms = VALUES(updated_at_ms)`, appCode, now, now, ); err != nil { return err } return nil } func seedGift(ctx context.Context, db *sql.DB, giftID string, name string, resourceID int64, coinPrice int64, now int64) error { if _, err := db.ExecContext(ctx, ` INSERT INTO resources ( resource_id, app_code, resource_code, resource_type, name, status, grantable, manager_grant_enabled, grant_strategy, wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount, usage_scope_json, asset_url, preview_url, animation_url, metadata_json, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, 'gift', ?, 'active', true, false, 'none', '', 0, 'coin', ?, 0, '{}', '', '', '', '{}', 0, 1, 1, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), status = 'active', coin_price = VALUES(coin_price), updated_at_ms = VALUES(updated_at_ms)`, resourceID, appCode, giftID+"_resource", name, coinPrice, now, now, ); err != nil { return err } if _, err := db.ExecContext(ctx, ` INSERT INTO gift_type_configs ( app_code, type_code, name, tab_key, status, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, 'normal', '普通礼物', 'normal', 'active', 0, 1, 1, ?, ?) ON DUPLICATE KEY UPDATE status = 'active', updated_at_ms = VALUES(updated_at_ms)`, appCode, now, now, ); err != nil { return err } if _, err := db.ExecContext(ctx, ` INSERT INTO gift_configs ( app_code, gift_id, resource_id, status, name, sort_order, presentation_json, gift_type_code, effective_from_ms, effective_to_ms, effect_types_json, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, 'active', ?, 0, '{}', 'normal', 0, 0, '["global_broadcast"]', 1, 1, ?, ?) ON DUPLICATE KEY UPDATE status = 'active', name = VALUES(name), resource_id = VALUES(resource_id), updated_at_ms = VALUES(updated_at_ms)`, appCode, giftID, resourceID, name, now, now, ); err != nil { return err } _, err := db.ExecContext(ctx, ` INSERT INTO wallet_gift_prices ( app_code, gift_id, price_version, status, charge_asset_type, coin_price, gift_point_amount, heat_value, effective_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, 'robot_room_test_v1', 'active', 'COIN', ?, 0, ?, 0, ?, ?) ON DUPLICATE KEY UPDATE status = 'active', coin_price = VALUES(coin_price), heat_value = VALUES(heat_value), updated_at_ms = VALUES(updated_at_ms)`, appCode, giftID, coinPrice, coinPrice, now, now, ) return err } func verifyDB(ctx context.Context, dbs dbs, roomID string) error { var status string var owner int64 var rawRobots string err := dbs.room.QueryRowContext(ctx, ` SELECT status, owner_robot_user_id, CAST(robot_user_ids_json AS CHAR) FROM room_robot_rooms WHERE app_code = ? AND room_id = ?`, appCode, roomID, ).Scan(&status, &owner, &rawRobots) if err != nil { return err } if status != "stopped" { return fmt.Errorf("db status = %s, want stopped", status) } if owner != robotUserStart { return fmt.Errorf("db owner = %d, want %d", owner, robotUserStart) } var robotIDs []int64 if err := json.Unmarshal([]byte(rawRobots), &robotIDs); err != nil { return err } if len(robotIDs) < 6 || len(robotIDs) > 8 { return fmt.Errorf("db robot count = %d, want 6-8", len(robotIDs)) } var realGiftDebits int64 if err := dbs.wallet.QueryRowContext(ctx, ` SELECT COUNT(*) FROM wallet_transactions WHERE app_code = ? AND biz_type = 'gift_debit' AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.room_id')) = ?`, appCode, roomID, ).Scan(&realGiftDebits); err != nil { return err } if realGiftDebits != 0 { return fmt.Errorf("real gift debit rows for robot room = %d, want 0", realGiftDebits) } return nil } func login(ctx context.Context, client *http.Client, baseURL string, username string, password string) (string, error) { data, err := postJSON[loginData](ctx, client, baseURL+"/auth/login", "", map[string]string{ "username": username, "password": password, }) if err != nil { return "", err } if strings.TrimSpace(data.AccessToken) == "" { return "", errors.New("login returned empty access token") } return data.AccessToken, nil } func getJSON[T any](ctx context.Context, client *http.Client, url string, token string) (T, error) { var zero T req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return zero, err } return doJSON[T](client, req, token) } func postJSON[T any](ctx context.Context, client *http.Client, url string, token string, payload any) (T, error) { var zero T var body io.Reader if payload != nil { raw, err := json.Marshal(payload) if err != nil { return zero, err } body = bytes.NewReader(raw) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) if err != nil { return zero, err } if payload != nil { req.Header.Set("Content-Type", "application/json") } return doJSON[T](client, req, token) } func doJSON[T any](client *http.Client, req *http.Request, token string) (T, error) { var zero T if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := client.Do(req) if err != nil { return zero, err } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return zero, fmt.Errorf("%s %s: status %d body %s", req.Method, req.URL.Path, resp.StatusCode, strings.TrimSpace(string(raw))) } var wrapped apiBody if err := json.Unmarshal(raw, &wrapped); err != nil { return zero, err } if wrapped.Code != 0 { return zero, fmt.Errorf("%s %s: code %d message %s", req.Method, req.URL.Path, wrapped.Code, wrapped.Message) } var out T if len(wrapped.Data) == 0 || string(wrapped.Data) == "null" { return out, nil } if err := json.Unmarshal(wrapped.Data, &out); err != nil { return zero, err } return out, nil } func robotIDs() []int64 { ids := make([]int64, 0, robotUserCount) for i := 0; i < robotUserCount; i++ { ids = append(ids, robotUserStart+int64(i)) } return ids } func containsRoom(items []robotRoom, roomID string) bool { for _, item := range items { if item.RoomID == roomID { return true } } return false } func env(key string, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } return fallback } func fail(err error) { fmt.Fprintln(os.Stderr, "FAIL:", err) os.Exit(1) }