百顺游戏

This commit is contained in:
zhx 2026-05-23 12:07:02 +08:00
parent 33ddebd8de
commit d1a7facf10
6 changed files with 494 additions and 7 deletions

View File

@ -26,7 +26,7 @@ const (
adapterBaishunV1 = "baishun_v1"
defaultGameStatus = "disabled"
defaultGameCategory = "casino"
defaultLaunchMode = "h5_popup"
defaultLaunchMode = "full_screen"
defaultOrientation = "portrait"
)
@ -172,8 +172,8 @@ func fetchBaishunGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatform
appChannel := config.appChannel()
appKey := strings.TrimSpace(platform.GetCallbackSecret())
endpoint := baishunGameListEndpoint(platform.GetApiBaseUrl(), config)
if appID == 0 || appChannel == "" || appKey == "" || endpoint == "" {
return providerGameSyncPlan{}, fmt.Errorf("百顺列表同步需要 app_id、app_channel、回调密钥和 provider_api_base_url/game_list_url")
if missing := missingBaishunSyncFields(appID, appChannel, appKey, endpoint); len(missing) > 0 {
return providerGameSyncPlan{}, fmt.Errorf("百顺列表同步缺少配置: %s", strings.Join(missing, "、"))
}
body := baishunSignedListRequest(appID, appChannel, baishunGameListType(config, req), appKey)
raw, err := json.Marshal(body)
@ -225,6 +225,23 @@ func decodeBaishunSyncConfig(raw string) (baishunSyncConfig, error) {
return config, nil
}
func missingBaishunSyncFields(appID int64, appChannel string, appKey string, endpoint string) []string {
missing := make([]string, 0, 4)
if appID == 0 {
missing = append(missing, "adapterConfigJson.app_id")
}
if strings.TrimSpace(appChannel) == "" {
missing = append(missing, "adapterConfigJson.app_channel")
}
if strings.TrimSpace(appKey) == "" {
missing = append(missing, "回调密钥")
}
if strings.TrimSpace(endpoint) == "" {
missing = append(missing, "adapterConfigJson.game_list_url 或 provider_api_base_url")
}
return missing
}
func (c baishunSyncConfig) appID() int64 {
if c.AppID != 0 {
return c.AppID

View File

@ -55,7 +55,7 @@ func TestFetchBaishunGameSyncPlanFetchesSignsAndFormatsCatalog(t *testing.T) {
if game.GameID != "baishun_1006" || game.ProviderGameID != "1006" || game.GameName != "Gold of Test" {
t.Fatalf("catalog identity mismatch: %+v", game)
}
if game.IconURL != "https://cdn.test/1006.png" || game.CoverURL != "https://cdn.test/1006.png" || game.Orientation != "landscape" || game.Status != "disabled" {
if game.IconURL != "https://cdn.test/1006.png" || game.CoverURL != "https://cdn.test/1006.png" || game.Orientation != "landscape" || game.Status != "disabled" || game.LaunchMode != "full_screen" {
t.Fatalf("catalog presentation mismatch: %+v", game)
}
var merged struct {
@ -81,3 +81,21 @@ func TestSelectProviderGamesKeepsOnlySelectedIDs(t *testing.T) {
t.Fatalf("selected games mismatch: %+v", selected)
}
}
func TestMissingBaishunSyncFieldsNamesExactInputs(t *testing.T) {
missing := missingBaishunSyncFields(0, "", "", "")
want := []string{
"adapterConfigJson.app_id",
"adapterConfigJson.app_channel",
"回调密钥",
"adapterConfigJson.game_list_url 或 provider_api_base_url",
}
if len(missing) != len(want) {
t.Fatalf("missing len = %d, want %d: %+v", len(missing), len(want), missing)
}
for index := range want {
if missing[index] != want[index] {
t.Fatalf("missing[%d] = %q, want %q", index, missing[index], want[index])
}
}
}

View File

@ -7,8 +7,11 @@ const (
SceneVoiceRoom = "voice_room"
LaunchModeH5Popup = "h5_popup"
SessionActive = "active"
LaunchModeFullScreen = "full_screen"
LaunchModeHalfScreen = "half_screen"
LaunchModeThreeQuarterScreen = "three_quarter_screen"
LaunchModeH5Popup = "h5_popup"
SessionActive = "active"
AdapterDemo = "demo"
AdapterYomiV4 = "yomi_v4"

View File

@ -367,7 +367,7 @@ func normalizeCatalog(item gamedomain.CatalogItem) (gamedomain.CatalogItem, erro
item.GameName = strings.TrimSpace(item.GameName)
item.Status = normalizeStatus(item.Status)
if item.LaunchMode == "" {
item.LaunchMode = gamedomain.LaunchModeH5Popup
item.LaunchMode = gamedomain.LaunchModeFullScreen
}
if item.Orientation == "" {
item.Orientation = "portrait"

View File

@ -0,0 +1,79 @@
import http from "node:http";
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const htmlPath = join(__dirname, "baishun-game-test.html");
const port = Number(process.env.PORT || 8787);
const upstreamBase = (process.env.BAISHUN_API_BASE || "https://api-test.global-interaction.com").replace(/\/+$/, "");
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
function copyProxyHeaders(req) {
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (!value) continue;
const lower = key.toLowerCase();
if (["host", "origin", "referer", "connection", "content-length"].includes(lower)) continue;
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
}
return headers;
}
async function proxyApi(req, res) {
const targetUrl = `${upstreamBase}${req.url}`;
const body = req.method === "GET" || req.method === "HEAD" ? undefined : await readBody(req);
const upstream = await fetch(targetUrl, {
method: req.method,
headers: copyProxyHeaders(req),
body,
redirect: "manual",
});
const responseBody = Buffer.from(await upstream.arrayBuffer());
res.writeHead(upstream.status, {
"Content-Type": upstream.headers.get("content-type") || "application/octet-stream",
"Cache-Control": "no-store",
});
res.end(responseBody);
}
const server = http.createServer(async (req, res) => {
try {
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
if (req.url === "/" || req.url === "/baishun-game-test.html") {
const html = await readFile(htmlPath);
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
});
res.end(html);
return;
}
if (req.url?.startsWith("/api/")) {
await proxyApi(req, res);
return;
}
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("not found");
} catch (err) {
res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" });
res.end(err instanceof Error ? err.message : String(err));
}
});
server.listen(port, "127.0.0.1", () => {
console.log(`Baishun game test page: http://127.0.0.1:${port}`);
console.log(`Proxy upstream: ${upstreamBase}`);
});

View File

@ -0,0 +1,370 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>百顺游戏测试页</title>
<style>
:root {
color-scheme: light;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f4f7fb;
color: #172033;
}
* { box-sizing: border-box; }
body { margin: 0; padding: 24px; }
main { max-width: 1120px; margin: 0 auto; display: grid; gap: 16px; }
h1 { margin: 0; font-size: 24px; }
h2 { margin: 0 0 12px; font-size: 16px; }
section {
border: 1px solid #d9e2ef;
border-radius: 8px;
background: #fff;
padding: 16px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
}
label { display: grid; gap: 6px; color: #53627a; font-size: 13px; font-weight: 700; }
input, select, textarea {
width: 100%;
border: 1px solid #cad6e6;
border-radius: 8px;
padding: 10px 12px;
font: inherit;
color: #172033;
background: #fbfdff;
}
textarea { min-height: 88px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
button {
border: 1px solid #2563eb;
border-radius: 8px;
padding: 10px 14px;
font: inherit;
font-weight: 800;
color: #fff;
background: #2563eb;
cursor: pointer;
}
button.secondary { color: #2563eb; background: #fff; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.muted { color: #697891; font-size: 13px; }
.status { min-height: 22px; color: #334155; font-size: 13px; }
.status.error { color: #dc2626; }
.status.ok { color: #16a34a; }
.games { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
.game {
display: grid;
gap: 8px;
align-content: start;
border: 1px solid #d9e2ef;
border-radius: 8px;
padding: 10px;
background: #fbfdff;
}
.game.selected { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); }
.game img { width: 64px; height: 64px; border-radius: 8px; object-fit: cover; background: #eef3fa; }
.game-title { font-weight: 850; overflow-wrap: anywhere; }
.pill { display: inline-flex; width: fit-content; border-radius: 999px; padding: 2px 8px; background: #eef3ff; color: #2563eb; font-size: 12px; font-weight: 800; }
iframe { width: 100%; min-height: 720px; border: 1px solid #d9e2ef; border-radius: 8px; background: #fff; }
@media (max-width: 760px) {
body { padding: 12px; }
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<header>
<h1>百顺游戏测试页</h1>
<div class="muted">测试服:<code>https://api-test.global-interaction.com</code>;本地代理打开时 API Base URL 留空即可</div>
</header>
<section>
<h2>1. API 与账号</h2>
<div class="grid">
<label>
API Base URL
<input id="apiBase" value="https://api-test.global-interaction.com" placeholder="本地代理模式留空,直接用 /api/v1/..." />
</label>
<label>
X-App-Code
<input id="appCode" value="lalu" />
</label>
<label>
用户名 / UID
<input id="account" placeholder="用于账号密码登录" />
</label>
<label>
密码
<input id="password" type="password" placeholder="用于登录或快捷创建" />
</label>
</div>
<label style="margin-top:12px">
Access Token
<textarea id="token" placeholder="可以直接粘贴 App 登录 access_token也可以点击下方登录/快捷创建自动填充"></textarea>
</label>
<div class="actions" style="margin-top:12px">
<button id="loginBtn" type="button">用户名密码登录</button>
<button id="quickCreateBtn" class="secondary" type="button">快捷创建测试账号</button>
<span id="authStatus" class="status"></span>
</div>
</section>
<section>
<h2>2. 获取游戏列表</h2>
<div class="grid">
<label>
scene
<input id="scene" value="voice_room" />
</label>
<label>
room_id
<input id="roomId" value="test_room_1" />
</label>
</div>
<div class="actions" style="margin-top:12px">
<button id="loadGamesBtn" type="button">获取 /api/v1/games</button>
<span id="gamesStatus" class="status"></span>
</div>
<div id="games" class="games" style="margin-top:12px"></div>
</section>
<section>
<h2>3. 启动游戏</h2>
<div class="actions">
<button id="launchBtn" type="button" disabled>启动选中游戏</button>
<button id="openBtn" class="secondary" type="button" disabled>新窗口打开</button>
<span id="launchStatus" class="status"></span>
</div>
<label style="margin-top:12px">
launch_url
<textarea id="launchUrl" readonly></textarea>
</label>
<iframe id="gameFrame" title="game preview"></iframe>
</section>
</main>
<script>
const state = {
games: [],
selectedGameId: "",
launchUrl: "",
};
const $ = (id) => document.getElementById(id);
const defaultApiBase = "https://api-test.global-interaction.com";
const isLocalProxy = ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname);
$("token").value = localStorage.getItem("baishun_test_access_token") || "";
$("account").value = localStorage.getItem("baishun_test_account") || "";
const savedApiBase = localStorage.getItem("baishun_test_api_base") || "";
$("apiBase").value = isLocalProxy && (!savedApiBase || savedApiBase === defaultApiBase) ? "" : (savedApiBase || defaultApiBase);
$("appCode").value = localStorage.getItem("baishun_test_app_code") || $("appCode").value;
function apiBase() {
return $("apiBase").value.trim().replace(/\/+$/, "");
}
function appCode() {
return $("appCode").value.trim() || "lalu";
}
function token() {
return $("token").value.trim();
}
function setStatus(id, message, type = "") {
const el = $(id);
el.textContent = message || "";
el.className = ["status", type].filter(Boolean).join(" ");
}
async function request(path, options = {}) {
const headers = {
"Content-Type": "application/json",
"X-App-Code": appCode(),
"X-App-Platform": "web",
"X-App-Language": "en",
...(options.headers || {}),
};
if (options.auth !== false && token()) {
headers.Authorization = `Bearer ${token()}`;
}
const response = await fetch(`${apiBase()}${path}`, {
method: options.method || "GET",
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
});
const text = await response.text();
let payload = {};
try {
payload = text ? JSON.parse(text) : {};
} catch {
payload = { message: text };
}
if (!response.ok || payload.code !== 0) {
throw new Error(payload.message || response.statusText || `HTTP ${response.status}`);
}
return payload.data;
}
function saveToken(data) {
const accessToken = data?.access_token || data?.token?.access_token || "";
if (!accessToken) {
throw new Error("登录成功但响应里没有 access_token");
}
$("token").value = accessToken;
localStorage.setItem("baishun_test_access_token", accessToken);
if (data.account || data.uid) {
$("account").value = data.account || data.uid;
localStorage.setItem("baishun_test_account", $("account").value);
}
localStorage.setItem("baishun_test_api_base", apiBase());
localStorage.setItem("baishun_test_app_code", appCode());
}
async function login() {
setStatus("authStatus", "登录中...");
const account = $("account").value.trim();
const password = $("password").value;
if (!account || !password) {
setStatus("authStatus", "请填写用户名/UID 和密码", "error");
return;
}
try {
const data = await request("/api/v1/auth/account/login", {
method: "POST",
auth: false,
body: {
account,
password,
device_id: "baishun-html-test",
platform: "web",
language: "en",
timezone: "Asia/Shanghai",
},
});
saveToken(data);
setStatus("authStatus", "登录成功token 已填入", "ok");
} catch (err) {
setStatus("authStatus", err.message, "error");
}
}
async function quickCreate() {
setStatus("authStatus", "创建中...");
const password = $("password").value || "Test@123456";
try {
const data = await request("/api/v1/auth/account/quick-create", {
method: "POST",
auth: false,
body: {
password,
username: `baishun_html_${Date.now()}`,
device_id: "baishun-html-test",
platform: "web",
language: "en",
timezone: "Asia/Shanghai",
},
});
$("password").value = password;
saveToken(data);
setStatus("authStatus", `创建成功,账号/UID${data.account || data.uid || ""}`, "ok");
} catch (err) {
setStatus("authStatus", err.message, "error");
}
}
async function loadGames() {
setStatus("gamesStatus", "加载中...");
state.selectedGameId = "";
state.launchUrl = "";
$("launchBtn").disabled = true;
$("openBtn").disabled = true;
$("launchUrl").value = "";
$("gameFrame").src = "about:blank";
try {
const query = new URLSearchParams();
if ($("scene").value.trim()) query.set("scene", $("scene").value.trim());
if ($("roomId").value.trim()) query.set("room_id", $("roomId").value.trim());
const data = await request(`/api/v1/games?${query.toString()}`);
state.games = Array.isArray(data.games) ? data.games : [];
renderGames();
setStatus("gamesStatus", `已获取 ${state.games.length} 个游戏`, "ok");
} catch (err) {
$("games").innerHTML = "";
setStatus("gamesStatus", err.message, "error");
}
}
function renderGames() {
$("games").innerHTML = "";
for (const game of state.games) {
const card = document.createElement("button");
card.type = "button";
card.className = `game ${state.selectedGameId === game.game_id ? "selected" : ""}`;
card.innerHTML = `
${game.icon_url ? `<img src="${escapeHtml(game.icon_url)}" alt="">` : ""}
<span class="game-title">${escapeHtml(game.name || game.game_id)}</span>
<span class="muted">${escapeHtml(game.game_id || "")}</span>
<span class="pill">${escapeHtml(game.platform_code || "")}</span>
<span class="muted">${escapeHtml(game.enabled ? "可启动" : game.maintenance ? "维护" : "不可用")}</span>
`;
card.onclick = () => {
state.selectedGameId = game.game_id;
$("launchBtn").disabled = false;
renderGames();
};
$("games").appendChild(card);
}
}
async function launchSelected() {
const gameId = state.selectedGameId;
if (!gameId) {
setStatus("launchStatus", "请先选择游戏", "error");
return;
}
setStatus("launchStatus", "启动中...");
try {
const data = await request(`/api/v1/games/${encodeURIComponent(gameId)}/launch`, {
method: "POST",
body: {
scene: $("scene").value.trim() || "voice_room",
room_id: $("roomId").value.trim() || "test_room_1",
},
});
state.launchUrl = data.launch_url || "";
$("launchUrl").value = state.launchUrl;
$("openBtn").disabled = !state.launchUrl;
if (state.launchUrl) {
$("gameFrame").src = state.launchUrl;
setStatus("launchStatus", "启动成功", "ok");
} else {
setStatus("launchStatus", "接口成功,但 launch_url 为空", "error");
}
} catch (err) {
setStatus("launchStatus", err.message, "error");
}
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
$("loginBtn").onclick = login;
$("quickCreateBtn").onclick = quickCreate;
$("loadGamesBtn").onclick = loadGames;
$("launchBtn").onclick = launchSelected;
$("openBtn").onclick = () => {
if (state.launchUrl) window.open(state.launchUrl, "_blank", "noopener,noreferrer");
};
</script>
</body>
</html>