feat: 支持大湾区球场监控
This commit is contained in:
215
src/main/adapters/dawanqu/adapter.test.ts
Normal file
215
src/main/adapters/dawanqu/adapter.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
212
src/main/adapters/dawanqu/adapter.ts
Normal file
212
src/main/adapters/dawanqu/adapter.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
368
src/main/adapters/dawanqu/matrix-dates.fixture.json
Normal file
368
src/main/adapters/dawanqu/matrix-dates.fixture.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
1020
src/main/adapters/dawanqu/matrix.fixture.json
Normal file
1020
src/main/adapters/dawanqu/matrix.fixture.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[],
|
||||
|
||||
Reference in New Issue
Block a user