init
This commit is contained in:
297
src/main/adapters/fansibote-fuzhong/adapter.test.ts
Normal file
297
src/main/adapters/fansibote-fuzhong/adapter.test.ts
Normal 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('无法识别的时段数据')
|
||||
})
|
||||
})
|
||||
525
src/main/adapters/fansibote-fuzhong/adapter.ts
Normal file
525
src/main/adapters/fansibote-fuzhong/adapter.ts
Normal 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
|
||||
}
|
||||
316
src/main/adapters/registry.test.ts
Normal file
316
src/main/adapters/registry.test.ts
Normal 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'
|
||||
}))
|
||||
})
|
||||
})
|
||||
277
src/main/adapters/registry.ts
Normal file
277
src/main/adapters/registry.ts
Normal 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
|
||||
}
|
||||
}
|
||||
38
src/main/adapters/types.ts
Normal file
38
src/main/adapters/types.ts
Normal 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>
|
||||
}
|
||||
32
src/main/bookings/PendingBookingStore.test.ts
Normal file
32
src/main/bookings/PendingBookingStore.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
99
src/main/bookings/PendingBookingStore.ts
Normal file
99
src/main/bookings/PendingBookingStore.ts
Normal 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
91
src/main/index.ts
Normal 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()
|
||||
})
|
||||
67
src/main/settings/CourtSettingsStore.test.ts
Normal file
67
src/main/settings/CourtSettingsStore.test.ts
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
81
src/main/settings/CourtSettingsStore.ts
Normal file
81
src/main/settings/CourtSettingsStore.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
4
src/main/settings/types.ts
Normal file
4
src/main/settings/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface CourtRuntimeSettings {
|
||||
courtId: string
|
||||
visitorId?: string
|
||||
}
|
||||
Reference in New Issue
Block a user