feat: 支持大湾区球场监控

This commit is contained in:
richarjiang
2026-09-23 19:49:59 +08:00
parent 9adc665ef4
commit d4f55a97dc
32 changed files with 3888 additions and 46 deletions

View File

@@ -0,0 +1,215 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import fixture from './matrix.fixture.json'
import dateFixtures from './matrix-dates.fixture.json'
import { dawanquAdapter as adapter, parseMatrix } from './adapter'
import { AdapterRegistry } from '../registry'
import { CourtSettingsStore } from '../../settings/CourtSettingsStore'
import { PendingBookingStore } from '../../bookings/PendingBookingStore'
import { MonitorService } from '../../monitors/MonitorService'
import { MonitorTaskStore } from '../../monitors/MonitorTaskStore'
const query = { courtId: 'dawanqu', date: '2026-09-24' }
const settings = { courtId: 'dawanqu', credential: 'fixture.token.signature' }
const success = (result: unknown) => ({ ok: true, status: 200, json: async () => ({ code: 200, result }) })
const quote = (amount = 120, count = 1) => success({ total: amount, subTotal: amount, orders: Array.from({ length: count }, () => ({ total: amount / count })) })
const order = () => success({ orderBusUuid: '2026092319115446374373082', orderPayUuid: '2026092319115450465766477', subTotal: 120 })
function slots() { return parseMatrix(fixture.result, query).slots.filter((slot) => slot.status === 'available') }
async function runtime() {
const dir = await mkdtemp(join(tmpdir(), 'tennis-dawanqu-'))
const store = new CourtSettingsStore(join(dir, 'settings.json'))
await store.update(settings)
const pending = new PendingBookingStore(join(dir, 'pending.json'))
return { registry: new AdapterRegistry(store, pending), store, pending }
}
const input = () => ({ ...query, slotId: slots()[0]!.id, expectedPriceCents: 12000 })
afterEach(() => vi.unstubAllGlobals())
describe('大湾区列表与请求边界', () => {
it('解析原始矩阵并以场地和时段联合去重', () => {
const day = parseMatrix(fixture.result, query)
expect(day.slots).toHaveLength(48)
expect(new Set(day.slots.map((slot) => slot.id)).size).toBe(48)
expect(day.slots[0]).toMatchObject({ courtLabel: '网球1号场', startTime: '09:00', endTime: '10:00', priceCents: 12000, status: 'available' })
expect(day.slots[1]).toMatchObject({ status: 'booked', priceCents: 0 })
expect(parseMatrix({ fieldList: [], matrix: [] }, query).slots).toEqual([])
})
it('无可订时段仍保留已售信息,特殊活动不开放普通锁场', () => {
const result = structuredClone(fixture.result)
for (const row of result.matrix) for (const cell of row.matrix) cell.isAbleReserve = false
expect(parseMatrix(result, query).slots.every((slot) => slot.status === 'booked')).toBe(true)
const special = structuredClone(fixture.result)
special.matrix[0]!.matrix[0]!.isDiscountActivity = true
expect(parseMatrix(special, query).slots[0]).toMatchObject({ status: 'available', bookingUnavailableReason: '优惠场次需在原小程序预约' })
})
it('拒绝未知格式、重复 ID、跨日期和可订空价格', () => {
expect(() => parseMatrix({}, query)).toThrow('格式')
const result = structuredClone(fixture.result)
result.matrix[0]!.matrix.push(result.matrix[0]!.matrix[0]!)
expect(() => parseMatrix(result, query)).toThrow('格式')
expect(() => parseMatrix(fixture.result, { ...query, date: '2026-09-25' })).toThrow('格式')
const invalid = structuredClone(fixture.result)
invalid.matrix[0]!.matrix[0]!.price = null
expect(() => parseMatrix(invalid, query)).toThrow('格式')
})
it('已开始提示不再覆盖成特殊场次,未开始的已售时段仍为已售', () => {
const day = parseMatrix(dateFixtures['2026-09-23'], { ...query, date: '2026-09-23' })
expect(day.slots[0]).toMatchObject({ status: 'blocked', unavailableLabel: '已开始' })
expect(day.slots.at(-1)).toMatchObject({ status: 'booked', unavailableLabel: '已售' })
expect(day.slots.every((slot) => slot.status !== 'available')).toBe(true)
})
it('9 月 25 日已售优惠时段按真实不可订状态显示,不因秒杀标记变成未知', () => {
const day = parseMatrix(dateFixtures['2026-09-25'], { ...query, date: '2026-09-25' })
expect(day.slots).toHaveLength(12)
for (const slot of day.slots) {
expect(slot).toMatchObject({ status: 'booked', unavailableLabel: '已售', priceCents: 7000 })
expect(slot.bookingUnavailableReason).toBeUndefined()
}
})
it('普通提示不否认可订状态;未开放原因不误报已售', () => {
const result = structuredClone(fixture.result) as unknown as { fieldList: unknown[]; matrix: { matrix: Record<string, unknown>[] }[] }
result.matrix[0]!.matrix[0]!.msg = '请合理安排时间'
result.matrix[0]!.matrix[1]!.msg = '尚未开放预约'
const day = parseMatrix(result, query)
expect(day.slots[0]).toMatchObject({ status: 'available' })
expect(day.slots[0]!.bookingUnavailableReason).toBeUndefined()
expect(day.slots[1]).toMatchObject({ status: 'blocked', unavailableLabel: '尚未开放' })
})
it.each(['2026-09-26', '2026-09-27', '2026-09-28', '2026-09-29'])('%s 超出开放窗口返回明确提示,不冒充网络失败', async (date) => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ code: 2000, text: '当前最多可提前 3 天预定场地', result: null }) }))
const day = await adapter.getAuthenticatedAvailability!({ ...query, date }, settings)
expect(day).toMatchObject({ date, slots: [], notice: '尚未开放预约:该球场最多可提前 3 天预定,请在开放后刷新。' })
})
it('不把其他业务码 2000 误报为尚未开放', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ code: 2000, text: '其他业务异常', result: null }) }))
await expect(adapter.getAuthenticatedAvailability!(query, settings)).rejects.toThrow('未确认成功')
})
it('可订优惠场次可识别但主进程仍阻止未接入的优惠下单', async () => {
const result = structuredClone(dateFixtures['2026-09-25'])
result.matrix[0]!.matrix[0]!.isAbleReserve = true
const q = { ...query, date: '2026-09-25' }
const slot = parseMatrix(result, q).slots[0]!
expect(slot).toMatchObject({ status: 'available', bookingUnavailableReason: '优惠场次需在原小程序预约' })
const { registry } = await runtime()
const fetchMock = vi.fn().mockResolvedValue(success(result))
vi.stubGlobal('fetch', fetchMock)
await expect(registry.createBooking({ ...q, slotId: slot.id, expectedPriceCents: 7000 })).rejects.toThrow('暂不支持应用内预约')
expect(fetchMock).toHaveBeenCalledOnce()
expect(await registry.getPendingBooking(q.courtId)).toBeNull()
await expect(adapter.createBooking!([slot], settings)).rejects.toThrow('已不可预约')
expect(fetchMock).toHaveBeenCalledOnce()
})
it('主进程查询注入 Authorization,匿名监控不读凭证也不发请求', async () => {
const { registry, store } = await runtime()
const fetchMock = vi.fn().mockResolvedValue(success(fixture.result))
vi.stubGlobal('fetch', fetchMock)
const day = await registry.getAvailability(query)
expect(fetchMock.mock.calls[0]![1].headers.Authorization).toBe('Wechat fixture.token.signature')
expect(JSON.stringify(day)).not.toContain(settings.credential)
expect(JSON.stringify(registry.listCourts())).not.toContain(settings.credential)
const readSpy = vi.spyOn(store, 'get')
await expect(registry.getAvailability(query, true)).rejects.toThrow('无凭证')
expect(readSpy).not.toHaveBeenCalled()
expect(fetchMock).toHaveBeenCalledOnce()
})
it.each([401, 403, 429, 500])('处理 HTTP %i 且不回传原始响应中的敏感信息', async (status) => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status }))
await expect(adapter.getAuthenticatedAvailability!(query, settings)).rejects.toThrow(status === 429 ? '限流' : status === 500 ? '请求失败' : '登录')
})
it.each([403, 429])('处理业务错误 %i', async (code) => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ code, text: settings.credential }) }))
await expect(adapter.getAuthenticatedAvailability!(query, settings)).rejects.toThrow(code === 403 ? '登录' : '限流')
})
it('网络异常不泄露底层异常或凭证', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error(settings.credential)))
await expect(adapter.getAuthenticatedAvailability!(query, settings)).rejects.toThrow('网络')
await expect(adapter.getAuthenticatedAvailability!(query, settings)).rejects.not.toThrow(settings.credential)
})
})
describe('大湾区核价、锁场与本机恢复', () => {
it('按重查、核价、锁场顺序创建订单且重复下单被拦截', async () => {
const { registry } = await runtime()
const fetchMock = vi.fn().mockResolvedValueOnce(success(fixture.result)).mockResolvedValueOnce(quote()).mockResolvedValueOnce(order())
vi.stubGlobal('fetch', fetchMock)
const booking = await registry.createBooking(input())
expect(fetchMock.mock.calls.map((call) => call[0].split('/').slice(-1)[0])).toEqual(['matrix', 'price', 'reserve'])
const body = JSON.parse(fetchMock.mock.calls[2]![1].body)
expect(body).toMatchObject({ payment: 'wxMini', reserveDate: query.date, checkReserveUuid: '', fieldTimeInfos: [{ fieldUuid: fixture.result.fieldList[0]!.fieldUuid, fieldTimeUuid: fixture.result.fieldTimeList[0]!.fieldTimeUuid, wholeFieldUuid: fixture.result.fieldList[0]!.fieldUuid }] })
expect(booking.status).toBe('pending-payment')
expect(booking.expiresAt).toBeUndefined()
await expect(registry.createBooking(input())).rejects.toThrow('已有待支付')
await expect(registry.cancelBooking({ courtId: query.courtId, orderId: booking.orderId })).rejects.toThrow('尚未接入取消')
await expect(registry.acknowledgeBookingHandled({ courtId: query.courtId, orderId: booking.orderId })).rejects.toThrow('确认')
await registry.acknowledgeBookingHandled({ courtId: query.courtId, orderId: booking.orderId, confirmed: true })
expect(await registry.getPendingBooking(query.courtId)).toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(3)
})
it('连续两小时作为同一笔请求提交,禁止跨场拼接', async () => {
const result = structuredClone(fixture.result)
result.matrix[0]!.matrix[1]!.isAbleReserve = true
result.matrix[0]!.matrix[1]!.price = 120
const selected = parseMatrix(result, query).slots.slice(0, 2)
const fetchMock = vi.fn().mockResolvedValueOnce(quote(240, 2)).mockResolvedValueOnce(success({ orderBusUuid: '123', orderPayUuid: '456', subTotal: 240 }))
vi.stubGlobal('fetch', fetchMock)
await adapter.createBooking!(selected, settings)
expect(JSON.parse(fetchMock.mock.calls[1]![1].body).fieldTimeInfos).toHaveLength(2)
await expect(adapter.createBooking!([selected[0]!, slots()[1]!], settings)).rejects.toThrow('同一场地')
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('核价改变或网络失败不会锁场,也不残留不确定记录', async () => {
const { registry } = await runtime()
const fetchMock = vi.fn().mockResolvedValueOnce(success(fixture.result)).mockResolvedValueOnce(quote(130))
vi.stubGlobal('fetch', fetchMock)
await expect(registry.createBooking(input())).rejects.toThrow('价格已变化')
expect(await registry.getPendingBooking(query.courtId)).toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(2)
fetchMock.mockResolvedValueOnce(success(fixture.result)).mockRejectedValueOnce(new Error('network'))
await expect(registry.createBooking(input())).rejects.toThrow('网络')
expect(await registry.getPendingBooking(query.courtId)).toBeNull()
})
it.each(['network', 'http500', 'malformed', 'business500'])('锁场 %s 保留不确定记录并禁止重试', async (failure) => {
const { registry } = await runtime()
const fetchMock = vi.fn().mockResolvedValueOnce(success(fixture.result)).mockResolvedValueOnce(quote())
if (failure === 'network') fetchMock.mockRejectedValueOnce(new Error('network'))
if (failure === 'http500') fetchMock.mockResolvedValueOnce({ ok: false, status: 500 })
if (failure === 'malformed') fetchMock.mockResolvedValueOnce(success({}))
if (failure === 'business500') fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ code: 500 }) })
vi.stubGlobal('fetch', fetchMock)
await expect(registry.createBooking(input())).rejects.toThrow('结果无法确认')
expect((await registry.getPendingBooking(query.courtId))?.status).toBe('submitting-uncertain')
await expect(registry.createBooking(input())).rejects.toThrow('已有待支付')
expect(fetchMock).toHaveBeenCalledTimes(3)
})
it('无取消能力且锁场摘要落盘失败时,不伪称自动释放', async () => {
const { registry, pending } = await runtime()
const realSet = pending.set.bind(pending)
vi.spyOn(pending, 'set').mockImplementation(async (booking) => {
if (booking.status === 'pending-payment') throw new Error('disk failure')
return realSet(booking)
})
const fetchMock = vi.fn().mockResolvedValueOnce(success(fixture.result)).mockResolvedValueOnce(quote()).mockResolvedValueOnce(order())
vi.stubGlobal('fetch', fetchMock)
await expect(registry.createBooking(input())).rejects.toThrow('立即到小程序检查')
expect(await registry.getPendingBooking(query.courtId)).not.toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(3)
})
it('拒绝新建需要凭证的后台监控', async () => {
const { registry } = await runtime()
const dir = await mkdtemp(join(tmpdir(), 'tennis-dawanqu-monitor-'))
const store = new MonitorTaskStore(join(dir, 'tasks.json'))
const service = new MonitorService(registry, store, vi.fn())
await expect(service.createTask({ courtId: query.courtId, startTime: '09:00', endTime: '10:00' })).rejects.toThrow('无凭证空场监控')
expect(await store.list()).toEqual([])
})
it('新凭证按球场持久化,旧球场 visitorId 保持兼容', async () => {
const { registry, store } = await runtime()
await store.update({ courtId: 'fansibote-fuzhong', visitorId: 'legacy-token' })
await registry.updateCourtSettings({ courtId: query.courtId, credential: 'Wechat new.token.signature' })
expect(await registry.getCourtSettings(query.courtId)).toMatchObject({ credentialConfigured: true, credential: 'Wechat new.token.signature' })
expect(await registry.getCourtSettings('fansibote-fuzhong')).toMatchObject({ visitorId: 'legacy-token', visitorIdConfigured: true })
})
})

View File

@@ -0,0 +1,212 @@
import type { AvailabilityDay, AvailabilityQuery, BookingSlot } from '../../../shared/contracts'
import type { CourtRuntimeSettings } from '../../settings/types'
import { isContinuousSingleCourtSequence, sortSlots } from '../../bookings/slotSequence'
import { ProviderMutationError, type CourtAdapter } from '../types'
const COURT_ID = 'dawanqu'
const PROJECT_ID = '0ddda9c33d4e11f1b51c2273436a3e4e'
const AREA_ID = 'cec42d973dee11f1b51c2273436a3e4e'
const BASE = 'https://wlhmobile.crland.com.cn/business/client/field/area'
const UUID = /^[a-f0-9]{32}$/i
const FORMAT_ERROR = '大湾区接口格式已变化,请暂停预约并更新适配器'
class BookingWindowClosedError extends Error {}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function uuid(value: unknown): string {
if (typeof value !== 'string' || !UUID.test(value)) throw new Error(FORMAT_ERROR)
return value
}
function cents(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 ||
!Number.isSafeInteger(Math.round(value * 100)) || Math.abs(value * 100 - Math.round(value * 100)) > 0.00001) {
throw new Error(FORMAT_ERROR)
}
return Math.round(value * 100)
}
function authorization(settings: CourtRuntimeSettings): string {
const raw = settings.credential?.trim().replace(/^Wechat\s+/i, '')
if (!raw || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(raw)) {
throw new Error('请在球场配置中填写有效的 Authorization(Wechat 登录凭证)')
}
return `Wechat ${raw}`
}
function validateQuery(query: AvailabilityQuery) {
if (query.courtId !== COURT_ID || !/^\d{4}-\d{2}-\d{2}$/.test(query.date) ||
!Number.isFinite(Date.parse(`${query.date}T00:00:00Z`)) ||
new Date(`${query.date}T00:00:00Z`).toISOString().slice(0, 10) !== query.date) {
throw new Error('球场或预约日期格式错误')
}
}
async function request(path: string, body: unknown, settings: CourtRuntimeSettings, mutation = false): Promise<unknown> {
const auth = authorization(settings)
let response: Response
try {
response = await fetch(`${BASE}/${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
appId: 'wx020209beec4251e0',
projectUuid: PROJECT_ID,
Authorization: auth
},
body: JSON.stringify(body),
redirect: 'error',
signal: AbortSignal.timeout(12_000)
})
} catch {
throw new ProviderMutationError('大湾区网络请求失败或超时,请稍后重试', mutation ? 'uncertain' : 'rejected')
}
const fail = (message: string, definite = false): never => {
throw new ProviderMutationError(message, mutation && !definite ? 'uncertain' : 'rejected')
}
if (response.status === 401 || response.status === 403) fail('大湾区登录已失效,请更新 Authorization', true)
if (response.status === 429) fail('大湾区接口限流,请稍后重试', true)
if (!response.ok) fail(`大湾区请求失败(HTTP ${response.status})`)
let payload: unknown
try { payload = await response.json() } catch { return fail(FORMAT_ERROR) }
if (!record(payload) || typeof payload.code !== 'number') return fail(FORMAT_ERROR)
if (payload.code === 401 || payload.code === 403) fail('大湾区登录已失效,请更新 Authorization', true)
if (payload.code === 429) fail('大湾区接口限流,请稍后重试', true)
// Unknown business errors are not proof that a mutation never happened.
if (path === 'matrix' && payload.code === 2000 && typeof payload.text === 'string') {
const window = /^当前最多可提前\s*(\d{1,3})\s*天预定场地$/.exec(payload.text.trim())
if (window && Number(window[1]) > 0) {
throw new BookingWindowClosedError(`尚未开放预约:该球场最多可提前 ${Number(window[1])} 天预定,请在开放后刷新。`)
}
}
if (payload.code !== 200) fail('大湾区接口未确认成功,请到原小程序核实')
return payload.result
}
export function parseMatrix(result: unknown, query: AvailabilityQuery): AvailabilityDay {
validateQuery(query)
if (!record(result) || !Array.isArray(result.fieldList) || !Array.isArray(result.matrix)) throw new Error(FORMAT_ERROR)
const names = new Map<string, string>()
for (const field of result.fieldList) {
if (!record(field) || typeof field.fieldName !== 'string' || !field.fieldName.trim()) throw new Error(FORMAT_ERROR)
const id = uuid(field.fieldUuid)
if (names.has(id)) throw new Error(FORMAT_ERROR)
names.set(id, field.fieldName)
}
const slots: BookingSlot[] = []
const ids = new Set<string>()
const rows = new Set<string>()
for (const row of result.matrix) {
if (!record(row) || !Array.isArray(row.matrix)) throw new Error(FORMAT_ERROR)
const fieldId = uuid(row.fieldUuid)
const name = names.get(fieldId)
if (!name || rows.has(fieldId)) throw new Error(FORMAT_ERROR)
rows.add(fieldId)
for (const cell of row.matrix) {
if (!record(cell) || cell.fieldUuid !== fieldId || typeof cell.isAbleReserve !== 'boolean') throw new Error(FORMAT_ERROR)
const timeId = uuid(cell.fieldTimeUuid)
const start = cell.startTime
const end = cell.endTime
if (typeof start !== 'string' || typeof end !== 'string' ||
!/^\d{4}-\d{2}-\d{2} ([01]\d|2[0-3]):[0-5]\d:00$/.test(start) ||
!/^\d{4}-\d{2}-\d{2} ([01]\d|2[0-3]):[0-5]\d:00$/.test(end) ||
start.slice(0, 10) !== query.date || end.slice(0, 10) !== query.date || start >= end) throw new Error(FORMAT_ERROR)
const id = `${query.date}:${fieldId}:${timeId}`
if (ids.has(id)) throw new Error(FORMAT_ERROR)
ids.add(id)
// Availability, unavailability reasons and unsupported booking flows are separate.
const promotional = Boolean(cell.isSeckill || cell.isDiscountActivity || cell.discountSpecUuid)
const unsupported = promotional || Boolean(cell.originFieldTimeUuid || cell.isOriginReserved)
const available = cell.isAbleReserve
const message = typeof cell.msg === 'string' ? cell.msg.trim() : ''
const unavailableLabel = /场次已开始/.test(message) ? '已开始'
: /未开放|未开始|未到.*预[订定约]/.test(message) ? '尚未开放'
: message ? '暂不可订' : '已售'
slots.push({
id, courtLabel: name, startTime: start.slice(11, 16), endTime: end.slice(11, 16),
priceCents: cell.price === null && !cell.isAbleReserve ? 0 : cents(cell.price),
priceKnown: cell.price !== null,
status: available ? 'available' : message ? 'blocked' : 'booked',
...(!available ? { unavailableLabel } : {}),
...(available && unsupported ? {
bookingUnavailableReason: promotional ? '优惠场次需在原小程序预约' : '此场次需在原小程序预约'
} : {}),
providerReference: { physicalCourtId: fieldId, beginDatetime: start, endDatetime: end },
sourceLabel: '大湾区网球场'
})
}
}
return { courtId: query.courtId, date: query.date, updatedAt: new Date().toISOString(), slots }
}
async function getAuthenticatedAvailability(query: AvailabilityQuery, settings: CourtRuntimeSettings) {
validateQuery(query)
try {
return parseMatrix(await request('matrix', {
fieldAreaUuid: AREA_ID, reserveDate: query.date, enterpriseUuid: '', discountSpecUuid: '', projectUuid: PROJECT_ID
}, settings), query)
} catch (error) {
if (!(error instanceof BookingWindowClosedError)) throw error
return { courtId: query.courtId, date: query.date, updatedAt: new Date().toISOString(), slots: [], notice: error.message }
}
}
export const dawanquAdapter: CourtAdapter = {
court: {
id: COURT_ID, name: '大湾区网球场', shortName: '大湾区', indoor: true,
accent: '#4776d0', status: 'online', credentialLabel: 'Authorization',
requiresAuthenticatedAvailability: true, supportsCancellation: false,
bookingNotice: '只锁场不支付。支付、取消和订单状态请到原小程序查看;本应用暂不支持远端取消。'
},
defaultSettings: { courtId: COURT_ID },
async getAvailability() { throw new Error('大湾区列表需要登录,暂不支持无凭证空场监控') },
getAuthenticatedAvailability,
preflightWriteSettings: authorization,
async createBooking(selected, settings) {
let body: Record<string, unknown>
let slots: BookingSlot[]
let total: number
try {
authorization(settings)
slots = sortSlots(selected)
if (!isContinuousSingleCourtSequence(slots)) throw new Error('请选择同一场地的完整连续时段')
const date = slots[0]!.providerReference?.beginDatetime.slice(0, 10)
if (!date) throw new Error(FORMAT_ERROR)
const fieldTimeInfos = slots.map((slot) => {
const parts = slot.id.split(':')
const ref = slot.providerReference
if (parts.length !== 3 || parts[0] !== date || !ref || parts[1] !== ref.physicalCourtId ||
ref.beginDatetime.slice(0, 10) !== date || ref.endDatetime.slice(0, 10) !== date ||
slot.status !== 'available' || slot.enrollment || slot.bookingUnavailableReason) throw new Error('目标时段已不可预约')
return { fieldUuid: uuid(parts[1]), fieldTimeUuid: uuid(parts[2]), wholeFieldUuid: uuid(parts[1]) }
})
if (new Set(slots.map((slot) => slot.id)).size !== slots.length) throw new Error('时段重复')
total = slots.reduce((sum, slot) => sum + slot.priceCents, 0)
body = { reserveDate: date, fieldTimeInfos, enterpriseUuid: '', discountSpecUuid: '', couponAssignUuid: '', projectUuid: PROJECT_ID }
const quote = await request('reserve/price', body, settings)
if (!record(quote) || cents(quote.total) !== total || cents(quote.subTotal) !== total ||
!Array.isArray(quote.orders) || quote.orders.length !== slots.length ||
quote.orders.reduce((sum: number, order: unknown) => {
if (!record(order)) throw new Error(FORMAT_ERROR)
return sum + cents(order.total)
}, 0) !== total) throw new Error('价格已变化或核价格式异常,请刷新列表重新确认')
} catch (error) {
throw new ProviderMutationError(error instanceof Error ? error.message : '核价失败', 'rejected')
}
const result = await request('reserve', { payment: 'wxMini', ...body, checkReserveUuid: '' }, settings, true)
if (!record(result) || typeof result.orderBusUuid !== 'string' || !/^\d+$/.test(result.orderBusUuid) ||
typeof result.orderPayUuid !== 'string' || !/^\d+$/.test(result.orderPayUuid)) {
throw new ProviderMutationError('锁场响应格式异常,请到原小程序核实订单', 'uncertain')
}
let amount: number
try { amount = cents(result.subTotal) } catch { throw new ProviderMutationError('锁场金额无法确认', 'uncertain') }
if (amount !== total) throw new ProviderMutationError('锁场返回金额与核价不一致,请到原小程序核实', 'uncertain')
return {
booking: {
courtId: COURT_ID, orderId: result.orderBusUuid, venueAppointmentIds: [], amountCents: amount,
courtLabel: slots[0]!.courtLabel, startTime: slots[0]!.startTime, endTime: slots.at(-1)!.endTime,
status: 'pending-payment'
},
paymentScript: null
}
}
}

View File

@@ -0,0 +1,368 @@
{
"2026-09-23": {
"fieldList": [
{
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldName": "网球1号场"
}
],
"matrix": [
{
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"matrix": [
{
"isAbleReserve": false,
"msg": "场次已开始,请合理安排时间",
"isSeckill": false,
"isDiscountActivity": false,
"discountSpecUuid": null,
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": null,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86da62b6760c11f19bcb922ac365dbe6",
"startTime": "2026-09-23 09:00:00",
"endTime": "2026-09-23 10:00:00"
},
{
"isAbleReserve": false,
"msg": "场次已开始,请合理安排时间",
"isSeckill": false,
"isDiscountActivity": false,
"discountSpecUuid": null,
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": null,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86db2608760c11f19bcb922ac365dbe6",
"startTime": "2026-09-23 10:00:00",
"endTime": "2026-09-23 11:00:00"
},
{
"isAbleReserve": false,
"msg": "场次已开始,请合理安排时间",
"isSeckill": false,
"isDiscountActivity": false,
"discountSpecUuid": null,
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": null,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86dbc24a760c11f19bcb922ac365dbe6",
"startTime": "2026-09-23 11:00:00",
"endTime": "2026-09-23 12:00:00"
},
{
"isAbleReserve": false,
"msg": "场次已开始,请合理安排时间",
"isSeckill": false,
"isDiscountActivity": false,
"discountSpecUuid": null,
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": null,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86dcacac760c11f19bcb922ac365dbe6",
"startTime": "2026-09-23 12:00:00",
"endTime": "2026-09-23 13:00:00"
},
{
"isAbleReserve": false,
"msg": "场次已开始,请合理安排时间",
"isSeckill": false,
"isDiscountActivity": false,
"discountSpecUuid": null,
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": null,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86dde52e760c11f19bcb922ac365dbe6",
"startTime": "2026-09-23 13:00:00",
"endTime": "2026-09-23 14:00:00"
},
{
"isAbleReserve": false,
"msg": "场次已开始,请合理安排时间",
"isSeckill": false,
"isDiscountActivity": false,
"discountSpecUuid": null,
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": null,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86de8170760c11f19bcb922ac365dbe6",
"startTime": "2026-09-23 14:00:00",
"endTime": "2026-09-23 15:00:00"
},
{
"isAbleReserve": false,
"msg": "场次已开始,请合理安排时间",
"isSeckill": false,
"isDiscountActivity": false,
"discountSpecUuid": null,
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": null,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86df44c2760c11f19bcb922ac365dbe6",
"startTime": "2026-09-23 15:00:00",
"endTime": "2026-09-23 16:00:00"
},
{
"isAbleReserve": false,
"msg": "场次已开始,请合理安排时间",
"isSeckill": false,
"isDiscountActivity": false,
"discountSpecUuid": null,
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": null,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86dfe104760c11f19bcb922ac365dbe6",
"startTime": "2026-09-23 16:00:00",
"endTime": "2026-09-23 17:00:00"
},
{
"isAbleReserve": false,
"msg": "场次已开始,请合理安排时间",
"isSeckill": false,
"isDiscountActivity": false,
"discountSpecUuid": null,
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": null,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86e0a456760c11f19bcb922ac365dbe6",
"startTime": "2026-09-23 17:00:00",
"endTime": "2026-09-23 18:00:00"
},
{
"isAbleReserve": false,
"msg": "场次已开始,请合理安排时间",
"isSeckill": false,
"isDiscountActivity": false,
"discountSpecUuid": null,
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": null,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86e14098760c11f19bcb922ac365dbe6",
"startTime": "2026-09-23 18:00:00",
"endTime": "2026-09-23 19:00:00"
},
{
"isAbleReserve": false,
"msg": "场次已开始,请合理安排时间",
"isSeckill": false,
"isDiscountActivity": false,
"discountSpecUuid": null,
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": null,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86e22afa760c11f19bcb922ac365dbe6",
"startTime": "2026-09-23 19:00:00",
"endTime": "2026-09-23 20:00:00"
},
{
"isAbleReserve": false,
"msg": null,
"isSeckill": false,
"isDiscountActivity": false,
"discountSpecUuid": null,
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": null,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86e2c73c760c11f19bcb922ac365dbe6",
"startTime": "2026-09-23 20:00:00",
"endTime": "2026-09-23 21:00:00"
}
]
}
]
},
"2026-09-25": {
"fieldList": [
{
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldName": "网球1号场"
}
],
"matrix": [
{
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"matrix": [
{
"isAbleReserve": false,
"msg": null,
"isSeckill": true,
"isDiscountActivity": false,
"discountSpecUuid": "70111563b62c11f19fe83a9fa704cf43",
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": 70.0,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86da62b6760c11f19bcb922ac365dbe6",
"startTime": "2026-09-25 09:00:00",
"endTime": "2026-09-25 10:00:00"
},
{
"isAbleReserve": false,
"msg": null,
"isSeckill": true,
"isDiscountActivity": false,
"discountSpecUuid": "70111563b62c11f19fe83a9fa704cf43",
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": 70.0,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86db2608760c11f19bcb922ac365dbe6",
"startTime": "2026-09-25 10:00:00",
"endTime": "2026-09-25 11:00:00"
},
{
"isAbleReserve": false,
"msg": null,
"isSeckill": true,
"isDiscountActivity": false,
"discountSpecUuid": "70111563b62c11f19fe83a9fa704cf43",
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": 70.0,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86dbc24a760c11f19bcb922ac365dbe6",
"startTime": "2026-09-25 11:00:00",
"endTime": "2026-09-25 12:00:00"
},
{
"isAbleReserve": false,
"msg": null,
"isSeckill": true,
"isDiscountActivity": false,
"discountSpecUuid": "70111563b62c11f19fe83a9fa704cf43",
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": 70.0,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86dcacac760c11f19bcb922ac365dbe6",
"startTime": "2026-09-25 12:00:00",
"endTime": "2026-09-25 13:00:00"
},
{
"isAbleReserve": false,
"msg": null,
"isSeckill": true,
"isDiscountActivity": false,
"discountSpecUuid": "70111563b62c11f19fe83a9fa704cf43",
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": 70.0,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86dde52e760c11f19bcb922ac365dbe6",
"startTime": "2026-09-25 13:00:00",
"endTime": "2026-09-25 14:00:00"
},
{
"isAbleReserve": false,
"msg": null,
"isSeckill": true,
"isDiscountActivity": false,
"discountSpecUuid": "70111563b62c11f19fe83a9fa704cf43",
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": 70.0,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86de8170760c11f19bcb922ac365dbe6",
"startTime": "2026-09-25 14:00:00",
"endTime": "2026-09-25 15:00:00"
},
{
"isAbleReserve": false,
"msg": null,
"isSeckill": true,
"isDiscountActivity": false,
"discountSpecUuid": "70111563b62c11f19fe83a9fa704cf43",
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": 70.0,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86df44c2760c11f19bcb922ac365dbe6",
"startTime": "2026-09-25 15:00:00",
"endTime": "2026-09-25 16:00:00"
},
{
"isAbleReserve": false,
"msg": null,
"isSeckill": true,
"isDiscountActivity": false,
"discountSpecUuid": "70111563b62c11f19fe83a9fa704cf43",
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": 70.0,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86dfe104760c11f19bcb922ac365dbe6",
"startTime": "2026-09-25 16:00:00",
"endTime": "2026-09-25 17:00:00"
},
{
"isAbleReserve": false,
"msg": null,
"isSeckill": true,
"isDiscountActivity": false,
"discountSpecUuid": "70116384b62c11f19fe83a9fa704cf43",
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": 70.0,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86e0a456760c11f19bcb922ac365dbe6",
"startTime": "2026-09-25 17:00:00",
"endTime": "2026-09-25 18:00:00"
},
{
"isAbleReserve": false,
"msg": null,
"isSeckill": true,
"isDiscountActivity": false,
"discountSpecUuid": "70116384b62c11f19fe83a9fa704cf43",
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": 70.0,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86e14098760c11f19bcb922ac365dbe6",
"startTime": "2026-09-25 18:00:00",
"endTime": "2026-09-25 19:00:00"
},
{
"isAbleReserve": false,
"msg": null,
"isSeckill": true,
"isDiscountActivity": false,
"discountSpecUuid": "70116384b62c11f19fe83a9fa704cf43",
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": 70.0,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86e22afa760c11f19bcb922ac365dbe6",
"startTime": "2026-09-25 19:00:00",
"endTime": "2026-09-25 20:00:00"
},
{
"isAbleReserve": false,
"msg": null,
"isSeckill": true,
"isDiscountActivity": false,
"discountSpecUuid": "70116384b62c11f19fe83a9fa704cf43",
"originFieldTimeUuid": null,
"isOriginReserved": null,
"price": 70.0,
"fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b",
"fieldTimeUuid": "86e2c73c760c11f19bcb922ac365dbe6",
"startTime": "2026-09-25 20:00:00",
"endTime": "2026-09-25 21:00:00"
}
]
}
]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -64,7 +64,7 @@ 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
const SENSITIVE_LOG_KEY = /visitor|paysign|prepay|nonce|package|token|cookie|authorization|customer|phone|tel|pspl/i
function redactForLog(value: unknown, key = ''): unknown {
if (SENSITIVE_LOG_KEY.test(key)) return '[REDACTED]'
@@ -361,7 +361,7 @@ export function buildCreateBookingRequestBody(
input: BookingSlot | BookingSlot[]
) {
const slots = sortSlots(Array.isArray(input) ? input : [input])
if (slots.length === 0 || slots.some((slot) => !slot.providerReference)) {
if (slots.length === 0 || slots.some((slot) => !slot.providerReference?.classroomUid)) {
throw new Error('时段缺少下单引用信息')
}
if (!isContinuousSingleCourtSequence(slots)) {

View File

@@ -6,6 +6,7 @@ import type {
} from '../../shared/contracts'
import type { CourtSettingsStore } from '../settings/CourtSettingsStore'
import type { PendingBookingStore } from '../bookings/PendingBookingStore'
import { dawanquAdapter } from './dawanqu/adapter'
import { fansiboteFuzhongAdapter } from './fansibote-fuzhong/adapter'
import { ProviderMutationError, type CourtAdapter } from './types'
import {
@@ -14,7 +15,7 @@ import {
sortSlots
} from '../bookings/slotSequence'
const adapters: CourtAdapter[] = [fansiboteFuzhongAdapter]
const adapters: CourtAdapter[] = [fansiboteFuzhongAdapter, dawanquAdapter]
interface ParsedCreateBookingInput extends Omit<CreateBookingInput, 'slotId' | 'slotIds'> {
slotIds: string[]
@@ -39,8 +40,12 @@ export class AdapterRegistry {
return [...this.adapters.values()].map((adapter) => adapter.court)
}
async getAvailability(query: AvailabilityQuery) {
async getAvailability(query: AvailabilityQuery, anonymousOnly = false) {
const adapter = this.getAdapter(query.courtId)
if (adapter.getAuthenticatedAvailability && !anonymousOnly) {
const settings = await this.settingsStore.get(adapter.court.id, adapter.defaultSettings)
return adapter.getAuthenticatedAvailability(query, settings)
}
return adapter.getAvailability(query)
}
@@ -49,6 +54,7 @@ export class AdapterRegistry {
const settings = await this.settingsStore.get(courtId, adapter.defaultSettings)
return {
courtId,
...(adapter.court.credentialLabel ? { credential: settings.credential, credentialConfigured: Boolean(settings.credential) } : {}),
visitorId: settings.visitorId,
visitorIdConfigured: Boolean(settings.visitorId)
}
@@ -58,12 +64,14 @@ export class AdapterRegistry {
if (
!isRecord(input) ||
typeof input.courtId !== 'string' ||
(input.visitorId !== undefined && typeof input.visitorId !== 'string')
(input.visitorId !== undefined && typeof input.visitorId !== 'string') ||
(input.credential !== undefined && typeof input.credential !== 'string')
) {
throw new Error('球场配置参数格式错误')
}
const parsedInput: UpdateCourtSettingsInput = {
courtId: input.courtId,
credential: input.credential,
visitorId: input.visitorId
}
this.getAdapter(parsedInput.courtId)
@@ -80,12 +88,17 @@ export class AdapterRegistry {
if (parsedInput.visitorId !== undefined && !parsedInput.visitorId.trim()) {
throw new Error('PSPLVISITORID 不能为空')
}
const credential = parsedInput.credential?.trim() ?? current.credential
if (parsedInput.credential !== undefined && !credential) throw new Error('登录凭证不能为空')
if (adapter.court.credentialLabel) adapter.preflightWriteSettings?.({ courtId: parsedInput.courtId, credential })
const saved = await this.settingsStore.update({
courtId: parsedInput.courtId,
visitorId
visitorId,
credential
})
return {
courtId: saved.courtId,
...(adapter.court.credentialLabel ? { credential: saved.credential, credentialConfigured: Boolean(saved.credential) } : {}),
visitorId: saved.visitorId,
visitorIdConfigured: Boolean(saved.visitorId)
}
@@ -106,6 +119,18 @@ export class AdapterRegistry {
)
}
async acknowledgeBookingHandled(input: unknown) {
if (!isRecord(input) || input.confirmed !== true) throw new Error('请先确认已在原小程序处理订单')
const parsed = this.parseCancelBookingInput(input)
return this.runExclusive(parsed.courtId, async () => {
const adapter = this.getAdapter(parsed.courtId)
if (adapter.cancelBooking) throw new Error('当前球场请使用取消释放流程')
const pending = await this.pendingBookingStore.get(parsed.courtId)
if (!pending || pending.orderId !== parsed.orderId) throw new Error('待处理订单已变化,请刷新')
await this.pendingBookingStore.clear(parsed.courtId)
})
}
async getPendingBooking(courtId: string) {
this.getAdapter(courtId)
const pending = await this.pendingBookingStore.get(courtId)
@@ -127,7 +152,7 @@ export class AdapterRegistry {
adapter.court.id,
adapter.defaultSettings
)
const availability = await adapter.getAvailability({
const availability = await this.getAvailability({
courtId: parsedInput.courtId,
date: parsedInput.date
})
@@ -141,6 +166,9 @@ export class AdapterRegistry {
if (slots.some((slot) => slot.status !== 'available' && slot.status !== 'limited')) {
throw new Error('目标连续时段已不可预约,请刷新列表')
}
if (slots.some((slot) => slot.bookingUnavailableReason)) {
throw new Error('该场次暂不支持应用内预约,请到原小程序处理')
}
if (slots.some((slot) => slot.enrollment)) {
throw new Error('活动时段不能通过普通场地接口下单')
}
@@ -192,18 +220,21 @@ export class AdapterRegistry {
throw error
}
throw new Error(
`下单结果无法确认,已保留恢复记录;请刷新列表后使用“尝试释放”。${error instanceof Error ? ` 原因:${error.message}` : ''}`
`下单结果无法确认,已保留恢复记录;请到原小程序核实订单。${error instanceof Error ? ` 原因:${error.message}` : ''}`
)
}
try {
await this.pendingBookingStore.set(result.booking)
} catch {
if (!adapter.cancelBooking) {
throw new Error('锁场成功但本机记录失败,当前球场不支持自动释放,请立即到小程序检查订单')
}
try {
await this.pendingBookingStore.set({
...uncertainBooking,
status: 'release-uncertain'
})
await adapter.cancelBooking?.(settings)
await adapter.cancelBooking(settings)
this.releaseConfirmedCourts.add(parsedInput.courtId)
await this.pendingBookingStore.set({
...uncertainBooking,

View File

@@ -26,6 +26,7 @@ export interface CourtAdapter {
readonly court: CourtSummary
readonly defaultSettings: CourtRuntimeSettings
getAvailability(query: AvailabilityQuery): Promise<AvailabilityDay>
getAuthenticatedAvailability?(query: AvailabilityQuery, settings: CourtRuntimeSettings): Promise<AvailabilityDay>
preflightWriteSettings?(settings: CourtRuntimeSettings): void
createBooking?(
slots: BookingSlot[],