完善后端联调链路与模拟器多通道支持

This commit is contained in:
2026-04-01 18:48:59 +08:00
parent 94a1f0ba78
commit a70dc8d5d0
51 changed files with 4037 additions and 197 deletions

View File

@@ -0,0 +1,375 @@
import { normalizeBackendBaseUrl } from './backendAuth'
export interface BackendApiError {
statusCode: number
code: string
message: string
details?: unknown
}
export interface BackendAuthLoginResult {
user?: {
id?: string
nickname?: string
avatarUrl?: string
}
tokens: {
accessToken: string
refreshToken: string
}
}
export interface BackendResolvedRelease {
launchMode: string
source: string
eventId: string
releaseId: string
configLabel: string
manifestUrl: string
manifestChecksumSha256?: string | null
routeCode?: string | null
}
export interface BackendEntrySessionSummary {
id: string
status: string
eventId?: string
eventName?: string
releaseId?: string | null
configLabel?: string | null
routeCode?: string | null
launchedAt?: string | null
startedAt?: string | null
endedAt?: string | null
// 兼容前端旧字段名,避免联调过渡期多处判断
sessionId?: string
sessionStatus?: string
eventDisplayName?: string
}
export interface BackendCardResult {
id: string
type: string
title: string
subtitle?: string | null
coverUrl?: string | null
displaySlot: string
displayPriority: number
event?: {
id: string
displayName: string
summary?: string | null
} | null
htmlUrl?: string | null
}
export interface BackendEntryHomeResult {
user: {
id: string
publicId: string
status: string
nickname?: string | null
avatarUrl?: string | null
}
tenant: {
id: string
code: string
name: string
}
channel: {
id: string
code: string
type: string
platformAppId?: string | null
displayName: string
status: string
isDefault: boolean
}
cards: BackendCardResult[]
ongoingSession?: BackendEntrySessionSummary | null
recentSession?: BackendEntrySessionSummary | null
}
export interface BackendEventPlayResult {
event: {
id: string
slug: string
displayName: string
summary?: string | null
status: string
}
release?: {
id: string
configLabel: string
manifestUrl: string
manifestChecksumSha256?: string | null
routeCode?: string | null
} | null
resolvedRelease?: BackendResolvedRelease | null
play: {
canLaunch: boolean
primaryAction: string
reason: string
launchSource?: string
ongoingSession?: BackendEntrySessionSummary | null
recentSession?: BackendEntrySessionSummary | null
}
}
export interface BackendLaunchResult {
event: {
id: string
displayName: string
}
launch: {
source: string
resolvedRelease?: BackendResolvedRelease | null
config: {
configUrl: string
configLabel: string
configChecksumSha256?: string | null
releaseId: string
routeCode?: string | null
}
business: {
source: string
eventId: string
sessionId: string
sessionToken: string
sessionTokenExpiresAt: string
routeCode?: string | null
}
}
}
export interface BackendSessionFinishSummaryPayload {
finalDurationSec?: number
finalScore?: number
completedControls?: number
totalControls?: number
distanceMeters?: number
averageSpeedKmh?: number
maxHeartRateBpm?: number
}
export interface BackendSessionResult {
session: {
id: string
status: string
clientType: string
deviceKey: string
routeCode?: string | null
sessionTokenExpiresAt: string
launchedAt: string
startedAt?: string | null
endedAt?: string | null
}
event: {
id: string
displayName: string
}
resolvedRelease?: BackendResolvedRelease | null
}
export interface BackendSessionResultView {
session: BackendEntrySessionSummary
result: {
status: string
finalDurationSec?: number
finalScore?: number
completedControls?: number
totalControls?: number
distanceMeters?: number
averageSpeedKmh?: number
maxHeartRateBpm?: number
summary?: Record<string, unknown>
}
}
type BackendEnvelope<T> = {
data: T
}
type RequestOptions = {
method: 'GET' | 'POST'
baseUrl: string
path: string
authToken?: string
body?: Record<string, unknown>
}
function requestBackend<T>(options: RequestOptions): Promise<T> {
const url = `${normalizeBackendBaseUrl(options.baseUrl)}${options.path}`
const header: Record<string, string> = {}
if (options.body) {
header['Content-Type'] = 'application/json'
}
if (options.authToken) {
header.Authorization = `Bearer ${options.authToken}`
}
return new Promise<T>((resolve, reject) => {
wx.request({
url,
method: options.method,
header,
data: options.body,
success: (response) => {
const statusCode = typeof response.statusCode === 'number' ? response.statusCode : 0
const data = response.data as BackendEnvelope<T> | { error?: { code?: string; message?: string; details?: unknown } }
if (statusCode >= 200 && statusCode < 300 && data && typeof data === 'object' && 'data' in data) {
resolve((data as BackendEnvelope<T>).data)
return
}
const errorPayload = data && typeof data === 'object' && 'error' in data
? (data as { error?: { code?: string; message?: string; details?: unknown } }).error
: undefined
reject({
statusCode,
code: errorPayload && errorPayload.code ? errorPayload.code : 'backend_error',
message: errorPayload && errorPayload.message ? errorPayload.message : `request failed: ${statusCode}`,
details: errorPayload && errorPayload.details ? errorPayload.details : response.data,
} as BackendApiError)
},
fail: (error) => {
reject({
statusCode: 0,
code: 'network_error',
message: error && error.errMsg ? error.errMsg : 'network request failed',
} as BackendApiError)
},
})
})
}
export function loginWechatMini(input: {
baseUrl: string
code: string
deviceKey: string
clientType?: string
}): Promise<BackendAuthLoginResult> {
return requestBackend<BackendAuthLoginResult>({
method: 'POST',
baseUrl: input.baseUrl,
path: '/auth/login/wechat-mini',
body: {
code: input.code,
clientType: input.clientType || 'wechat',
deviceKey: input.deviceKey,
},
})
}
export function getEventPlay(input: {
baseUrl: string
eventId: string
accessToken: string
}): Promise<BackendEventPlayResult> {
return requestBackend<BackendEventPlayResult>({
method: 'GET',
baseUrl: input.baseUrl,
path: `/events/${encodeURIComponent(input.eventId)}/play`,
authToken: input.accessToken,
})
}
export function getEntryHome(input: {
baseUrl: string
accessToken: string
channelCode: string
channelType: string
}): Promise<BackendEntryHomeResult> {
const query = `channelCode=${encodeURIComponent(input.channelCode)}&channelType=${encodeURIComponent(input.channelType)}`
return requestBackend<BackendEntryHomeResult>({
method: 'GET',
baseUrl: input.baseUrl,
path: `/me/entry-home?${query}`,
authToken: input.accessToken,
})
}
export function launchEvent(input: {
baseUrl: string
eventId: string
accessToken: string
releaseId?: string
clientType: string
deviceKey: string
}): Promise<BackendLaunchResult> {
const body: Record<string, unknown> = {
clientType: input.clientType,
deviceKey: input.deviceKey,
}
if (input.releaseId) {
body.releaseId = input.releaseId
}
return requestBackend<BackendLaunchResult>({
method: 'POST',
baseUrl: input.baseUrl,
path: `/events/${encodeURIComponent(input.eventId)}/launch`,
authToken: input.accessToken,
body,
})
}
export function startSession(input: {
baseUrl: string
sessionId: string
sessionToken: string
}): Promise<BackendSessionResult> {
return requestBackend<BackendSessionResult>({
method: 'POST',
baseUrl: input.baseUrl,
path: `/sessions/${encodeURIComponent(input.sessionId)}/start`,
body: {
sessionToken: input.sessionToken,
},
})
}
export function finishSession(input: {
baseUrl: string
sessionId: string
sessionToken: string
status: 'finished' | 'failed' | 'cancelled'
summary: BackendSessionFinishSummaryPayload
}): Promise<BackendSessionResult> {
return requestBackend<BackendSessionResult>({
method: 'POST',
baseUrl: input.baseUrl,
path: `/sessions/${encodeURIComponent(input.sessionId)}/finish`,
body: {
sessionToken: input.sessionToken,
status: input.status,
summary: input.summary,
},
})
}
export function getSessionResult(input: {
baseUrl: string
accessToken: string
sessionId: string
}): Promise<BackendSessionResultView> {
return requestBackend<BackendSessionResultView>({
method: 'GET',
baseUrl: input.baseUrl,
path: `/sessions/${encodeURIComponent(input.sessionId)}/result`,
authToken: input.accessToken,
})
}
export function getMyResults(input: {
baseUrl: string
accessToken: string
limit?: number
}): Promise<BackendSessionResultView[]> {
const limit = typeof input.limit === 'number' ? input.limit : 20
return requestBackend<BackendSessionResultView[]>({
method: 'GET',
baseUrl: input.baseUrl,
path: `/me/results?limit=${encodeURIComponent(String(limit))}`,
authToken: input.accessToken,
})
}

View File

@@ -0,0 +1,86 @@
export interface BackendAuthTokens {
accessToken: string
refreshToken: string
}
const BACKEND_BASE_URL_STORAGE_KEY = 'cmr.backend.baseUrl.v1'
const BACKEND_AUTH_TOKENS_STORAGE_KEY = 'cmr.backend.authTokens.v1'
const DEFAULT_BACKEND_BASE_URL = 'https://api.gotomars.xyz'
const LEGACY_LOCAL_BACKEND_BASE_URLS = [
'http://127.0.0.1:8080',
'https://127.0.0.1:8080',
'http://localhost:8080',
'https://localhost:8080',
]
function normalizeString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
export function normalizeBackendBaseUrl(value: unknown): string {
const normalized = normalizeString(value).replace(/\/+$/, '')
if (LEGACY_LOCAL_BACKEND_BASE_URLS.indexOf(normalized) >= 0) {
return DEFAULT_BACKEND_BASE_URL
}
return normalized || DEFAULT_BACKEND_BASE_URL
}
export function loadBackendBaseUrl(): string {
try {
const stored = wx.getStorageSync(BACKEND_BASE_URL_STORAGE_KEY)
const normalized = normalizeBackendBaseUrl(stored)
if (normalized !== stored && normalized === DEFAULT_BACKEND_BASE_URL) {
wx.setStorageSync(BACKEND_BASE_URL_STORAGE_KEY, normalized)
}
return normalized
} catch {
return DEFAULT_BACKEND_BASE_URL
}
}
export function saveBackendBaseUrl(baseUrl: string): string {
const normalized = normalizeBackendBaseUrl(baseUrl)
try {
wx.setStorageSync(BACKEND_BASE_URL_STORAGE_KEY, normalized)
} catch {}
return normalized
}
export function loadBackendAuthTokens(): BackendAuthTokens | null {
try {
const stored = wx.getStorageSync(BACKEND_AUTH_TOKENS_STORAGE_KEY)
if (!stored || typeof stored !== 'object') {
return null
}
const accessToken = normalizeString((stored as Record<string, unknown>).accessToken)
const refreshToken = normalizeString((stored as Record<string, unknown>).refreshToken)
if (!accessToken || !refreshToken) {
return null
}
return {
accessToken,
refreshToken,
}
} catch {
return null
}
}
export function saveBackendAuthTokens(tokens: BackendAuthTokens): BackendAuthTokens {
const normalized = {
accessToken: normalizeString(tokens.accessToken),
refreshToken: normalizeString(tokens.refreshToken),
}
try {
wx.setStorageSync(BACKEND_AUTH_TOKENS_STORAGE_KEY, normalized)
} catch {}
return normalized
}
export function clearBackendAuthTokens() {
try {
wx.removeStorageSync(BACKEND_AUTH_TOKENS_STORAGE_KEY)
} catch {}
}

View File

@@ -0,0 +1,21 @@
import { type GameLaunchEnvelope } from './gameLaunch'
import { type BackendLaunchResult } from './backendApi'
export function adaptBackendLaunchResultToEnvelope(result: BackendLaunchResult): GameLaunchEnvelope {
return {
config: {
configUrl: result.launch.config.configUrl,
configLabel: result.launch.config.configLabel,
configChecksumSha256: result.launch.config.configChecksumSha256 || null,
releaseId: result.launch.config.releaseId,
routeCode: result.launch.config.routeCode || null,
},
business: {
source: result.launch.business.source === 'direct-event' ? 'direct-event' : 'custom',
eventId: result.launch.business.eventId,
sessionId: result.launch.business.sessionId,
sessionToken: result.launch.business.sessionToken,
sessionTokenExpiresAt: result.launch.business.sessionTokenExpiresAt,
},
}
}