feat: 接入积分服务端真实接口并完成隔离联调
新增积分服务端接口封装、积分中心页面,支持登录回跳、签到恢复、积分明细等功能。清理缓存时保留未确认签到键,新增ApiRequestError错误类型区分业务状态,更新开发文档记录积分页面联调完成情况。
This commit is contained in:
+13
-1
@@ -6,6 +6,16 @@ export { API_BASE_URL, API_DEMO_ENABLED } from './config.uts'
|
||||
const REQUEST_TIMEOUT = 10000
|
||||
const SESSION_KEY = 'aiFinanceSession'
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
status: number
|
||||
errorCode: string
|
||||
constructor(message: string, status: number, errorCode: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.errorCode = errorCode
|
||||
}
|
||||
}
|
||||
|
||||
const wait = (duration: number) => new Promise<void>((resolve) => { setTimeout(() => resolve(), duration) })
|
||||
|
||||
export function saveSession(token: string, phone: string, userId: string, expiresAt: string): void {
|
||||
@@ -74,7 +84,9 @@ const requestJson = (path: string, method: RequestMethod, payload: UTSJSONObject
|
||||
const message = body['msg'] as string | null
|
||||
if (response.statusCode < 200 || response.statusCode >= 300 || code != 1) {
|
||||
if (code == 401 && sentToken.length > 0) clearSession()
|
||||
reject(new Error(message != null && message.length > 0 ? message : '请求失败,请稍后重试')); return
|
||||
const details = body['data']
|
||||
const errorCode = details != null && typeof details == 'object' ? (details as UTSJSONObject).getString('errorCode') ?? '' : ''
|
||||
reject(new ApiRequestError(message != null && message.length > 0 ? message : '请求失败,请稍后重试', response.statusCode, errorCode)); return
|
||||
}
|
||||
const data = body['data']
|
||||
if (data == null) resolve(new UTSJSONObject())
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
export function clearAccountCache(userId: string): void {
|
||||
if (userId.length == 0) return
|
||||
const prefix = 'airz:v1:user:' + userId + ':'
|
||||
const pendingCheckIn = prefix + 'finance.points.checkin.pending'
|
||||
uni.getStorageInfoSync().keys.forEach((key: string) => {
|
||||
if (key.startsWith(prefix)) uni.removeStorageSync(key)
|
||||
// 未确认的签到是恢复用命令上下文,仅保留原键/业务日/规则,重新登录后仍须服务端鉴权。
|
||||
if (key.startsWith(prefix) && key != pendingCheckIn) uni.removeStorageSync(key)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user