Files
jiapuapp/utils/api.js
T
2026-07-23 08:24:07 +08:00

499 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
currentUser,
genealogies,
publicGenealogies,
treeMembers,
notifications,
joinApplications,
listFamilyFeedFixtures,
listFamilyArticleFixtures,
listFamilyAlbumFixtures,
listCeremonyFixtures,
listGrowthRecordFixtures,
listNotificationFixtures
} from '@/data/mock.js'
import { hasRemoteConfig, resolveRuntimeMode, runtimeConfig } from '@/utils/config.js'
import { AUTH_TAC_SCENE, assertSmsCode } from '@/utils/auth-verification.js'
import { GENEALOGY_ACCESS_PRESET } from '@/utils/genealogy-contracts.js'
import { session } from '@/utils/session.js'
const successCodes = [0, 200]
const REQUEST_TIMEOUT_MS = 15000
const createRequestError = (message, code, details = {}) => {
const error = new Error(message)
error.code = code
Object.assign(error, details)
return error
}
const createRequestCancelledError = () => createRequestError('请求已取消', 'REQUEST_CANCELLED')
// 页面只持有这个窄控制器,不直接依赖各端 RequestTask 的实现差异。
// request() 是唯一绑定/释放 owner;离页时 abort() 会同时拒绝等待中的 Promise,
// 因而页面不会在已销毁后继续处理成功回调。
export const createRequestController = () => {
let abortCurrent = null
return {
bind(abortRequest) {
if (typeof abortRequest !== 'function') throw new TypeError('请求中止器必须是函数')
if (abortCurrent) abortCurrent()
abortCurrent = abortRequest
},
release(abortRequest) {
if (abortCurrent === abortRequest) abortCurrent = null
},
abort() {
const abortRequest = abortCurrent
abortCurrent = null
if (abortRequest) abortRequest()
}
}
}
export const isRequestCancelled = (error) => error?.code === 'REQUEST_CANCELLED'
export const unwrapResponse = (response) => {
if (!response || typeof response !== 'object' || Array.isArray(response)) return response
if (!Object.prototype.hasOwnProperty.call(response, 'code')) return response
if (!successCodes.includes(Number(response.code))) {
const numericCode = Number(response.code)
throw createRequestError(response.msg || '请求未成功', 'BUSINESS_ERROR', {
businessCode: Number.isNaN(numericCode) ? response.code : numericCode
})
}
return Object.prototype.hasOwnProperty.call(response, 'data') ? response.data : response
}
const request = (options, {
authenticated = true,
strictEnvelope = false,
requireEnvelopeData = false,
expectedStatus = null,
requestController = null
} = {}) => new Promise((resolve, reject) => {
if (requestController !== null && (
typeof requestController.bind !== 'function' ||
typeof requestController.release !== 'function'
)) {
reject(new TypeError('请求控制器格式无效'))
return
}
let settled = false
let requestTask = null
const release = () => requestController?.release(abortRequest)
const resolveOnce = (value) => {
if (settled) return
settled = true
release()
resolve(value)
}
const rejectOnce = (error) => {
if (settled) return
settled = true
release()
reject(error)
}
const abortRequest = () => {
if (settled) return
const task = requestTask
rejectOnce(createRequestCancelledError())
task?.abort?.()
}
const token = session.getToken()
requestTask = uni.request({
...options,
url: `${runtimeConfig.baseUrl}${options.url}`,
header: {
clientid: runtimeConfig.clientId,
...(authenticated && token ? { Authorization: `Bearer ${token}` } : {}),
...(options.header || {})
},
success: ({ data, statusCode }) => {
if (expectedStatus !== null && statusCode !== expectedStatus) {
rejectOnce(createRequestError(
`服务只接受 HTTP ${expectedStatus} 响应,实际为 ${statusCode}`,
'HTTP_ERROR',
{ httpStatus: statusCode }
))
return
}
if (statusCode < 200 || statusCode >= 300) {
rejectOnce(createRequestError(
data?.msg || `请求失败(${statusCode}`,
'HTTP_ERROR',
{ httpStatus: statusCode }
))
return
}
if (
strictEnvelope &&
(!data ||
typeof data !== 'object' ||
Array.isArray(data) ||
!Object.prototype.hasOwnProperty.call(data, 'code') ||
!Number.isInteger(data.code) ||
(requireEnvelopeData && !Object.prototype.hasOwnProperty.call(data, 'data')))
) {
rejectOnce(createRequestError('服务响应格式无效', 'RESPONSE_INVALID'))
return
}
try {
resolveOnce(unwrapResponse(data))
} catch (error) {
rejectOnce(error)
}
},
fail: (error) => {
const message = error?.errMsg || '网络连接失败'
rejectOnce(createRequestError(
message,
/timeout/i.test(message) ? 'REQUEST_TIMEOUT' : 'NETWORK_ERROR'
))
}
})
if (!settled && requestController) requestController.bind(abortRequest)
})
const authPayload = (payload) => ({
clientId: runtimeConfig.clientId,
tenantId: runtimeConfig.tenantId,
...payload
})
// 已核对接口使用这一严格边界:只接受 HTTP 200 与完整 JSON envelope
// 并统一限制弱网等待时间。尚未逐页治理的旧业务读取仍沿用上方通用 request。
const requestStrict = (options, {
authenticated = true,
requireData = true,
requestController = null
} = {}) => request({
...options,
timeout: REQUEST_TIMEOUT_MS
}, {
authenticated,
strictEnvelope: true,
requireEnvelopeData: requireData,
expectedStatus: 200,
requestController
})
const requestAuth = (options, { requestController = null } = {}) => requestStrict(options, {
authenticated: false,
requestController
})
// RVoid 的 OpenAPI schema 没有把 data 声明为必填。空结果接口仍要求合法的
// HTTP 200 与整数 code,但统一向页面返回 null,避免把 envelope 本身泄漏成业务数据。
const requestAuthVoid = async (options, { requestController = null } = {}) => {
await requestStrict(options, {
authenticated: false,
requireData: false,
requestController
})
return null
}
const requireRemoteAuth = () => {
if (resolveRuntimeMode() !== 'remote') {
const error = new Error('当前为本地预览模式,真实认证服务未启用')
error.code = 'AUTH_REMOTE_REQUIRED'
throw error
}
}
const assertAuthScene = (sceneCode) => {
if (!Object.values(AUTH_TAC_SCENE).includes(sceneCode)) {
throw new TypeError('短信场景不属于当前认证合同')
}
return sceneCode
}
const assertValidToken = (validToken) => {
if (typeof validToken !== 'string' || !validToken.trim()) {
throw new TypeError('发送短信前必须取得有效的行为验证票据')
}
return validToken.trim()
}
const assertPasswordHash = (passwordHash) => {
if (typeof passwordHash !== 'string' || !/^[a-f0-9]{32}$/.test(passwordHash)) {
throw new TypeError('密码摘要必须是 32 位小写 MD5')
}
return passwordHash
}
const normalizeFeedbackPayload = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.getPrototypeOf(payload) !== Object.prototype) {
throw new TypeError('反馈请求必须是普通对象')
}
const allowedFields = new Set(['feedbackType', 'feedbackContent', 'contactInfo'])
const fieldNames = Object.getOwnPropertyNames(payload)
if (Object.getOwnPropertySymbols(payload).length > 0 || fieldNames.some((field) => !allowedFields.has(field))) {
throw new TypeError('反馈请求包含未声明字段')
}
const descriptors = Object.getOwnPropertyDescriptors(payload)
if (fieldNames.some((field) => !Object.prototype.hasOwnProperty.call(descriptors[field], 'value'))) {
throw new TypeError('反馈字段必须是普通数据属性')
}
if (typeof payload.feedbackContent !== 'string' || !payload.feedbackContent.trim()) {
throw new TypeError('反馈内容必须是非空字符串')
}
const normalized = { feedbackContent: payload.feedbackContent.trim() }
for (const optionalField of ['feedbackType', 'contactInfo']) {
if (!Object.prototype.hasOwnProperty.call(payload, optionalField)) continue
if (typeof payload[optionalField] !== 'string') {
throw new TypeError(`反馈字段 ${optionalField} 必须是字符串`)
}
const value = payload[optionalField].trim()
if (value) normalized[optionalField] = value
}
return normalized
}
const saveLogin = (loginResult) => {
const token = loginResult?.access_token
if (!token) throw new Error('登录响应未包含会话令牌')
session.saveToken(token)
return loginResult
}
const toTreeNode = (person, index = 0) => {
const generation = Number(person.generationNo || person.generation || 1)
return {
...person,
relatives: Array.isArray(person.relatives)
? person.relatives.map((relative) => ({ ...relative }))
: [],
id: person.id || person.personId,
name: person.personName || person.name || '未命名族人',
relation: person.relation || (generation === 1 ? '始祖' : '族人'),
generation,
years: person.years || [person.birthDate, person.deathDate].filter(Boolean).join('—') || '生卒待补',
branch: person.branch || '主支',
x: person.x ?? (20 + (index % 4) * 20),
y: person.y ?? (generation * 31 - 24)
}
}
export const appApi = {
async getCaptchaRequirement({ sceneCode, subject }, requestOptions = {}) {
requireRemoteAuth()
return requestAuth({
url: '/captcha/require',
method: 'GET',
data: authPayload({ sceneCode: assertAuthScene(sceneCode), subject })
}, requestOptions)
},
async sendSmsCode({ sceneCode, phone, validToken }, requestOptions = {}) {
requireRemoteAuth()
return requestAuthVoid({
url: '/genealogy/app/auth/sms/code',
method: 'POST',
data: authPayload({
grantType: 'sms',
sceneCode: assertAuthScene(sceneCode),
phone,
validToken: assertValidToken(validToken)
})
}, requestOptions)
},
async loginWithPassword({ phone, passwordHash }, requestOptions = {}) {
requireRemoteAuth()
const result = await requestAuth({
url: '/genealogy/app/auth/login',
method: 'POST',
data: authPayload({ grantType: 'password', phone, password: assertPasswordHash(passwordHash) })
}, requestOptions)
return saveLogin(result)
},
async loginWithSms({ phone, smsCode }, requestOptions = {}) {
requireRemoteAuth()
const result = await requestAuth({
url: '/genealogy/app/auth/login/sms',
method: 'POST',
data: authPayload({ grantType: 'sms', phone, smsCode: assertSmsCode(smsCode) })
}, requestOptions)
return saveLogin(result)
},
async registerWithPassword({ phone, passwordHash, smsCode }, requestOptions = {}) {
requireRemoteAuth()
const result = await requestAuth({
url: '/genealogy/app/auth/register',
method: 'POST',
data: authPayload({
grantType: 'password',
phone,
password: assertPasswordHash(passwordHash),
smsCode: assertSmsCode(smsCode)
})
}, requestOptions)
return saveLogin(result)
},
async resetPassword({ phone, passwordHash, smsCode }, requestOptions = {}) {
requireRemoteAuth()
return requestAuthVoid({
url: '/genealogy/app/auth/password/reset',
method: 'PUT',
data: authPayload({
grantType: 'password',
phone,
newPassword: assertPasswordHash(passwordHash),
smsCode: assertSmsCode(smsCode)
})
}, requestOptions)
},
async submitFeedback(payload, requestOptions = {}) {
const data = normalizeFeedbackPayload(payload)
if (!requestOptions || typeof requestOptions !== 'object' || Array.isArray(requestOptions) || Object.getPrototypeOf(requestOptions) !== Object.prototype) {
throw new TypeError('反馈请求选项必须是普通对象')
}
const optionNames = Object.getOwnPropertyNames(requestOptions)
const optionDescriptors = Object.getOwnPropertyDescriptors(requestOptions)
if (
Object.getOwnPropertySymbols(requestOptions).length > 0 ||
optionNames.some((field) => field !== 'requestController') ||
optionNames.some((field) => !Object.prototype.hasOwnProperty.call(optionDescriptors[field], 'value'))
) {
throw new TypeError('反馈请求选项包含未声明字段')
}
const runtimeMode = resolveRuntimeMode()
if (runtimeMode !== 'remote') {
throw createRequestError('当前为本地预览模式,反馈未提交服务器', 'WRITE_UNAVAILABLE')
}
return requestStrict({
url: '/genealogy/app/feedback',
method: 'POST',
data
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
},
async getProfile() {
return hasRemoteConfig() ? request({ url: '/genealogy/app/auth/profile' }) : currentUser
},
async getMyGenealogies() {
return hasRemoteConfig() ? request({ url: '/genealogy/app/genealogies/mine' }) : genealogies
},
async createGenealogy(payload) {
if (hasRemoteConfig()) return request({ url: '/genealogy/app/genealogies', method: 'POST', data: payload })
const accessPreset = Object.values(GENEALOGY_ACCESS_PRESET).includes(payload.accessPreset)
? payload.accessPreset
: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY
const created = { ...payload, id: String(Date.now()), memberCount: 0, activeCount: 0, motto: '敦亲睦族,敬祖传家。', accessPreset, membership: 'created' }
genealogies.unshift(created)
return created
},
async getGenealogy(genealogyId) {
return hasRemoteConfig() ? request({ url: `/genealogy/app/genealogies/${genealogyId}` }) : genealogies.find((item) => String(item.id) === String(genealogyId))
},
async getOverview(genealogyId) {
return hasRemoteConfig() ? request({ url: `/genealogy/app/genealogies/${genealogyId}/overview` }) : await this.getGenealogy(genealogyId)
},
async getTree(genealogyId) {
if (hasRemoteConfig()) {
const tree = await request({ url: `/genealogy/app/genealogies/${genealogyId}/lineage/tree` })
return (tree || []).map(toTreeNode)
}
return treeMembers
.filter((item) => String(item.genealogyId) === String(genealogyId))
.map(toTreeNode)
},
async getPerson(genealogyId, personId) {
if (hasRemoteConfig()) {
const person = await request({ url: `/genealogy/app/genealogies/${genealogyId}/lineage/persons/${personId}` })
return toTreeNode(person)
}
const person = treeMembers.find(
(item) => String(item.id) === String(personId) &&
String(item.genealogyId) === String(genealogyId)
)
return person ? toTreeNode(person) : null
},
async createPerson(genealogyId, payload) {
if (hasRemoteConfig()) {
const person = await request({ url: `/genealogy/app/genealogies/${genealogyId}/lineage/persons`, method: 'POST', data: payload })
return toTreeNode(person)
}
const error = new Error('人物创建接口在本地预览模式不可用,当前内容不会保存')
error.code = 'WRITE_UNAVAILABLE'
throw error
},
async getFeeds(genealogyId) {
return hasRemoteConfig()
? request({ url: `/genealogy/app/genealogies/${genealogyId}/feeds` })
: listFamilyFeedFixtures(genealogyId)
},
async createFeed(genealogyId, payload) {
if (hasRemoteConfig()) return request({ url: `/genealogy/app/genealogies/${genealogyId}/feeds`, method: 'POST', data: payload })
const error = new Error('动态发布接口尚未接入,当前内容不会保存')
error.code = 'WRITE_UNAVAILABLE'
throw error
},
async getArticles(genealogyId) {
return hasRemoteConfig()
? request({ url: `/genealogy/app/genealogies/${genealogyId}/articles` })
: listFamilyArticleFixtures(genealogyId)
},
async getAlbums(genealogyId) {
return hasRemoteConfig()
? request({ url: `/genealogy/app/genealogies/${genealogyId}/albums` })
: listFamilyAlbumFixtures(genealogyId)
},
async getCeremonies(genealogyId) {
return hasRemoteConfig() ? request({ url: `/genealogy/app/genealogies/${genealogyId}/ceremonies` }) : listCeremonyFixtures(genealogyId)
},
async getGrowthRecords(genealogyId) {
return hasRemoteConfig() ? request({ url: `/genealogy/app/genealogies/${genealogyId}/growth-records` }) : listGrowthRecordFixtures(genealogyId)
},
async getContent(type, genealogyId) {
const loaders = { article: this.getArticles, album: this.getAlbums, ceremony: this.getCeremonies, record: this.getGrowthRecords }
if (!loaders[type]) throw new Error('未知内容类型')
return loaders[type].call(this, genealogyId)
},
async getNotifications() {
return hasRemoteConfig() ? request({ url: '/genealogy/app/notifications' }) : listNotificationFixtures()
},
async markNotificationRead(notificationId) {
if (hasRemoteConfig()) return request({ url: `/genealogy/app/notifications/${notificationId}/read`, method: 'POST' })
const notification = notifications.find((item) => String(item.id) === String(notificationId))
if (notification) notification.unread = false
return { success: true }
},
async markAllNotificationsRead() {
if (hasRemoteConfig()) return request({ url: '/genealogy/app/notifications/read-all', method: 'POST' })
notifications.forEach((item) => { item.unread = false })
return { success: true }
},
async getPublicGenealogies() {
return hasRemoteConfig() ? request({ url: '/genealogy/app/genealogies/public' }) : publicGenealogies
},
async applyToJoin(genealogyId, payload) {
if (hasRemoteConfig()) return request({ url: `/genealogy/app/genealogies/${genealogyId}/join-applies`, method: 'POST', data: payload })
joinApplications.unshift({
id: String(Date.now()),
genealogyId,
name: payload.applicantName || currentUser.name,
phone: payload.phone || currentUser.phone,
relation: payload.relationDesc || '关系待补充',
reason: payload.applyReason || '',
appliedAt: '刚刚',
status: 'PENDING'
})
return { success: true }
},
async getPendingApplications(genealogyId) {
return hasRemoteConfig() ? request({ url: `/genealogy/app/genealogies/${genealogyId}/join-applies/pending` }) : joinApplications.filter((item) => String(item.genealogyId || 1001) === String(genealogyId))
},
async auditApplication(genealogyId, applicationId, { status, auditRemark = '' }) {
if (typeof status !== 'string' || !/^[12]$/.test(status)) throw new TypeError('审核状态只能是 1 或 2')
if (typeof auditRemark !== 'string') throw new TypeError('审核备注必须是字符串')
if (Array.from(auditRemark).length > 500) throw new RangeError('审核备注不能超过 500 个字符')
if (hasRemoteConfig()) return request({ url: `/genealogy/app/genealogies/${genealogyId}/join-applies/${applicationId}/audit`, method: 'PUT', data: { status, auditRemark } })
const application = joinApplications.find((item) => String(item.id) === String(applicationId))
if (application) Object.assign(application, { status, auditRemark })
return { success: true }
}
}