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 } } }