2026-06-15 23:53:30 +08:00

485 lines
15 KiB
Go

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)
}