This commit is contained in:
richarjiang
2026-07-15 17:12:04 +08:00
commit f8c8c688a1
26 changed files with 6789 additions and 0 deletions

View File

@@ -0,0 +1,297 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AvailabilityQuery } from '../../../shared/contracts'
import {
buildAvailabilityRequestBody,
buildCreateBookingRequestBody,
fansiboteFuzhongAdapter,
parseCreateBookingResponse,
parseAvailabilityResponse
} from './adapter'
const query: AvailabilityQuery = {
courtId: 'fansibote-fuzhong',
date: '2026-07-16'
}
function createSlot(status: number, canApptOrNot: boolean) {
return {
classroomUid: 1775556146897330236,
txtClassroomUid: '1775556146897330236',
classRoomName: '1号风雨场',
beginDatetime: '2026-07-16 08:00:00',
endDatetime: '2026-07-16 08:59:00',
cost: 80,
status,
apptInfo: {
canApptOrNot,
customerNames: ['王小球'],
hadVenueAppts: [{
customerName: '王小球',
customerTel: '13800000000'
}]
}
}
}
describe('范思伯特福中福响应映射', () => {
afterEach(() => vi.unstubAllGlobals())
it('把内部场地 ID 映射为第三方 userId 字段', () => {
const body = buildAvailabilityRequestBody('2026-07-16')
expect(body).toEqual({
dateTime: '2026-07-16',
userId: 6038652,
projectUid: '1775556129325750199'
})
})
it('列表请求固定使用适配器私有的场地 ID', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ successed: true, result: { slots: [] } })
})
vi.stubGlobal('fetch', fetchMock)
await fansiboteFuzhongAdapter.getAvailability(query, {
courtId: query.courtId
})
expect(fetchMock).toHaveBeenCalledOnce()
const request = fetchMock.mock.calls[0]?.[1] as RequestInit
expect(request.headers).toEqual(expect.objectContaining({
STOREID: '6038652'
}))
expect(JSON.parse(String(request.body))).toEqual({
dateTime: '2026-07-16',
userId: 6038652,
projectUid: '1775556129325750199'
})
})
it('使用实时重新查询得到的场地引用和价格构造下单请求', () => {
const slot = parseAvailabilityResponse({
successed: true,
result: { slots: [createSlot(0, true)] }
}, query).slots[0]!
expect(buildCreateBookingRequestBody(slot)).toEqual({
userId: 6038652,
projectType: 0,
classroomItems: [{
classroomUid: '1775556146897330236',
beginDatetime: '2026-07-16 08:00:00',
endDatetime: '2026-07-16 08:59:00',
peopleNum: 1
}],
remark: '',
combinationPayments: [{ paymentMethod: 900, cost: 80 }]
})
})
it('把下单成功响应映射为待支付锁场,不暴露支付脚本给公共订单', () => {
const slot = parseAvailabilityResponse({
successed: true,
result: { slots: [createSlot(0, true)] }
}, query).slots[0]!
const result = parseCreateBookingResponse({
successed: true,
result: {
apptUid: '1784102970857723008',
script: {
expireTime: '2026-07-15 16:24:31',
paySign: 'private-pay-sign'
},
venueAppointUids: ['1784102970857723008']
}
}, slot, query.courtId)
expect(result.booking).toEqual({
courtId: query.courtId,
orderId: '1784102970857723008',
venueAppointmentIds: ['1784102970857723008'],
amountCents: 8000,
courtLabel: '1号风雨场',
startTime: '08:00',
endTime: '08:59',
expiresAt: '2026-07-15 16:24:31',
status: 'pending-payment'
})
expect(JSON.stringify(result.booking)).not.toContain('private-pay-sign')
})
it('下单和取消都注入访客凭证,取消请求体严格为空对象', async () => {
const slot = parseAvailabilityResponse({
successed: true,
result: { slots: [createSlot(0, true)] }
}, query).slots[0]!
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
successed: true,
result: {
apptUid: 'order-1',
script: { expireTime: '2026-07-15 16:24:31' },
venueAppointUids: ['order-1']
}
})
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ successed: true })
})
vi.stubGlobal('fetch', fetchMock)
const settings = {
courtId: query.courtId,
visitorId: 'visitor-token'
}
await fansiboteFuzhongAdapter.createBooking!(slot, settings)
await fansiboteFuzhongAdapter.cancelBooking!(settings)
const createRequest = fetchMock.mock.calls[0]?.[1] as RequestInit
const cancelRequest = fetchMock.mock.calls[1]?.[1] as RequestInit
expect(createRequest.headers).toEqual(expect.objectContaining({
PSPLVISITORID: 'visitor-token',
STOREID: '6038652',
VERSIONINFO: 'NC|2026.07.13',
xweb_xhr: '1',
'Sec-Fetch-Site': 'cross-site',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Dest': 'empty',
Referer: 'https://servicewechat.com/wx2088ca46b47d8be7/12/page-frame.html',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Mac MacWechat/WMPF MacWechat/3.8.7(0x13080712) UnifiedPCMacWechat(0xf2641b36) XWEB/25193'
}))
expect(cancelRequest.headers).toEqual(expect.objectContaining({
PSPLVISITORID: 'visitor-token',
STOREID: '6038652',
VERSIONINFO: 'NC|2026.07.13',
Referer: 'https://servicewechat.com/wx2088ca46b47d8be7/12/page-frame.html'
}))
expect(cancelRequest.body).toBe('{}')
})
it('未配置访客凭证时拒绝产生下单请求', async () => {
const slot = parseAvailabilityResponse({
successed: true,
result: { slots: [createSlot(0, true)] }
}, query).slots[0]!
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(fansiboteFuzhongAdapter.createBooking!(slot, {
courtId: query.courtId
})).rejects.toThrow('PSPLVISITORID')
expect(fetchMock).not.toHaveBeenCalled()
})
it('保留字符串 UID、价格和时间可订时段不携带预约人信息', () => {
const result = parseAvailabilityResponse(
{
successed: true,
result: { slots: [createSlot(0, true)] }
},
query
)
expect(result.slots).toEqual([
expect.objectContaining({
id: '1775556146897330236:2026-07-16 08:00:00',
courtLabel: '1号风雨场',
startTime: '08:00',
endTime: '08:59',
priceCents: 8000,
status: 'available'
})
])
expect(JSON.stringify(result)).not.toContain('13800000000')
expect(JSON.stringify(result)).not.toContain('王小球')
})
it('区分已预约与排课', () => {
const result = parseAvailabilityResponse(
{
successed: true,
result: {
slots: [createSlot(1, false), createSlot(4, false)]
}
},
query
)
expect(result.slots.map(({ status, unavailableLabel, bookedBy }) => ({
status,
unavailableLabel,
bookedBy
}))).toEqual([
{
status: 'booked',
unavailableLabel: '已订',
bookedBy: [{ name: '王小球', phone: '13800000000' }]
},
{ status: 'blocked', unavailableLabel: '排课', bookedBy: undefined }
])
})
it('把跨小时活动合并到同一子场的每个对应时段', () => {
const firstSlot = createSlot(0, true)
const secondSlot = {
...createSlot(0, true),
beginDatetime: '2026-07-16 09:00:00',
endDatetime: '2026-07-16 09:59:00'
}
const result = parseAvailabilityResponse(
{
successed: true,
result: {
slots: [firstSlot, secondSlot],
enrollSlots: [{
txtClassroomUid: '1775556146897330236',
beginDatetime: '2026-07-16 08:00:00',
endDatetime: '2026-07-16 09:59:00',
enrollmentRule: {
uidTxt: '1784027073124705081',
title: '【2.5双打】福中福8-10',
beginDate: '2026-07-16 08:00:00',
endDate: '2026-07-16 10:00:00',
enrollmentEndDate: '2026-07-16 07:00:00',
enrollmentFee: 70,
enrollmentCount: 3,
enrollmentLimitNum: 4,
customerModels: [{ name: '星' }, { name: '浩哥' }]
}
}]
}
},
query
)
expect(result.slots.map((slot) => slot.enrollment)).toEqual([
{
id: '1784027073124705081',
title: '【2.5双打】福中福8-10',
startTime: '08:00',
endTime: '10:00',
feeCents: 7000,
enrolledCount: 3,
capacity: 4,
participantNames: ['星', '浩哥'],
enrollmentDeadline: '07:00'
},
expect.objectContaining({ id: '1784027073124705081' })
])
})
it('接口字段变化时明确失败,不静默丢时段', () => {
expect(() => parseAvailabilityResponse(
{
successed: true,
result: { slots: [{ classRoomName: '字段缺失' }] }
},
query
)).toThrow('无法识别的时段数据')
})
})

View File

@@ -0,0 +1,525 @@
import { createHash } from 'node:crypto'
import type {
AvailabilityDay,
AvailabilityQuery,
BookingContact,
BookingSlot,
CourtSummary,
EnrollmentActivity
} from '../../../shared/contracts'
import type { CourtRuntimeSettings } from '../../settings/types'
import { ProviderMutationError, type AdapterBookingResult, type CourtAdapter } from '../types'
const AVAILABILITY_ENDPOINT =
'https://wxservice-stg48.pospal.cn/wxapi/AppointmentVenue/LoadValidClassRoomApptSettingV2'
const CREATE_BOOKING_ENDPOINT =
'https://wxservice-stg48.pospal.cn/wxapi/AppointmentVenue/SaveVenueAppointmentV2'
const CANCEL_BOOKING_ENDPOINT =
'https://wxservice-stg48.pospal.cn/wxapi/AppointmentVenue/CrmAutoReleaseVenueAppoint'
const DEFAULT_VENUE_ID = '6038652'
const PROJECT_UID = '1775556129325750199'
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2} (\d{2}:\d{2}):\d{2}$/
const WECHAT_USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Mac MacWechat/WMPF MacWechat/3.8.7(0x13080712) UnifiedPCMacWechat(0xf2641b36) XWEB/25193'
export const fansiboteFuzhongCourt: CourtSummary = {
id: 'fansibote-fuzhong',
name: '范思伯特福中福',
shortName: '范思',
accent: '#D8FF63',
status: 'online'
}
const defaultSettings: CourtRuntimeSettings = {
courtId: fansiboteFuzhongCourt.id
}
interface PospalSlot {
txtClassroomUid: unknown
classRoomName: unknown
beginDatetime: unknown
endDatetime: unknown
cost: unknown
status: unknown
apptInfo: unknown
}
interface PospalResponse {
successed?: unknown
status?: unknown
errorCode?: unknown
result?: unknown
}
interface ParsedEnrollment {
classroomUid: string
beginDatetime: string
endDatetime: string
activity: EnrollmentActivity
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
const SENSITIVE_LOG_KEY = /visitor|paysign|prepay|nonce|package|token|cookie|authorization|customer|phone|tel/i
function redactForLog(value: unknown, key = ''): unknown {
if (SENSITIVE_LOG_KEY.test(key)) return '[REDACTED]'
if (Array.isArray(value)) return value.map((item) => redactForLog(item))
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => [
entryKey,
redactForLog(entryValue, entryKey)
])
)
}
return value
}
function logDevRequest(event: string, details: Record<string, unknown>) {
if (!import.meta.env.DEV) return
console.info(
`[tennis-book][fansibote-fuzhong][${event}]`,
JSON.stringify(redactForLog(details), null, 2)
)
}
function credentialFingerprint(visitorId: string) {
return createHash('sha256').update(visitorId).digest('hex').slice(0, 12)
}
function providerErrorDetail(payload: unknown): string | null {
if (!isRecord(payload)) return null
const keys = ['message', 'msg', 'errorMessage', 'errorMsg', 'status', 'errorCode']
const details = keys.flatMap((key) => {
const value = payload[key]
return typeof value === 'string' || typeof value === 'number'
? [`${key}=${String(value).slice(0, 200)}`]
: []
})
return details.length > 0 ? details.join(', ') : null
}
function extractTime(value: unknown): string | null {
if (typeof value !== 'string') return null
return DATE_TIME_PATTERN.exec(value)?.[1] ?? null
}
function parseBookingContacts(appointment: Record<string, unknown>): BookingContact[] {
const venueAppointments = Array.isArray(appointment.hadVenueAppts)
? appointment.hadVenueAppts
: []
const contacts: BookingContact[] = []
for (const venueAppointment of venueAppointments) {
if (!isRecord(venueAppointment)) continue
const name = typeof venueAppointment.customerName === 'string'
? venueAppointment.customerName.trim()
: ''
const phone = typeof venueAppointment.customerTel === 'string'
? venueAppointment.customerTel.trim()
: ''
if (name || phone) contacts.push({
name: name || undefined,
phone: phone || undefined
})
}
if (contacts.length === 0 && Array.isArray(appointment.customerNames)) {
for (const value of appointment.customerNames) {
if (typeof value === 'string' && value.trim()) {
contacts.push({ name: value.trim() })
}
}
}
return contacts.filter((contact, index) =>
contacts.findIndex((candidate) =>
candidate.name === contact.name && candidate.phone === contact.phone
) === index
)
}
function parseParticipantNames(value: unknown): string[] {
if (!Array.isArray(value)) return []
const names = value.flatMap((customer) => {
if (!isRecord(customer) || typeof customer.name !== 'string') return []
const name = customer.name.trim()
return name ? [name] : []
})
return [...new Set(names)]
}
function parseEnrollment(value: unknown): ParsedEnrollment {
if (!isRecord(value) || !isRecord(value.enrollmentRule)) {
throw new Error('球场接口返回了无法识别的活动数据')
}
const rule = value.enrollmentRule
const startTime = extractTime(rule.beginDate)
const endTime = extractTime(rule.endDate)
const deadline = extractTime(rule.enrollmentEndDate)
const enrolledCount = typeof rule.enrollmentCount === 'number'
? rule.enrollmentCount
: typeof rule.ParticipateCount === 'number'
? rule.ParticipateCount
: 0
if (
typeof value.txtClassroomUid !== 'string' ||
typeof value.beginDatetime !== 'string' ||
typeof value.endDatetime !== 'string' ||
typeof rule.uidTxt !== 'string' ||
typeof rule.title !== 'string' ||
startTime === null ||
endTime === null ||
typeof rule.enrollmentFee !== 'number' ||
!Number.isFinite(rule.enrollmentFee) ||
typeof rule.enrollmentLimitNum !== 'number' ||
!Number.isFinite(rule.enrollmentLimitNum)
) {
throw new Error('球场接口返回了无法识别的活动数据')
}
return {
classroomUid: value.txtClassroomUid,
beginDatetime: value.beginDatetime,
endDatetime: value.endDatetime,
activity: {
id: rule.uidTxt,
title: rule.title,
startTime,
endTime,
feeCents: Math.round(rule.enrollmentFee * 100),
enrolledCount,
capacity: rule.enrollmentLimitNum,
participantNames: parseParticipantNames(rule.customerModels),
enrollmentDeadline: deadline ?? undefined
}
}
}
function parseSlot(value: unknown, enrollments: ParsedEnrollment[]): BookingSlot {
if (!isRecord(value)) throw new Error('球场接口返回了无法识别的时段数据')
const slot = value as unknown as PospalSlot
const startTime = extractTime(slot.beginDatetime)
const endTime = extractTime(slot.endDatetime)
const appointment = isRecord(slot.apptInfo) ? slot.apptInfo : null
const canBook = appointment?.canApptOrNot
if (
typeof slot.txtClassroomUid !== 'string' ||
typeof slot.classRoomName !== 'string' ||
typeof slot.beginDatetime !== 'string' ||
typeof slot.endDatetime !== 'string' ||
startTime === null ||
endTime === null ||
typeof slot.cost !== 'number' ||
!Number.isFinite(slot.cost) ||
typeof slot.status !== 'number' ||
typeof canBook !== 'boolean'
) {
throw new Error('球场接口返回了无法识别的时段数据')
}
const slotBeginDatetime = slot.beginDatetime
const slotEndDatetime = slot.endDatetime
const status: BookingSlot['status'] = canBook
? 'available'
: slot.status === 1
? 'booked'
: 'blocked'
const bookedBy = status === 'booked' && appointment
? parseBookingContacts(appointment)
: []
const enrollment = enrollments.find((candidate) =>
candidate.classroomUid === slot.txtClassroomUid &&
slotBeginDatetime <= candidate.endDatetime &&
slotEndDatetime >= candidate.beginDatetime
)?.activity
return {
id: `${slot.txtClassroomUid}:${slotBeginDatetime}`,
courtLabel: slot.classRoomName,
startTime,
endTime,
priceCents: Math.round(slot.cost * 100),
status,
unavailableLabel:
status === 'booked' ? '已订' : status === 'blocked' ? '排课' : undefined,
bookedBy: bookedBy.length > 0 ? bookedBy : undefined,
enrollment,
providerReference: {
classroomUid: slot.txtClassroomUid,
beginDatetime: slotBeginDatetime,
endDatetime: slotEndDatetime
},
sourceLabel: '范思伯特实时接口'
}
}
export function parseAvailabilityResponse(
payload: unknown,
query: AvailabilityQuery
): AvailabilityDay {
if (!isRecord(payload)) throw new Error('球场接口响应格式错误')
const response = payload as PospalResponse
if (response.successed !== true || !isRecord(response.result)) {
throw new Error('球场接口拒绝了可订时段查询')
}
const slots = response.result.slots
if (!Array.isArray(slots)) throw new Error('球场接口响应缺少时段列表')
const enrollmentSlots = response.result.enrollSlots
if (enrollmentSlots !== undefined && !Array.isArray(enrollmentSlots)) {
throw new Error('球场接口响应中的活动列表格式错误')
}
const enrollments = (enrollmentSlots ?? []).map(parseEnrollment)
return {
courtId: query.courtId,
date: query.date,
updatedAt: new Date().toISOString(),
slots: slots.map((slot) => parseSlot(slot, enrollments))
}
}
export function buildAvailabilityRequestBody(date: string) {
return {
dateTime: date,
userId: Number(DEFAULT_VENUE_ID),
projectUid: PROJECT_UID
}
}
async function getAvailability(
query: AvailabilityQuery,
settings: CourtRuntimeSettings
): Promise<AvailabilityDay> {
if (query.courtId !== fansiboteFuzhongCourt.id) {
throw new Error('球场标识与适配器不匹配')
}
if (settings.courtId !== query.courtId) throw new Error('球场配置与适配器不匹配')
if (!DATE_PATTERN.test(query.date)) throw new Error('预约日期格式错误')
const response = await fetch(AVAILABILITY_ENDPOINT, {
method: 'POST',
headers: {
Accept: '*/*',
APPTYPE: '1',
'Content-Type': 'application/json',
PSPLVISITORAUTO: 'API',
Referer: 'https://servicewechat.com/wx2088ca46b47d8be7/11/page-frame.html',
STOREID: DEFAULT_VENUE_ID,
VERSIONINFO: 'NC|2026.07.03'
},
body: JSON.stringify(buildAvailabilityRequestBody(query.date)),
signal: AbortSignal.timeout(12_000)
})
if (!response.ok) {
throw new Error(`球场接口请求失败HTTP ${response.status}`)
}
return parseAvailabilityResponse(await response.json(), query)
}
function requireVisitorId(settings: CourtRuntimeSettings): string {
const visitorId = settings.visitorId?.trim()
if (!visitorId) throw new Error('请先在球场配置中填写 PSPLVISITORID')
return visitorId
}
function createWriteHeaders(settings: CourtRuntimeSettings) {
return {
Accept: '*/*',
'Accept-Encoding': 'gzip, deflate, br',
'Accept-Language': 'zh-CN,zh;q=0.9',
APPTYPE: '1',
'Content-Type': 'application/json',
PSPLVISITORID: requireVisitorId(settings),
Referer: 'https://servicewechat.com/wx2088ca46b47d8be7/12/page-frame.html',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'cross-site',
STOREID: DEFAULT_VENUE_ID,
'User-Agent': WECHAT_USER_AGENT,
VERSIONINFO: 'NC|2026.07.13',
xweb_xhr: '1'
}
}
export function buildCreateBookingRequestBody(
slot: BookingSlot
) {
if (!slot.providerReference) throw new Error('时段缺少下单引用信息')
return {
userId: Number(DEFAULT_VENUE_ID),
projectType: 0,
classroomItems: [{
classroomUid: slot.providerReference.classroomUid,
beginDatetime: slot.providerReference.beginDatetime,
endDatetime: slot.providerReference.endDatetime,
peopleNum: 1
}],
remark: '',
combinationPayments: [{
paymentMethod: 900,
cost: slot.priceCents / 100
}]
}
}
export function parseCreateBookingResponse(
payload: unknown,
slot: BookingSlot,
courtId: string
): AdapterBookingResult {
if (!isRecord(payload)) {
throw new ProviderMutationError('球场下单接口响应格式错误', 'uncertain')
}
if (payload.successed !== true) {
const detail = providerErrorDetail(payload)
throw new ProviderMutationError(
`球场下单接口拒绝了锁场请求${detail ? `${detail}` : ''}`,
'rejected'
)
}
if (!isRecord(payload.result)) {
throw new ProviderMutationError('球场下单接口响应格式错误', 'uncertain')
}
const result = payload.result
if (
typeof result.apptUid !== 'string' ||
!isRecord(result.script) ||
typeof result.script.expireTime !== 'string' ||
!Array.isArray(result.venueAppointUids) ||
!result.venueAppointUids.every((value) => typeof value === 'string')
) {
throw new ProviderMutationError('球场下单接口响应格式错误', 'uncertain')
}
return {
booking: {
courtId,
orderId: result.apptUid,
venueAppointmentIds: result.venueAppointUids,
amountCents: slot.priceCents,
courtLabel: slot.courtLabel,
startTime: slot.startTime,
endTime: slot.endTime,
expiresAt: result.script.expireTime,
status: 'pending-payment'
},
paymentScript: result.script
}
}
async function createBooking(
slot: BookingSlot,
settings: CourtRuntimeSettings
): Promise<AdapterBookingResult> {
if (slot.status !== 'available' && slot.status !== 'limited') {
throw new Error('该时段当前不可预约')
}
if (slot.enrollment) throw new Error('活动时段不能通过普通场地接口下单')
const headers = createWriteHeaders(settings)
const requestBody = buildCreateBookingRequestBody(slot)
logDevRequest('booking.request', {
url: CREATE_BOOKING_ENDPOINT,
method: 'POST',
credentialFingerprint: credentialFingerprint(headers.PSPLVISITORID),
headers,
body: requestBody
})
let response: Response
try {
response = await fetch(CREATE_BOOKING_ENDPOINT, {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(12_000)
})
} catch (error) {
logDevRequest('booking.network-error', {
message: error instanceof Error ? error.message : String(error)
})
throw new ProviderMutationError('球场下单网络响应不确定', 'uncertain')
}
let payload: unknown
try {
payload = await response.json()
} catch {
logDevRequest('booking.response', {
httpStatus: response.status,
ok: response.ok,
body: '[UNPARSEABLE]'
})
if (!response.ok) {
throw new ProviderMutationError(
`球场下单请求被拒绝HTTP ${response.status}`,
'rejected'
)
}
throw new ProviderMutationError('球场下单响应无法解析', 'uncertain')
}
logDevRequest('booking.response', {
httpStatus: response.status,
ok: response.ok,
body: payload
})
if (!response.ok) {
const detail = providerErrorDetail(payload)
throw new ProviderMutationError(
`球场下单请求被拒绝HTTP ${response.status}${detail ? `${detail}` : ''}`,
'rejected'
)
}
return parseCreateBookingResponse(payload, slot, settings.courtId)
}
async function cancelBooking(settings: CourtRuntimeSettings): Promise<void> {
const headers = createWriteHeaders(settings)
let response: Response
try {
response = await fetch(CANCEL_BOOKING_ENDPOINT, {
method: 'POST',
headers,
body: '{}',
signal: AbortSignal.timeout(12_000)
})
} catch {
throw new ProviderMutationError('取消锁场网络响应不确定', 'uncertain')
}
if (!response.ok) {
throw new ProviderMutationError(
`取消锁场请求被拒绝HTTP ${response.status}`,
'rejected'
)
}
let payload: unknown
try {
payload = await response.json()
} catch {
throw new ProviderMutationError('取消锁场响应无法解析', 'uncertain')
}
if (!isRecord(payload) || payload.successed !== true) {
throw new ProviderMutationError('取消接口拒绝释放场地', 'rejected')
}
}
export const fansiboteFuzhongAdapter: CourtAdapter = {
court: fansiboteFuzhongCourt,
defaultSettings,
getAvailability,
preflightWriteSettings: requireVisitorId,
createBooking,
cancelBooking
}

View File

@@ -0,0 +1,316 @@
import { mkdtemp } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CourtSettingsStore } from '../settings/CourtSettingsStore'
import { PendingBookingStore } from '../bookings/PendingBookingStore'
import { AdapterRegistry } from './registry'
const courtId = 'fansibote-fuzhong'
const slotId = '1775556146897330236:2026-07-16 08:00:00'
function availabilityPayload() {
return {
successed: true,
result: {
slots: [{
txtClassroomUid: '1775556146897330236',
classRoomName: '1号风雨场',
beginDatetime: '2026-07-16 08:00:00',
endDatetime: '2026-07-16 08:59:00',
cost: 80,
status: 0,
apptInfo: { canApptOrNot: true }
}]
}
}
}
describe('AdapterRegistry 预约状态机', () => {
afterEach(() => vi.unstubAllGlobals())
it('重新校验后锁场,阻止重复订单,并在取消成功后释放状态', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-registry-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({
courtId,
visitorId: 'visitor-token'
})
const pendingStore = new PendingBookingStore(join(directory, 'pending.json'))
const registry = new AdapterRegistry(settingsStore, pendingStore)
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => availabilityPayload() })
.mockResolvedValueOnce({
ok: true,
json: async () => ({
successed: true,
result: {
apptUid: 'order-1',
script: { expireTime: '2026-07-16 08:10:00' },
venueAppointUids: ['order-1']
}
})
})
.mockResolvedValueOnce({ ok: true, json: async () => ({ successed: true }) })
vi.stubGlobal('fetch', fetchMock)
const booking = await registry.createBooking({
courtId,
date: '2026-07-16',
slotId,
expectedPriceCents: 8000
})
expect(booking.orderId).toBe('order-1')
await expect(registry.getPendingBooking(courtId)).resolves.toEqual(booking)
expect(fetchMock).toHaveBeenCalledTimes(2)
await expect(registry.createBooking({
courtId,
date: '2026-07-16',
slotId,
expectedPriceCents: 8000
})).rejects.toThrow('已有待支付订单')
expect(fetchMock).toHaveBeenCalledTimes(2)
await expect(registry.updateCourtSettings({
courtId,
visitorId: 'replacement-token'
})).rejects.toThrow('请先取消释放后再修改配置')
await registry.cancelBooking({ courtId, orderId: 'order-1' })
expect(fetchMock).toHaveBeenCalledTimes(3)
await expect(registry.getPendingBooking(courtId)).resolves.toBeNull()
})
it('在主进程串行化并发下单,只允许生成一个锁场订单', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-concurrent-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId, visitorId: 'visitor-token' })
const registry = new AdapterRegistry(
settingsStore,
new PendingBookingStore(join(directory, 'pending.json'))
)
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => availabilityPayload() })
.mockResolvedValueOnce({
ok: true,
json: async () => ({
successed: true,
result: {
apptUid: 'order-1',
script: { expireTime: '2026-07-16 08:10:00' },
venueAppointUids: ['order-1']
}
})
})
vi.stubGlobal('fetch', fetchMock)
const input = { courtId, date: '2026-07-16', slotId, expectedPriceCents: 8000 }
const results = await Promise.allSettled([
registry.createBooking(input),
registry.createBooking(input)
])
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('最新价格与用户确认金额不一致时不发送下单请求', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-price-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId, visitorId: 'visitor-token' })
const registry = new AdapterRegistry(
settingsStore,
new PendingBookingStore(join(directory, 'pending.json'))
)
const payload = availabilityPayload()
payload.result.slots[0]!.cost = 90
const fetchMock = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => payload
})
vi.stubGlobal('fetch', fetchMock)
await expect(registry.createBooking({
courtId,
date: '2026-07-16',
slotId,
expectedPriceCents: 8000
})).rejects.toThrow('价格已变化')
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('响应不确定时保留恢复记录并允许用户主动尝试释放', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-uncertain-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId, visitorId: 'visitor-token' })
const registry = new AdapterRegistry(
settingsStore,
new PendingBookingStore(join(directory, 'pending.json'))
)
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => availabilityPayload() })
.mockRejectedValueOnce(new Error('connection reset'))
.mockResolvedValueOnce({ ok: true, json: async () => ({ successed: true }) })
vi.stubGlobal('fetch', fetchMock)
await expect(registry.createBooking({
courtId,
date: '2026-07-16',
slotId,
expectedPriceCents: 8000
})).rejects.toThrow('下单结果无法确认')
const uncertain = await registry.getPendingBooking(courtId)
expect(uncertain?.status).toBe('submitting-uncertain')
await registry.cancelBooking({ courtId, orderId: uncertain!.orderId })
await expect(registry.getPendingBooking(courtId)).resolves.toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(3)
})
it('缺少访客凭证时不发送下单请求,也不生成恢复死锁', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-preflight-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId })
const registry = new AdapterRegistry(
settingsStore,
new PendingBookingStore(join(directory, 'pending.json'))
)
const fetchMock = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => availabilityPayload()
})
vi.stubGlobal('fetch', fetchMock)
await expect(registry.createBooking({
courtId,
date: '2026-07-16',
slotId,
expectedPriceCents: 8000
})).rejects.toThrow('PSPLVISITORID')
expect(fetchMock).toHaveBeenCalledTimes(1)
await expect(registry.getPendingBooking(courtId)).resolves.toBeNull()
})
it('释放响应不确定时不自动重试访客级释放接口', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-release-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId, visitorId: 'visitor-token' })
const pendingStore = new PendingBookingStore(join(directory, 'pending.json'))
const registry = new AdapterRegistry(settingsStore, pendingStore)
await pendingStore.set({
courtId,
orderId: 'order-1',
venueAppointmentIds: ['order-1'],
amountCents: 8000,
courtLabel: '1号风雨场',
startTime: '08:00',
endTime: '08:59',
expiresAt: '2026-07-16 08:10:00',
status: 'pending-payment'
})
const fetchMock = vi.fn().mockRejectedValueOnce(new Error('connection reset'))
vi.stubGlobal('fetch', fetchMock)
await expect(registry.cancelBooking({ courtId, orderId: 'order-1' }))
.rejects.toThrow('释放结果无法确认')
await expect(registry.getPendingBooking(courtId)).resolves.toMatchObject({
status: 'release-uncertain'
})
await registry.cancelBooking({ courtId, orderId: 'order-1' })
expect(fetchMock).toHaveBeenCalledTimes(1)
await expect(registry.getPendingBooking(courtId)).resolves.toBeNull()
})
it('下单被服务端明确拒绝时清除预写记录且不触发释放', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-rejected-create-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId, visitorId: 'visitor-token' })
const registry = new AdapterRegistry(
settingsStore,
new PendingBookingStore(join(directory, 'pending.json'))
)
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => availabilityPayload() })
.mockResolvedValueOnce({ ok: false, status: 400 })
vi.stubGlobal('fetch', fetchMock)
await expect(registry.createBooking({
courtId,
date: '2026-07-16',
slotId,
expectedPriceCents: 8000
})).rejects.toThrow('被拒绝')
await expect(registry.getPendingBooking(courtId)).resolves.toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('释放被服务端明确拒绝时恢复原订单状态以允许安全重试', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-rejected-release-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId, visitorId: 'visitor-token' })
const pendingStore = new PendingBookingStore(join(directory, 'pending.json'))
const registry = new AdapterRegistry(settingsStore, pendingStore)
const pending = {
courtId,
orderId: 'order-1',
venueAppointmentIds: ['order-1'],
amountCents: 8000,
courtLabel: '1号风雨场',
startTime: '08:00',
endTime: '08:59',
expiresAt: '2026-07-16 08:10:00',
status: 'pending-payment' as const
}
await pendingStore.set(pending)
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false, status: 401 }))
await expect(registry.cancelBooking({ courtId, orderId: 'order-1' }))
.rejects.toThrow('被拒绝')
await expect(registry.getPendingBooking(courtId)).resolves.toEqual(pending)
})
it('下单请求使用刚持久化更新的 PSPLVISITORID', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-current-credential-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId, visitorId: 'old-token' })
const registry = new AdapterRegistry(
settingsStore,
new PendingBookingStore(join(directory, 'pending.json'))
)
await registry.updateCourtSettings({ courtId, visitorId: 'updated-token' })
await expect(registry.getCourtSettings(courtId)).resolves.toEqual({
courtId,
visitorId: 'updated-token',
visitorIdConfigured: true
})
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => availabilityPayload() })
.mockResolvedValueOnce({
ok: true,
json: async () => ({
successed: true,
result: {
apptUid: 'order-current-token',
script: { expireTime: '2026-07-16 08:10:00' },
venueAppointUids: ['order-current-token']
}
})
})
vi.stubGlobal('fetch', fetchMock)
await registry.createBooking({
courtId,
date: '2026-07-16',
slotId,
expectedPriceCents: 8000
})
const createRequest = fetchMock.mock.calls[1]?.[1] as RequestInit
expect(createRequest.headers).toEqual(expect.objectContaining({
PSPLVISITORID: 'updated-token'
}))
})
})

View File

@@ -0,0 +1,277 @@
import type {
AvailabilityQuery,
CancelBookingInput,
CreateBookingInput,
UpdateCourtSettingsInput
} from '../../shared/contracts'
import type { CourtSettingsStore } from '../settings/CourtSettingsStore'
import type { PendingBookingStore } from '../bookings/PendingBookingStore'
import { fansiboteFuzhongAdapter } from './fansibote-fuzhong/adapter'
import { ProviderMutationError, type CourtAdapter } from './types'
const adapters: CourtAdapter[] = [fansiboteFuzhongAdapter]
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export class AdapterRegistry {
private readonly adapters = new Map(
adapters.map((adapter) => [adapter.court.id, adapter])
)
private readonly courtOperations = new Map<string, Promise<void>>()
private readonly releaseConfirmedCourts = new Set<string>()
constructor(
private readonly settingsStore: CourtSettingsStore,
private readonly pendingBookingStore: PendingBookingStore
) {}
listCourts() {
return [...this.adapters.values()].map((adapter) => adapter.court)
}
async getAvailability(query: AvailabilityQuery) {
const adapter = this.getAdapter(query.courtId)
const settings = await this.settingsStore.get(
adapter.court.id,
adapter.defaultSettings
)
return adapter.getAvailability(query, settings)
}
async getCourtSettings(courtId: string) {
const adapter = this.getAdapter(courtId)
const settings = await this.settingsStore.get(courtId, adapter.defaultSettings)
return {
courtId,
visitorId: settings.visitorId,
visitorIdConfigured: Boolean(settings.visitorId)
}
}
async updateCourtSettings(input: unknown) {
if (
!isRecord(input) ||
typeof input.courtId !== 'string' ||
(input.visitorId !== undefined && typeof input.visitorId !== 'string')
) {
throw new Error('球场配置参数格式错误')
}
const parsedInput: UpdateCourtSettingsInput = {
courtId: input.courtId,
visitorId: input.visitorId
}
this.getAdapter(parsedInput.courtId)
return this.runExclusive(parsedInput.courtId, async () => {
const adapter = this.getAdapter(parsedInput.courtId)
if (await this.pendingBookingStore.get(parsedInput.courtId)) {
throw new Error('当前球场存在待支付订单,请先取消释放后再修改配置')
}
const current = await this.settingsStore.get(
parsedInput.courtId,
adapter.defaultSettings
)
const visitorId = parsedInput.visitorId?.trim() || current.visitorId
if (parsedInput.visitorId !== undefined && !parsedInput.visitorId.trim()) {
throw new Error('PSPLVISITORID 不能为空')
}
const saved = await this.settingsStore.update({
courtId: parsedInput.courtId,
visitorId
})
return {
courtId: saved.courtId,
visitorId: saved.visitorId,
visitorIdConfigured: Boolean(saved.visitorId)
}
})
}
async createBooking(input: unknown) {
const parsedInput = this.parseCreateBookingInput(input)
return this.runExclusive(parsedInput.courtId, () =>
this.createBookingExclusive(parsedInput)
)
}
async cancelBooking(input: unknown) {
const parsedInput = this.parseCancelBookingInput(input)
return this.runExclusive(parsedInput.courtId, () =>
this.cancelBookingExclusive(parsedInput)
)
}
async getPendingBooking(courtId: string) {
this.getAdapter(courtId)
const pending = await this.pendingBookingStore.get(courtId)
if (pending?.status === 'release-confirmed') {
await this.pendingBookingStore.clear(courtId)
return null
}
return pending
}
private async createBookingExclusive(parsedInput: CreateBookingInput) {
if (await this.pendingBookingStore.get(parsedInput.courtId)) {
throw new Error('当前球场已有待支付订单,请先取消释放')
}
const adapter = this.getAdapter(parsedInput.courtId)
if (!adapter.createBooking) throw new Error('当前球场尚未接入下单流程')
const settings = await this.settingsStore.get(
adapter.court.id,
adapter.defaultSettings
)
const availability = await adapter.getAvailability({
courtId: parsedInput.courtId,
date: parsedInput.date
}, settings)
const slot = availability.slots.find((candidate) => candidate.id === parsedInput.slotId)
if (!slot) throw new Error('目标时段已不存在,请刷新后重试')
if (slot.status !== 'available' && slot.status !== 'limited') {
throw new Error('目标时段已不可预约,请刷新列表')
}
if (slot.enrollment) throw new Error('活动时段不能通过普通场地接口下单')
if (slot.priceCents !== parsedInput.expectedPriceCents) {
throw new Error('价格已变化,请刷新列表并重新确认金额')
}
adapter.preflightWriteSettings?.(settings)
const uncertainBooking = {
courtId: parsedInput.courtId,
orderId: `uncertain:${Date.now()}`,
venueAppointmentIds: [],
amountCents: slot.priceCents,
courtLabel: slot.courtLabel,
startTime: slot.startTime,
endTime: slot.endTime,
status: 'submitting-uncertain' as const
}
await this.pendingBookingStore.set(uncertainBooking)
let result
try {
result = await adapter.createBooking(slot, settings)
} catch (error) {
if (error instanceof ProviderMutationError && error.outcome === 'rejected') {
await this.pendingBookingStore.clear(parsedInput.courtId)
throw error
}
throw new Error(
`下单结果无法确认,已保留恢复记录;请刷新列表后使用“尝试释放”。${error instanceof Error ? ` 原因:${error.message}` : ''}`
)
}
try {
await this.pendingBookingStore.set(result.booking)
} catch {
try {
await this.pendingBookingStore.set({
...uncertainBooking,
status: 'release-uncertain'
})
await adapter.cancelBooking?.(settings)
this.releaseConfirmedCourts.add(parsedInput.courtId)
await this.pendingBookingStore.set({
...uncertainBooking,
status: 'release-confirmed'
})
await this.pendingBookingStore.clear(parsedInput.courtId)
this.releaseConfirmedCourts.delete(parsedInput.courtId)
} catch {
throw new Error('锁场成功但本机记录和自动释放均失败,请立即到小程序检查订单')
}
throw new Error('锁场后的本机记录失败,场地已自动释放')
}
return result.booking
}
private async cancelBookingExclusive(input: CancelBookingInput) {
const pending = await this.pendingBookingStore.get(input.courtId)
if (!pending || pending.orderId !== input.orderId) {
throw new Error('没有找到对应的待支付锁场订单')
}
if (
pending.status === 'release-confirmed' ||
this.releaseConfirmedCourts.has(input.courtId) ||
pending.status === 'release-uncertain'
) {
await this.pendingBookingStore.clear(input.courtId)
this.releaseConfirmedCourts.delete(input.courtId)
return
}
const adapter = this.getAdapter(input.courtId)
if (!adapter.cancelBooking) throw new Error('当前球场尚未接入取消流程')
const settings = await this.settingsStore.get(
adapter.court.id,
adapter.defaultSettings
)
adapter.preflightWriteSettings?.(settings)
await this.pendingBookingStore.set({ ...pending, status: 'release-uncertain' })
try {
await adapter.cancelBooking(settings)
} catch (error) {
if (error instanceof ProviderMutationError && error.outcome === 'rejected') {
await this.pendingBookingStore.set(pending)
throw error
}
throw new Error(
`释放结果无法确认;为避免重复释放新订单,已停止自动重试。请先到小程序核实,再清理本机记录。${error instanceof Error ? ` 原因:${error.message}` : ''}`
)
}
this.releaseConfirmedCourts.add(input.courtId)
await this.pendingBookingStore.set({ ...pending, status: 'release-confirmed' })
await this.pendingBookingStore.clear(input.courtId)
this.releaseConfirmedCourts.delete(input.courtId)
}
private parseCreateBookingInput(input: unknown): CreateBookingInput {
if (
!isRecord(input) ||
typeof input.courtId !== 'string' ||
typeof input.date !== 'string' ||
typeof input.slotId !== 'string' ||
typeof input.expectedPriceCents !== 'number' ||
!Number.isSafeInteger(input.expectedPriceCents) ||
input.expectedPriceCents < 0
) {
throw new Error('下单参数格式错误')
}
return {
courtId: input.courtId,
date: input.date,
slotId: input.slotId,
expectedPriceCents: input.expectedPriceCents
}
}
private parseCancelBookingInput(input: unknown): CancelBookingInput {
if (
!isRecord(input) ||
typeof input.courtId !== 'string' ||
typeof input.orderId !== 'string'
) {
throw new Error('取消锁场参数格式错误')
}
return { courtId: input.courtId, orderId: input.orderId }
}
private async runExclusive<T>(courtId: string, operation: () => Promise<T>): Promise<T> {
const previous = this.courtOperations.get(courtId) ?? Promise.resolve()
const result = previous.catch(() => undefined).then(operation)
const tail = result.then(() => undefined, () => undefined)
this.courtOperations.set(courtId, tail)
try {
return await result
} finally {
if (this.courtOperations.get(courtId) === tail) {
this.courtOperations.delete(courtId)
}
}
}
private getAdapter(courtId: string) {
const adapter = this.adapters.get(courtId)
if (!adapter) throw new Error(`Unknown court adapter: ${courtId}`)
return adapter
}
}

View File

@@ -0,0 +1,38 @@
import type {
AvailabilityDay,
AvailabilityQuery,
BookingSlot,
PendingBooking,
CourtSummary
} from '../../shared/contracts'
import type { CourtRuntimeSettings } from '../settings/types'
export interface AdapterBookingResult {
booking: PendingBooking
paymentScript: unknown
}
export class ProviderMutationError extends Error {
constructor(
message: string,
readonly outcome: 'rejected' | 'uncertain'
) {
super(message)
this.name = 'ProviderMutationError'
}
}
export interface CourtAdapter {
readonly court: CourtSummary
readonly defaultSettings: CourtRuntimeSettings
getAvailability(
query: AvailabilityQuery,
settings: CourtRuntimeSettings
): Promise<AvailabilityDay>
preflightWriteSettings?(settings: CourtRuntimeSettings): void
createBooking?(
slot: BookingSlot,
settings: CourtRuntimeSettings
): Promise<AdapterBookingResult>
cancelBooking?(settings: CourtRuntimeSettings): Promise<void>
}

View File

@@ -0,0 +1,32 @@
import { mkdtemp } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { describe, expect, it } from 'vitest'
import type { PendingBooking } from '../../shared/contracts'
import { PendingBookingStore } from './PendingBookingStore'
const booking: PendingBooking = {
courtId: 'court-a',
orderId: 'order-1',
venueAppointmentIds: ['order-1'],
amountCents: 8000,
courtLabel: '1号场',
startTime: '08:00',
endTime: '08:59',
expiresAt: '2026-07-16 08:10:00',
status: 'pending-payment'
}
describe('PendingBookingStore', () => {
it('可在重启式重新实例化后恢复和清除待支付订单', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-pending-'))
const filePath = join(directory, 'pending.json')
const store = new PendingBookingStore(filePath)
await store.set(booking)
const reloaded = new PendingBookingStore(filePath)
await expect(reloaded.get('court-a')).resolves.toEqual(booking)
await reloaded.clear('court-a')
await expect(reloaded.get('court-a')).resolves.toBeNull()
})
})

View File

@@ -0,0 +1,99 @@
import { dirname } from 'node:path'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import type { PendingBooking } from '../../shared/contracts'
interface BookingFile {
version: 1
courts: Record<string, PendingBooking>
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function parseBooking(value: unknown): PendingBooking {
if (
!isRecord(value) ||
typeof value.courtId !== 'string' ||
typeof value.orderId !== 'string' ||
!Array.isArray(value.venueAppointmentIds) ||
!value.venueAppointmentIds.every((id) => typeof id === 'string') ||
typeof value.amountCents !== 'number' ||
typeof value.courtLabel !== 'string' ||
typeof value.startTime !== 'string' ||
typeof value.endTime !== 'string' ||
(value.expiresAt !== undefined && typeof value.expiresAt !== 'string') ||
![
'submitting-uncertain',
'pending-payment',
'release-uncertain',
'release-confirmed'
].includes(
String(value.status)
)
) {
throw new Error('本机待支付订单记录格式错误')
}
return value as unknown as PendingBooking
}
export class PendingBookingStore {
private writeQueue: Promise<void> = Promise.resolve()
constructor(private readonly filePath: string) {}
async get(courtId: string): Promise<PendingBooking | null> {
return (await this.read()).courts[courtId] ?? null
}
set(booking: PendingBooking): Promise<void> {
return this.write((file) => {
file.courts[booking.courtId] = booking
})
}
clear(courtId: string): Promise<void> {
return this.write((file) => {
delete file.courts[courtId]
})
}
private async write(update: (file: BookingFile) => void): Promise<void> {
const operation = this.writeQueue.then(async () => {
const file = await this.read()
update(file)
await mkdir(dirname(this.filePath), { recursive: true })
const temporaryPath = `${this.filePath}.${process.pid}.tmp`
await writeFile(temporaryPath, `${JSON.stringify(file, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600
})
await rename(temporaryPath, this.filePath)
})
this.writeQueue = operation.catch(() => undefined)
return operation
}
private async read(): Promise<BookingFile> {
try {
const value: unknown = JSON.parse(await readFile(this.filePath, 'utf8'))
if (!isRecord(value) || value.version !== 1 || !isRecord(value.courts)) {
throw new Error('本机待支付订单文件格式错误')
}
return {
version: 1,
courts: Object.fromEntries(
Object.entries(value.courts).map(([courtId, booking]) => [
courtId,
parseBooking(booking)
])
)
}
} catch (error) {
if (isRecord(error) && error.code === 'ENOENT') {
return { version: 1, courts: {} }
}
throw error
}
}
}

91
src/main/index.ts Normal file
View File

@@ -0,0 +1,91 @@
import { join } from 'node:path'
import { app, BrowserWindow, ipcMain, shell } from 'electron'
import { AdapterRegistry } from './adapters/registry'
import { CourtSettingsStore } from './settings/CourtSettingsStore'
import { PendingBookingStore } from './bookings/PendingBookingStore'
import {
IPC_CHANNELS,
type AvailabilityQuery
} from '../shared/contracts'
function createWindow() {
const mainWindow = new BrowserWindow({
width: 1440,
height: 920,
minWidth: 1040,
minHeight: 680,
show: false,
backgroundColor: '#f4f1e8',
titleBarStyle: 'hiddenInset',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
mainWindow.once('ready-to-show', () => mainWindow.show())
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('https://')) void shell.openExternal(url)
return { action: 'deny' }
})
mainWindow.webContents.on('will-navigate', (event) => event.preventDefault())
if (process.env.ELECTRON_RENDERER_URL) {
void mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
void mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(() => {
const settingsStore = new CourtSettingsStore(
join(app.getPath('userData'), 'court-settings.json')
)
const pendingBookingStore = new PendingBookingStore(
join(app.getPath('userData'), 'pending-bookings.json')
)
const registry = new AdapterRegistry(settingsStore, pendingBookingStore)
ipcMain.handle(IPC_CHANNELS.listCourts, () => registry.listCourts())
ipcMain.handle(
IPC_CHANNELS.getAvailability,
(_event, query: AvailabilityQuery) => registry.getAvailability(query)
)
ipcMain.handle(
IPC_CHANNELS.getCourtSettings,
(_event, courtId: unknown) => {
if (typeof courtId !== 'string') throw new Error('球场标识格式错误')
return registry.getCourtSettings(courtId)
}
)
ipcMain.handle(
IPC_CHANNELS.updateCourtSettings,
(_event, input: unknown) => registry.updateCourtSettings(input)
)
ipcMain.handle(
IPC_CHANNELS.createBooking,
(_event, input: unknown) => registry.createBooking(input)
)
ipcMain.handle(
IPC_CHANNELS.cancelBooking,
(_event, input: unknown) => registry.cancelBooking(input)
)
ipcMain.handle(
IPC_CHANNELS.getPendingBooking,
(_event, courtId: unknown) => {
if (typeof courtId !== 'string') throw new Error('球场标识格式错误')
return registry.getPendingBooking(courtId)
}
)
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})

View File

@@ -0,0 +1,67 @@
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { describe, expect, it } from 'vitest'
import { CourtSettingsStore } from './CourtSettingsStore'
describe('CourtSettingsStore', () => {
it('未配置时返回对应球场的默认值', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-settings-'))
const store = new CourtSettingsStore(join(directory, 'court-settings.json'))
await expect(store.get('court-a', {
courtId: 'court-a'
})).resolves.toEqual({ courtId: 'court-a' })
})
it('按球场隔离并持久化访客凭证', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-settings-'))
const filePath = join(directory, 'court-settings.json')
const store = new CourtSettingsStore(filePath)
await store.update({
courtId: 'court-a',
visitorId: 'court-a-secret'
})
await store.update({ courtId: 'court-b' })
const reloaded = new CourtSettingsStore(filePath)
await expect(reloaded.get('court-a', {
courtId: 'court-a'
})).resolves.toEqual({
courtId: 'court-a',
visitorId: 'court-a-secret'
})
await expect(reloaded.get('court-b', {
courtId: 'court-b'
})).resolves.toEqual({ courtId: 'court-b' })
const persisted = JSON.parse(await readFile(filePath, 'utf8')) as unknown
expect(persisted).toEqual({
version: 1,
courts: {
'court-a': { visitorId: 'court-a-secret' },
'court-b': {}
}
})
})
it('忽略旧版暴露的场地 ID只迁移访客凭证', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-settings-legacy-'))
const filePath = join(directory, 'court-settings.json')
await writeFile(filePath, JSON.stringify({
version: 1,
courts: {
'court-a': { userId: '6038652', visitorId: 'legacy-secret' }
}
}))
const store = new CourtSettingsStore(filePath)
await expect(store.get('court-a', {
courtId: 'court-a'
})).resolves.toEqual({
courtId: 'court-a',
visitorId: 'legacy-secret'
})
})
})

View File

@@ -0,0 +1,81 @@
import { dirname } from 'node:path'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import type { CourtRuntimeSettings } from './types'
interface SettingsFile {
version: 1
courts: Record<string, { visitorId?: string }>
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function parseSettingsFile(value: unknown): SettingsFile {
if (!isRecord(value) || value.version !== 1 || !isRecord(value.courts)) {
throw new Error('本机球场配置文件格式错误')
}
const courts: SettingsFile['courts'] = {}
for (const [courtId, settings] of Object.entries(value.courts)) {
if (
!isRecord(settings) ||
(settings.visitorId !== undefined && typeof settings.visitorId !== 'string')
) {
throw new Error(`球场 ${courtId} 的本机配置格式错误`)
}
courts[courtId] = {
visitorId: settings.visitorId
}
}
return { version: 1, courts }
}
export class CourtSettingsStore {
private writeQueue: Promise<void> = Promise.resolve()
constructor(private readonly filePath: string) {}
async get(
courtId: string,
defaults: CourtRuntimeSettings
): Promise<CourtRuntimeSettings> {
const file = await this.read()
return {
courtId,
visitorId: file.courts[courtId]?.visitorId ?? defaults.visitorId
}
}
async update(settings: CourtRuntimeSettings): Promise<CourtRuntimeSettings> {
const operation = this.writeQueue.then(async () => {
const file = await this.read()
file.courts[settings.courtId] = {
visitorId: settings.visitorId
}
await mkdir(dirname(this.filePath), { recursive: true })
const temporaryPath = `${this.filePath}.${process.pid}.tmp`
await writeFile(temporaryPath, `${JSON.stringify(file, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600
})
await rename(temporaryPath, this.filePath)
})
this.writeQueue = operation.catch(() => undefined)
await operation
return settings
}
private async read(): Promise<SettingsFile> {
try {
return parseSettingsFile(JSON.parse(await readFile(this.filePath, 'utf8')))
} catch (error) {
if (isRecord(error) && error.code === 'ENOENT') {
return { version: 1, courts: {} }
}
throw error
}
}
}

View File

@@ -0,0 +1,4 @@
export interface CourtRuntimeSettings {
courtId: string
visitorId?: string
}