新增积分服务端接口封装、积分中心页面,支持登录回跳、签到恢复、积分明细等功能。清理缓存时保留未确认签到键,新增ApiRequestError错误类型区分业务状态,更新开发文档记录积分页面联调完成情况。
146 lines
8.3 KiB
Plaintext
146 lines
8.3 KiB
Plaintext
import { getSession, requestJson } from '@/services/api.uts'
|
|
|
|
export type PointsSummary = {
|
|
balance: number, serverDate: string, timezone: string, signedToday: boolean,
|
|
checkInEnabled: boolean, ruleVersion: string | null, checkInReward: number | null, balanceState: string
|
|
}
|
|
export type PointsRules = { status: string, ruleVersion: string | null, checkInReward: number | null, timezone: string, publishedRules: UTSJSONObject | null }
|
|
export type PointsTask = { task: string, status: string, reward: number | null, ruleVersion: string | null }
|
|
export type PointsLedgerItem = {
|
|
ledgerId: string, delta: number, balanceBefore: number, balanceAfter: number,
|
|
businessType: string, businessDate: string | null, ruleVersion: string | null, createdAt: string | null
|
|
}
|
|
export type PointsLedgerPage = { items: PointsLedgerItem[], snapshotId: string, nextBeforeId: string | null }
|
|
export type PointsTaskPage = { serverDate: string, timezone: string, items: PointsTask[] }
|
|
export type CheckInRequest = { requestId: string, expectedRuleVersion: string, expectedDate: string }
|
|
export type CheckInReceipt = { ledgerId: string, delta: number, balanceAfter: number, signedDate: string, ruleVersion: string, replayed: boolean }
|
|
|
|
const text = (data: UTSJSONObject, key: string): string => data.getString(key) ?? ''
|
|
const nullableText = (data: UTSJSONObject, key: string): string | null => data.getString(key)
|
|
const integer = (data: UTSJSONObject, key: string): number => {
|
|
const value = data.getNumber(key)
|
|
if (value == null || !Number.isInteger(value)) throw new Error('积分响应格式无效')
|
|
return value
|
|
}
|
|
const nullableInteger = (data: UTSJSONObject, key: string): number | null => {
|
|
const value = data.getNumber(key)
|
|
if (value != null && (!Number.isInteger(value) || value < 1 || value > 1000)) throw new Error('积分奖励格式无效')
|
|
return value
|
|
}
|
|
const requiredDate = (value: string): string => {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error('积分日期格式无效')
|
|
return value
|
|
}
|
|
const validId = (value: string, allowZero: boolean): boolean => {
|
|
if (!(allowZero ? /^(?:0|[1-9][0-9]{0,9})$/.test(value) : /^[1-9][0-9]{0,9}$/.test(value))) return false
|
|
return !(value.length == 10 && value > '4294967295')
|
|
}
|
|
const requiredId = (value: string): string => {
|
|
if (!validId(value, false)) throw new Error('积分流水编号无效')
|
|
return value
|
|
}
|
|
const snapshotId = (value: string): string => {
|
|
if (!validId(value, true)) throw new Error('积分快照编号无效')
|
|
return value
|
|
}
|
|
const optionalId = (value: string | null): string | null => value == null || value.length == 0 ? null : requiredId(value)
|
|
const ensureSession = (): void => { if (getSession() == null) throw new Error('请先登录后查看积分') }
|
|
const ensureStatus = (value: string): string => {
|
|
if (!['draft', 'published', 'paused'].includes(value)) throw new Error('积分规则状态无效')
|
|
return value
|
|
}
|
|
const ensureTaskStatus = (value: string): string => {
|
|
if (!['available', 'completed', 'unavailable'].includes(value)) throw new Error('积分任务状态无效')
|
|
return value
|
|
}
|
|
|
|
function summaryFrom(data: UTSJSONObject): PointsSummary {
|
|
const serverDate = requiredDate(text(data, 'serverDate'))
|
|
const ruleVersion = nullableText(data, 'ruleVersion')
|
|
const reward = nullableInteger(data, 'checkInReward')
|
|
const balanceState = text(data, 'balanceState')
|
|
if (!['normal', 'anomaly'].includes(balanceState)) throw new Error('积分余额状态无效')
|
|
const signedToday = data.getBoolean('signedToday')
|
|
const checkInEnabled = data.getBoolean('checkInEnabled')
|
|
if (signedToday == null || checkInEnabled == null) throw new Error('积分响应格式无效')
|
|
return { balance: integer(data, 'balance'), serverDate, timezone: text(data, 'timezone'), signedToday, checkInEnabled, ruleVersion, checkInReward: reward, balanceState }
|
|
}
|
|
|
|
export async function fetchPointsSummary(): Promise<PointsSummary> {
|
|
ensureSession()
|
|
return summaryFrom(await requestJson('/points', 'GET', {}))
|
|
}
|
|
|
|
export async function fetchPointsRules(): Promise<PointsRules> {
|
|
ensureSession()
|
|
const data = await requestJson('/points/rules', 'GET', {})
|
|
const published = data['publishedRules']
|
|
return {
|
|
status: ensureStatus(text(data, 'status')), ruleVersion: nullableText(data, 'ruleVersion'),
|
|
checkInReward: nullableInteger(data, 'checkInReward'), timezone: text(data, 'timezone'),
|
|
publishedRules: published != null && typeof published == 'object' ? published as UTSJSONObject : null
|
|
}
|
|
}
|
|
|
|
function ledgerFrom(data: UTSJSONObject): PointsLedgerItem {
|
|
const businessDate = nullableText(data, 'businessDate')
|
|
const businessType = text(data, 'businessType')
|
|
if (businessDate != null) requiredDate(businessDate)
|
|
if (!['checkin', 'legacy'].includes(businessType)) throw new Error('积分流水类型无效')
|
|
return {
|
|
ledgerId: requiredId(text(data, 'ledgerId')), delta: integer(data, 'delta'), balanceBefore: integer(data, 'balanceBefore'), balanceAfter: integer(data, 'balanceAfter'),
|
|
businessType, businessDate, ruleVersion: nullableText(data, 'ruleVersion'), createdAt: nullableText(data, 'createdAt')
|
|
}
|
|
}
|
|
|
|
export async function fetchPointsLedger(limit: number = 50, snapshot: string = '', before: string = ''): Promise<PointsLedgerPage> {
|
|
ensureSession()
|
|
if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new Error('积分流水分页参数无效')
|
|
const payload: UTSJSONObject = { limit: limit.toString() }
|
|
if (snapshot.length > 0) payload['snapshotId'] = snapshotId(snapshot)
|
|
if (before.length > 0) payload['beforeId'] = requiredId(before)
|
|
const data = await requestJson('/points/ledger', 'GET', payload)
|
|
const rows = data['items']
|
|
if (rows == null || !Array.isArray(rows)) throw new Error('积分流水格式无效')
|
|
return {
|
|
items: (rows as UTSJSONObject[]).map((row: UTSJSONObject): PointsLedgerItem => ledgerFrom(row)),
|
|
snapshotId: snapshotId(text(data, 'snapshotId')), nextBeforeId: optionalId(data.getString('nextBeforeId'))
|
|
}
|
|
}
|
|
|
|
export async function fetchPointsTasks(): Promise<PointsTaskPage> {
|
|
ensureSession()
|
|
const data = await requestJson('/points/tasks', 'GET', {})
|
|
const rows = data['items']
|
|
if (rows == null || !Array.isArray(rows)) throw new Error('积分任务格式无效')
|
|
return {
|
|
serverDate: requiredDate(text(data, 'serverDate')), timezone: text(data, 'timezone'),
|
|
items: (rows as UTSJSONObject[]).map((row: UTSJSONObject): PointsTask => ({
|
|
task: text(row, 'task'), status: ensureTaskStatus(text(row, 'status')), reward: nullableInteger(row, 'reward'), ruleVersion: nullableText(row, 'ruleVersion')
|
|
}))
|
|
}
|
|
}
|
|
|
|
export function createCheckInRequest(summary: PointsSummary): CheckInRequest {
|
|
if (!summary.checkInEnabled || summary.signedToday || summary.ruleVersion == null || summary.checkInReward == null) throw new Error('签到活动暂不可用')
|
|
let value = ''
|
|
for (let index = 0; index < 32; index++) value += Math.floor(Math.random() * 16).toString(16)
|
|
return { requestId: 'pts_' + value, expectedRuleVersion: summary.ruleVersion, expectedDate: summary.serverDate }
|
|
}
|
|
|
|
export async function submitPointsCheckIn(input: CheckInRequest): Promise<CheckInReceipt> {
|
|
ensureSession()
|
|
if (!/^pts_[0-9a-f]{32}$/.test(input.requestId) || !/^[a-z][a-z0-9-]{1,63}$/.test(input.expectedRuleVersion)) throw new Error('签到请求参数无效')
|
|
requiredDate(input.expectedDate)
|
|
const data = await requestJson('/points/check-in', 'POST', { requestId: input.requestId, expectedRuleVersion: input.expectedRuleVersion, expectedDate: input.expectedDate })
|
|
const replayed = data.getBoolean('replayed')
|
|
if (replayed == null) throw new Error('签到回执格式无效')
|
|
const receipt: CheckInReceipt = {
|
|
ledgerId: requiredId(text(data, 'ledgerId')), delta: integer(data, 'delta'), balanceAfter: integer(data, 'balanceAfter'),
|
|
signedDate: requiredDate(text(data, 'signedDate')), ruleVersion: text(data, 'ruleVersion'), replayed
|
|
}
|
|
if (receipt.delta < 1 || receipt.delta > 1000 || receipt.balanceAfter < 0 || receipt.balanceAfter > 2147483647) throw new Error('签到回执格式无效')
|
|
if (receipt.signedDate != input.expectedDate || receipt.ruleVersion != input.expectedRuleVersion) throw new Error('签到回执与请求不一致')
|
|
return receipt
|
|
}
|