feat: migrate app routes and business modules

This commit is contained in:
2026-08-12 18:22:59 +08:00
parent 555aa00043
commit cc706378c2
247 changed files with 28623 additions and 14988 deletions
+42
View File
@@ -0,0 +1,42 @@
import { resolveRuntimeMode } from '@/utils/runtime-config.js'
import { AUTH_VERIFICATION_OPERATION } from '@/utils/auth/verification.js'
import { session } from '@/utils/session.js'
export const requireRemoteAuth = () => {
if (resolveRuntimeMode() !== 'remote') {
const error = new Error('当前为本地预览模式,真实认证服务未启用')
error.code = 'AUTH_REMOTE_REQUIRED'
throw error
}
}
export const assertAuthVerificationOperation = (operationCode) => {
if (!Object.values(AUTH_VERIFICATION_OPERATION).includes(operationCode)) {
throw new TypeError('认证动作不属于当前认证合同')
}
return operationCode
}
export const assertValidToken = (validToken) => {
if (typeof validToken !== 'string' || !validToken.trim()) {
throw new TypeError('发送短信前必须取得有效的行为验证票据')
}
return validToken.trim()
}
export const normalizeOptionalValidToken = (validToken) =>
validToken === undefined ? undefined : assertValidToken(validToken)
export const assertPasswordHash = (passwordHash) => {
if (typeof passwordHash !== 'string' || !/^[a-f0-9]{32}$/.test(passwordHash)) {
throw new TypeError('密码摘要必须是 32 位小写 MD5')
}
return passwordHash
}
export const saveLogin = (loginResult) => {
const token = loginResult?.access_token
if (!token) throw new Error('登录响应未包含会话令牌')
session.saveToken(token)
return loginResult
}
+161
View File
@@ -0,0 +1,161 @@
import { assertSmsCode } from '@/utils/auth/verification.js'
import { resolveRuntimeMode, runtimeConfig } from '@/utils/runtime-config.js'
import {
assertAuthVerificationOperation,
assertPasswordHash,
normalizeOptionalValidToken,
requireRemoteAuth,
saveLogin
} from './auth-contract.js'
import { normalizeOptionalText } from './request-normalizers.js'
import { normalizePhoneChangePayload } from './profile-contract.js'
import {
requestAuth,
requestAuthVoid,
requestStrict
} from './request-client.js'
export const authApi = {
async getCaptchaRequirement({ operationCode, subject }, requestOptions = {}) {
requireRemoteAuth()
return requestAuth({
url: `/genealogy/app/auth/verification/${encodeURIComponent(assertAuthVerificationOperation(operationCode))}/require`,
method: 'GET',
data: { tenantId: runtimeConfig.tenantId, subject }
}, requestOptions)
},
async sendSmsCode({ operationCode, phone, validToken }, requestOptions = {}) {
requireRemoteAuth()
const normalizedValidToken = normalizeOptionalValidToken(validToken)
return requestAuthVoid({
url: `/genealogy/app/auth/sms/${encodeURIComponent(assertAuthVerificationOperation(operationCode))}/code`,
method: 'POST',
data: {
tenantId: runtimeConfig.tenantId,
grantType: 'sms',
phone,
...(normalizedValidToken ? { validToken: normalizedValidToken } : {})
}
}, requestOptions)
},
async loginWithPassword({ phone, passwordHash, validToken }, requestOptions = {}) {
requireRemoteAuth()
const normalizedValidToken = normalizeOptionalValidToken(validToken)
const loginSession = await requestAuth({
url: '/genealogy/app/auth/login',
method: 'POST',
data: {
tenantId: runtimeConfig.tenantId,
grantType: 'password',
phone,
password: assertPasswordHash(passwordHash),
...(normalizedValidToken ? { validToken: normalizedValidToken } : {})
}
}, requestOptions)
return saveLogin(loginSession)
},
async loginWithSms({ phone, smsCode }, requestOptions = {}) {
requireRemoteAuth()
const loginSession = await requestAuth({
url: '/genealogy/app/auth/login/sms',
method: 'POST',
data: {
tenantId: runtimeConfig.tenantId,
grantType: 'sms',
phone,
smsCode: assertSmsCode(smsCode)
}
}, requestOptions)
return saveLogin(loginSession)
},
async registerWithPassword({ phone, passwordHash, smsCode, nickName }, requestOptions = {}) {
requireRemoteAuth()
const normalizedNickName = normalizeOptionalText(nickName, '昵称')
const loginSession = await requestAuth({
url: '/genealogy/app/auth/register',
method: 'POST',
data: {
tenantId: runtimeConfig.tenantId,
grantType: 'password',
phone,
password: assertPasswordHash(passwordHash),
smsCode: assertSmsCode(smsCode),
...(normalizedNickName ? { nickName: normalizedNickName } : {})
}
}, requestOptions)
return saveLogin(loginSession)
},
async resetPassword({ phone, passwordHash, smsCode }, requestOptions = {}) {
requireRemoteAuth()
return requestAuthVoid({
url: '/genealogy/app/auth/password/reset',
method: 'PUT',
data: {
tenantId: runtimeConfig.tenantId,
grantType: 'password',
phone,
newPassword: assertPasswordHash(passwordHash),
smsCode: assertSmsCode(smsCode)
}
}, requestOptions)
},
async changePassword({ oldPasswordHash, newPasswordHash }, requestOptions = {}) {
requireRemoteAuth()
await requestStrict({
url: '/genealogy/app/auth/password',
method: 'PUT',
data: {
oldPassword: assertPasswordHash(oldPasswordHash),
newPassword: assertPasswordHash(newPasswordHash)
}
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
},
async deactivateAccount({ smsCode }, requestOptions = {}) {
requireRemoteAuth()
await requestStrict({
url: '/genealogy/app/auth/account/deactivate',
method: 'POST',
data: { smsCode: assertSmsCode(smsCode) }
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
},
async logout(requestOptions = {}) {
if (resolveRuntimeMode() !== 'remote') return null
await requestStrict({
url: '/genealogy/app/auth/logout',
method: 'DELETE'
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
},
async changePhone(payload, requestOptions = {}) {
requireRemoteAuth()
await requestStrict({
url: '/genealogy/app/auth/phone',
method: 'PUT',
data: normalizePhoneChangePayload(payload)
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
}
}
@@ -0,0 +1,44 @@
import { createRequestError } from './request-client.js'
import {
normalizeOptionalNonnegativeInteger,
normalizeOptionalNumericId,
normalizeResponseText
} from './response-normalizers.js'
const supportedDictionaryTypes = new Set([
'gen_ceremony_type',
'gen_growth_record_type'
])
export const normalizeBusinessDictionaryType = (dictType) => {
if (!supportedDictionaryTypes.has(dictType)) {
throw new TypeError('当前页面不支持该业务字典')
}
return dictType
}
export const normalizeBusinessDictionaryOptions = (dictType, value) => {
normalizeBusinessDictionaryType(dictType)
if (!Array.isArray(value)) {
throw createRequestError('业务字典响应不是列表', 'BUSINESS_DICTIONARY_RESPONSE_INVALID')
}
const values = new Set()
return value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('业务字典响应包含无效条目', 'BUSINESS_DICTIONARY_RESPONSE_INVALID')
}
const optionValue = normalizeResponseText(item.value, 'value')
const label = normalizeResponseText(item.label, 'label')
if (!optionValue || !label || values.has(optionValue)) {
throw createRequestError('业务字典响应缺少稳定选项', 'BUSINESS_DICTIONARY_RESPONSE_INVALID')
}
values.add(optionValue)
return {
code: normalizeOptionalNumericId(item.code, '业务字典编码', 'BUSINESS_DICTIONARY_RESPONSE_INVALID'),
value: optionValue,
label,
sort: normalizeOptionalNonnegativeInteger(item.sort, '业务字典排序值', 'BUSINESS_DICTIONARY_RESPONSE_INVALID'),
default: item.default === true
}
})
}
@@ -0,0 +1,25 @@
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import {
normalizeBusinessDictionaryOptions,
normalizeBusinessDictionaryType
} from './business-dictionary-contract.js'
import { createRequestError, requestStrict } from './request-client.js'
export const businessDictionaryApi = {
async getBusinessDictionaryOptions(dictType, requestOptions = {}) {
const normalizedType = normalizeBusinessDictionaryType(dictType)
if (!hasRemoteConfig()) {
throw createRequestError(
'业务字典读取需要真实服务,当前本地预览不会伪造结果',
'REMOTE_READ_REQUIRED'
)
}
const dictionaryOptions = await requestStrict({
url: `/genealogy/app/dictionaries/${encodeURIComponent(normalizedType)}`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeBusinessDictionaryOptions(normalizedType, dictionaryOptions)
}
}
+63
View File
@@ -0,0 +1,63 @@
import { createRequestError } from './request-client.js'
import {
normalizeOptionalNonnegativeInteger,
normalizeOptionalNumericId
} from './response-normalizers.js'
export const normalizeResourceCapabilities = (value, label, code) => {
const capabilities = {}
for (const field of ['canEdit', 'canDelete']) {
if (value[field] !== undefined && typeof value[field] !== 'boolean') {
throw createRequestError(`${label}权限字段 ${field} 无效`, code)
}
capabilities[field] = value[field] === true
}
return capabilities
}
export const normalizeContentProtectionCapabilities = (value, label, code) => {
const capabilities = {}
for (const field of ['contentProtected', 'contentUnlocked', 'canManageProtection']) {
if (value[field] !== undefined && typeof value[field] !== 'boolean') {
throw createRequestError(`${label}内容保护字段 ${field} 无效`, code)
}
capabilities[field] = value[field] === true
}
return capabilities
}
export const normalizeBusinessFileAccess = (value, label, code, { required = false } = {}) => {
if (value === undefined || value === null) {
if (required) throw createRequestError(`${label}缺失`, code)
return null
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError(`${label}无效`, code)
}
const fileId = normalizeOptionalNumericId(value.fileId, `${label}文件标识`, code)
const ossId = normalizeOptionalNumericId(value.ossId, `${label}存储标识`, code)
if (!fileId || !ossId) throw createRequestError(`${label}缺少文件标识`, code)
const normalizeText = (field) => {
const fieldValue = value[field]
if (fieldValue === undefined || fieldValue === null) return ''
if (typeof fieldValue !== 'string') throw createRequestError(`${label}${field}无效`, code)
return fieldValue.trim()
}
const accessUrl = normalizeText('accessUrl')
if (required && !accessUrl) throw createRequestError(`${label}缺少授权访问地址`, code)
return {
fileId,
ossId,
fileName: normalizeText('fileName'),
mediaType: normalizeText('mediaType'),
fileSize: normalizeOptionalNonnegativeInteger(value.fileSize, `${label}文件大小`, code),
accessUrl,
expiresAt: normalizeText('expiresAt')
}
}
export const normalizeBusinessFileAccessRows = (value, label, code) => {
if (value === undefined || value === null) return []
if (!Array.isArray(value)) throw createRequestError(`${label}不是列表`, code)
return value.map((item) => normalizeBusinessFileAccess(item, label, code, { required: true }))
}
+325
View File
@@ -0,0 +1,325 @@
import { createRequestError } from './request-client.js'
import {
normalizeNormalDisableResponseStatus,
normalizeOptionalCurrencyAmount,
normalizeOptionalNonnegativeInteger,
normalizeOptionalNumericId,
normalizeResponseText
} from './response-normalizers.js'
import {
assertPlainPayload,
normalizeOptionalNormalDisableStatus,
normalizeOptionalSafeInteger,
normalizeOptionalText,
normalizeOssIdString,
normalizeResourcePathId
} from './request-normalizers.js'
import {
normalizeBusinessFileAccess,
normalizeResourceCapabilities
} from './business-file-contract.js'
import { normalizeGenealogyPathId } from './genealogy-contract.js'
const normalizeAppCeremonyCoordinate = (value, field) => {
if (value === undefined || value === null || value === '') return null
const coordinate = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(coordinate)) {
throw createRequestError(`礼仪活动响应字段 ${field} 无效`, 'CEREMONY_RESPONSE_INVALID')
}
return coordinate
}
const CEREMONY_TYPE_LABELS = Object.freeze({
ancestor: '祭祖',
memorial: '纪念',
funeral: '丧礼',
wedding: '婚礼',
birth: '出生宴',
birthday: '生日宴',
banquet: '宴席',
other: '其他'
})
const ceremonyTypes = new Set(Object.keys(CEREMONY_TYPE_LABELS))
export const CEREMONY_INVITATION_STATUS = Object.freeze({
PENDING: 'PENDING',
ACCEPTED: 'ACCEPTED',
DECLINED: 'DECLINED',
CANCELED: 'CANCELED'
})
export const CEREMONY_INVITATION_STATUS_LABELS = Object.freeze({
[CEREMONY_INVITATION_STATUS.PENDING]: '等待回应',
[CEREMONY_INVITATION_STATUS.ACCEPTED]: '已接受',
[CEREMONY_INVITATION_STATUS.DECLINED]: '已拒绝',
[CEREMONY_INVITATION_STATUS.CANCELED]: '已取消'
})
const ceremonyInvitationStatuses = new Set(
Object.values(CEREMONY_INVITATION_STATUS)
)
const ceremonyInvitationResponseStatuses = new Set([
CEREMONY_INVITATION_STATUS.ACCEPTED,
CEREMONY_INVITATION_STATUS.DECLINED
])
export const normalizeAppCeremony = (value, expectedGenealogyId, expectedCeremonyId = '') => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('礼仪活动响应无效', 'CEREMONY_RESPONSE_INVALID')
}
const id = normalizeOptionalNumericId(value.ceremonyId, '礼仪活动标识', 'CEREMONY_RESPONSE_INVALID')
const genealogyId = normalizeOptionalNumericId(value.genealogyId, '礼仪活动家谱标识', 'CEREMONY_RESPONSE_INVALID')
if (!id || genealogyId !== expectedGenealogyId || (expectedCeremonyId && id !== expectedCeremonyId)) {
throw createRequestError('礼仪活动响应缺少稳定归属字段', 'CEREMONY_RESPONSE_INVALID')
}
const location = normalizeResponseText(value.location, 'location')
const locationAddress = normalizeResponseText(value.locationAddress, 'locationAddress')
const longitude = normalizeAppCeremonyCoordinate(value.longitude, 'longitude')
const latitude = normalizeAppCeremonyCoordinate(value.latitude, 'latitude')
if ((longitude === null) !== (latitude === null)) {
throw createRequestError('礼仪活动响应的经纬度必须同时存在或同时为空', 'CEREMONY_RESPONSE_INVALID')
}
return {
id,
genealogyId,
title: normalizeResponseText(value.ceremonyTitle, 'ceremonyTitle') || '未命名活动',
type: normalizeResponseText(value.ceremonyType, 'ceremonyType'),
time: normalizeResponseText(value.ceremonyTime, 'ceremonyTime'),
location,
locationAddress,
longitude,
latitude,
description: normalizeResponseText(value.ceremonyDesc, 'ceremonyDesc'),
coverFile: normalizeBusinessFileAccess(value.coverFile, '礼仪活动封面', 'CEREMONY_RESPONSE_INVALID'),
giftCount: normalizeOptionalNonnegativeInteger(value.giftCount, '礼仪献礼数量', 'CEREMONY_RESPONSE_INVALID'),
sortOrder: normalizeOptionalNonnegativeInteger(value.sortOrder, '礼仪活动排序值', 'CEREMONY_RESPONSE_INVALID'),
status: normalizeNormalDisableResponseStatus(value.status, '礼仪活动状态', 'CEREMONY_RESPONSE_INVALID'),
...normalizeResourceCapabilities(value, '礼仪活动', 'CEREMONY_RESPONSE_INVALID')
}
}
export const normalizeAppCeremonyWithTypeLabel = (ceremony) => {
const typeLabel = CEREMONY_TYPE_LABELS[ceremony.type]
if (!ceremony.type || !typeLabel) {
throw createRequestError('礼仪活动响应包含未公开的活动类型', 'CEREMONY_RESPONSE_INVALID')
}
return { ...ceremony, typeLabel }
}
export const normalizeAppCeremonies = (value, expectedGenealogyId) => {
if (!Array.isArray(value)) throw createRequestError('礼仪活动响应不是列表', 'CEREMONY_RESPONSE_INVALID')
const ceremonies = value.map((item) => normalizeAppCeremony(item, expectedGenealogyId))
if (new Set(ceremonies.map((item) => item.id)).size !== ceremonies.length) {
throw createRequestError('礼仪活动响应包含重复标识', 'CEREMONY_RESPONSE_INVALID')
}
return ceremonies.map((item) => normalizeAppCeremonyWithTypeLabel(item))
}
export const normalizeAppCeremonyGift = (value, expectedGenealogyId, expectedCeremonyId) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('礼仪献礼响应无效', 'CEREMONY_GIFT_RESPONSE_INVALID')
}
const id = normalizeOptionalNumericId(value.giftId, '献礼标识', 'CEREMONY_GIFT_RESPONSE_INVALID')
const genealogyId = normalizeOptionalNumericId(value.genealogyId, '献礼家谱标识', 'CEREMONY_GIFT_RESPONSE_INVALID')
const ceremonyId = normalizeOptionalNumericId(value.ceremonyId, '献礼活动标识', 'CEREMONY_GIFT_RESPONSE_INVALID')
if (!id || genealogyId !== expectedGenealogyId || ceremonyId !== expectedCeremonyId) {
throw createRequestError('礼仪献礼响应缺少稳定归属字段', 'CEREMONY_GIFT_RESPONSE_INVALID')
}
return {
id,
genealogyId,
ceremonyId,
giverName: normalizeResponseText(value.giverName, 'giverName'),
giverNickName: normalizeResponseText(value.giverNickName, 'giverNickName'),
amount: normalizeOptionalCurrencyAmount(value.giftAmount, '献礼金额', 'CEREMONY_GIFT_RESPONSE_INVALID', { required: true }),
message: normalizeResponseText(value.giftMessage, 'giftMessage'),
time: normalizeResponseText(value.giftTime, 'giftTime'),
...normalizeResourceCapabilities(value, '礼仪献礼', 'CEREMONY_GIFT_RESPONSE_INVALID')
}
}
export const normalizeAppCeremonyGifts = (value, expectedGenealogyId, expectedCeremonyId) => {
if (!Array.isArray(value)) throw createRequestError('礼仪献礼响应不是列表', 'CEREMONY_GIFT_RESPONSE_INVALID')
const gifts = value.map((item) => normalizeAppCeremonyGift(item, expectedGenealogyId, expectedCeremonyId))
if (new Set(gifts.map((item) => item.id)).size !== gifts.length) {
throw createRequestError('礼仪献礼响应包含重复标识', 'CEREMONY_GIFT_RESPONSE_INVALID')
}
return gifts
}
export const projectPreviewCeremonies = (value, expectedGenealogyId) =>
value.map((item) => ({
id: String(item.ceremonyId),
genealogyId: expectedGenealogyId,
title: String(item.ceremonyTitle || '未命名活动'),
type: String(item.ceremonyType || ''),
typeLabel: CEREMONY_TYPE_LABELS[String(item.ceremonyType || '')] || '',
time: String(item.ceremonyTime || ''),
location: String(item.location || ''),
description: String(item.ceremonyDesc || ''),
coverFile: null,
giftCount: Number.isSafeInteger(item.giftCount) ? item.giftCount : 0,
canEdit: false,
canDelete: false
}))
export const normalizeCeremonyPayload = (payload) => {
const allowedFields = new Set(['ceremonyType', 'ceremonyTitle', 'ceremonyDesc', 'ceremonyTime', 'location', 'locationAddress', 'longitude', 'latitude', 'coverOssId', 'sortOrder', 'status'])
assertPlainPayload(payload, allowedFields, '礼仪活动请求')
const ceremonyType = normalizeOptionalText(payload.ceremonyType, 'ceremonyType')
const ceremonyTitle = normalizeOptionalText(payload.ceremonyTitle, 'ceremonyTitle')
if (!ceremonyType || !ceremonyTitle) throw new TypeError('请填写活动类型和活动标题')
if (!ceremonyTypes.has(ceremonyType)) throw new TypeError('请选择有效的活动类型')
const normalizedPayload = { ceremonyType, ceremonyTitle }
for (const field of ['ceremonyDesc', 'ceremonyTime', 'location', 'locationAddress']) {
const normalizedText = normalizeOptionalText(payload[field], field)
if (normalizedText) normalizedPayload[field] = normalizedText
}
const status = normalizeOptionalNormalDisableStatus(payload.status, '礼仪活动状态')
if (status) normalizedPayload.status = status
const hasLongitude = payload.longitude !== undefined && payload.longitude !== null && payload.longitude !== ''
const hasLatitude = payload.latitude !== undefined && payload.latitude !== null && payload.latitude !== ''
if (hasLongitude !== hasLatitude) throw new TypeError('longitude 和 latitude 必须同时提供')
if (hasLongitude) {
const longitude = typeof payload.longitude === 'number' ? payload.longitude : Number(payload.longitude)
const latitude = typeof payload.latitude === 'number' ? payload.latitude : Number(payload.latitude)
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
throw new TypeError('longitude 和 latitude 必须是有限数字')
}
normalizedPayload.longitude = longitude
normalizedPayload.latitude = latitude
}
if (payload.coverOssId !== undefined && payload.coverOssId !== null && payload.coverOssId !== '') {
normalizedPayload.coverOssId = normalizeOssIdString(payload.coverOssId, 'coverOssId')
}
const sortOrder = normalizeOptionalSafeInteger(payload.sortOrder, 'sortOrder')
if (sortOrder !== undefined) {
normalizedPayload.sortOrder = sortOrder
}
return normalizedPayload
}
export const normalizeCeremonyGiftPayload = (payload) => {
const allowedFields = new Set(['giverName', 'giftAmount', 'giftMessage'])
assertPlainPayload(payload, allowedFields, '礼仪献礼请求')
if (payload.giftAmount === undefined || payload.giftAmount === null || payload.giftAmount === '') {
throw new TypeError('请填写献礼金额')
}
const giftAmount = typeof payload.giftAmount === 'number' ? payload.giftAmount : Number(payload.giftAmount)
if (!Number.isFinite(giftAmount)) throw new TypeError('献礼金额必须是有限数字')
const normalizedPayload = { giftAmount }
for (const field of ['giverName', 'giftMessage']) {
const normalizedText = normalizeOptionalText(payload[field], field)
if (normalizedText) normalizedPayload[field] = normalizedText
}
return normalizedPayload
}
export const normalizeCeremonyInviteePayload = (payload) => {
assertPlainPayload(payload, new Set(['inviteeUserIds']), '活动受邀人请求')
if (!Array.isArray(payload.inviteeUserIds)) {
throw new TypeError('inviteeUserIds必须是数组')
}
const inviteeUserIds = payload.inviteeUserIds.map((value) =>
normalizeResourcePathId(value, '受邀业务用户标识')
)
if (new Set(inviteeUserIds).size !== inviteeUserIds.length) {
throw new TypeError('inviteeUserIds不能包含重复标识')
}
return { inviteeUserIds }
}
export const normalizeCeremonyInvitationResponsePayload = (payload) => {
assertPlainPayload(payload, new Set(['inviteStatus']), '活动邀请响应请求')
if (!ceremonyInvitationResponseStatuses.has(payload.inviteStatus)) {
throw new TypeError('inviteStatus仅允许ACCEPTED或DECLINED')
}
return { inviteStatus: payload.inviteStatus }
}
const normalizeCeremonyInvitationText = (value, field) => {
if (value === undefined || value === null) return ''
if (typeof value !== 'string') {
throw createRequestError(`活动邀请字段 ${field} 无效`, 'CEREMONY_INVITATION_RESPONSE_INVALID')
}
return value.trim()
}
export const normalizeCeremonyInvitationRows = (value, expectedGenealogyId = '') => {
if (!Array.isArray(value)) {
throw createRequestError('活动邀请响应不是数组', 'CEREMONY_INVITATION_RESPONSE_INVALID')
}
const seenIds = new Set()
return value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('活动邀请包含无效条目', 'CEREMONY_INVITATION_RESPONSE_INVALID')
}
const id = normalizeResourcePathId(item.invitationId, '活动邀请标识')
if (seenIds.has(id)) {
throw createRequestError('活动邀请包含重复标识', 'CEREMONY_INVITATION_RESPONSE_INVALID')
}
seenIds.add(id)
const genealogyId = normalizeGenealogyPathId(item.genealogyId)
if (expectedGenealogyId && genealogyId !== expectedGenealogyId) {
throw createRequestError('活动邀请家谱归属与请求不匹配', 'CEREMONY_INVITATION_RESPONSE_INVALID')
}
const ceremonyId = normalizeResourcePathId(item.ceremonyId, '礼仪活动标识')
const userKey = normalizeResourcePathId(item.inviteeUserId, '受邀业务用户标识')
const inviteStatus = normalizeCeremonyInvitationText(item.inviteStatus, 'inviteStatus')
if (!ceremonyInvitationStatuses.has(inviteStatus)) {
throw createRequestError('活动邀请状态无效', 'CEREMONY_INVITATION_RESPONSE_INVALID')
}
const inviteVersion = item.inviteVersion
if (inviteVersion !== undefined && (!Number.isSafeInteger(inviteVersion) || inviteVersion < 1)) {
throw createRequestError('活动邀请版本无效', 'CEREMONY_INVITATION_RESPONSE_INVALID')
}
return {
id,
genealogyId,
ceremonyId,
userKey,
inviteStatus,
inviteVersion: inviteVersion === undefined ? null : inviteVersion,
deliveredTime: normalizeCeremonyInvitationText(item.deliveredTime, 'deliveredTime'),
readTime: normalizeCeremonyInvitationText(item.readTime, 'readTime'),
responseTime: normalizeCeremonyInvitationText(item.responseTime, 'responseTime'),
ceremonyTitle: normalizeCeremonyInvitationText(item.ceremonyTitle, 'ceremonyTitle'),
ceremonyTime: normalizeCeremonyInvitationText(item.ceremonyTime, 'ceremonyTime'),
location: normalizeCeremonyInvitationText(item.location, 'location'),
locationAddress: normalizeCeremonyInvitationText(item.locationAddress, 'locationAddress')
}
})
}
export const normalizeCeremonyInviteeOptions = (value) => {
if (!Array.isArray(value)) {
throw createRequestError('活动受邀候选响应不是数组', 'CEREMONY_INVITEE_OPTIONS_RESPONSE_INVALID')
}
const seenUserIds = new Set()
return value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('活动受邀候选包含无效条目', 'CEREMONY_INVITEE_OPTIONS_RESPONSE_INVALID')
}
const memberId = normalizeResourcePathId(item.memberId, '受邀成员标识')
const appUserId = normalizeResourcePathId(item.appUserId, '受邀业务用户标识')
if (seenUserIds.has(appUserId)) {
throw createRequestError('活动受邀候选包含重复业务用户', 'CEREMONY_INVITEE_OPTIONS_RESPONSE_INVALID')
}
seenUserIds.add(appUserId)
const displayName = normalizeCeremonyInvitationText(item.displayName, 'displayName')
if (!displayName) {
throw createRequestError('活动受邀候选缺少显示名称', 'CEREMONY_INVITEE_OPTIONS_RESPONSE_INVALID')
}
if (typeof item.eligible !== 'boolean') {
throw createRequestError('活动受邀候选 eligible 无效', 'CEREMONY_INVITEE_OPTIONS_RESPONSE_INVALID')
}
return {
memberId,
appUserId,
displayName,
memberRole: normalizeCeremonyInvitationText(item.memberRole, 'memberRole'),
eligible: item.eligible,
disabledReason: normalizeCeremonyInvitationText(item.disabledReason, 'disabledReason')
}
})
}
+174
View File
@@ -0,0 +1,174 @@
import { listPreviewCeremonies } from '@/data/preview/records.js'
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import {
normalizeAppCeremonies,
normalizeAppCeremony,
normalizeAppCeremonyGift,
normalizeAppCeremonyGifts,
normalizeAppCeremonyWithTypeLabel,
normalizeCeremonyGiftPayload,
normalizeCeremonyInvitationResponsePayload,
normalizeCeremonyInvitationRows,
normalizeCeremonyInviteeOptions,
normalizeCeremonyInviteePayload,
normalizeCeremonyPayload,
projectPreviewCeremonies
} from './ceremony-contract.js'
import { normalizeGenealogyPathId } from './genealogy-contract.js'
import { normalizeResourcePathId } from './request-normalizers.js'
import { createRequestError, requestStrict } from './request-client.js'
const requireRemoteCeremony = (message, code) => {
if (hasRemoteConfig()) return
throw createRequestError(message, code)
}
export const ceremonyApi = {
async createCeremony(genealogyId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
requireRemoteCeremony('创建礼仪活动需要真实服务,当前本地预览不会伪造保存成功', 'REMOTE_WRITE_REQUIRED')
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies`,
method: 'POST',
data: normalizeCeremonyPayload(payload)
}, { requestController: requestOptions.requestController ?? null })
},
async updateCeremony(genealogyId, ceremonyId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedCeremonyId = normalizeResourcePathId(ceremonyId, '礼仪活动标识')
requireRemoteCeremony('修改礼仪活动需要真实服务,当前本地预览不会伪造保存成功', 'REMOTE_WRITE_REQUIRED')
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}`,
method: 'PUT',
data: normalizeCeremonyPayload(payload)
}, { requestController: requestOptions.requestController ?? null })
},
async deleteCeremony(genealogyId, ceremonyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedCeremonyId = normalizeResourcePathId(ceremonyId, '礼仪活动标识')
requireRemoteCeremony('删除礼仪活动需要真实服务,当前本地预览不会伪造删除成功', 'REMOTE_WRITE_REQUIRED')
await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}`,
method: 'DELETE'
}, { requireData: false, requestController: requestOptions.requestController ?? null })
return null
},
async createCeremonyGift(genealogyId, ceremonyId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedCeremonyId = normalizeResourcePathId(ceremonyId, '礼仪活动标识')
requireRemoteCeremony('新增礼仪献礼需要真实服务,当前本地预览不会伪造保存成功', 'REMOTE_WRITE_REQUIRED')
const gift = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}/gifts`,
method: 'POST',
data: normalizeCeremonyGiftPayload(payload)
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppCeremonyGift(gift, normalizedGenealogyId, normalizedCeremonyId)
},
async deleteCeremonyGift(genealogyId, ceremonyId, giftId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedCeremonyId = normalizeResourcePathId(ceremonyId, '礼仪活动标识')
const normalizedGiftId = normalizeResourcePathId(giftId, '献礼标识')
requireRemoteCeremony('删除礼仪献礼需要真实服务,当前本地预览不会伪造删除成功', 'REMOTE_WRITE_REQUIRED')
await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}/gifts/${normalizedGiftId}`,
method: 'DELETE'
}, { requireData: false, requestController: requestOptions.requestController ?? null })
return null
},
async getCeremonies(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
if (!hasRemoteConfig()) {
return projectPreviewCeremonies(listPreviewCeremonies(normalizedGenealogyId), normalizedGenealogyId)
}
const ceremonies = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies`,
method: 'GET'
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppCeremonies(ceremonies, normalizedGenealogyId)
},
async replaceCeremonyInvitees(genealogyId, ceremonyId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedCeremonyId = normalizeResourcePathId(ceremonyId, '礼仪活动标识')
const invitees = normalizeCeremonyInviteePayload(payload)
requireRemoteCeremony('活动受邀人写入需要真实服务,当前本地预览不会伪造结果', 'REMOTE_WRITE_REQUIRED')
const invitations = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}/invitees`,
method: 'PUT',
data: invitees
}, { requestController: requestOptions.requestController ?? null })
return normalizeCeremonyInvitationRows(invitations, normalizedGenealogyId)
},
async getCeremonyInviteeOptions(genealogyId, ceremonyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedCeremonyId = normalizeResourcePathId(ceremonyId, '礼仪活动标识')
requireRemoteCeremony('活动受邀候选读取需要真实服务,当前本地预览不会伪造结果', 'REMOTE_READ_REQUIRED')
const options = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}/invitee-options`,
method: 'GET'
}, { requestController: requestOptions.requestController ?? null })
return normalizeCeremonyInviteeOptions(options)
},
async getCeremonyInvitations(genealogyId, ceremonyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedCeremonyId = normalizeResourcePathId(ceremonyId, '礼仪活动标识')
requireRemoteCeremony('活动邀请名单读取需要真实服务,当前本地预览不会伪造结果', 'REMOTE_READ_REQUIRED')
const invitations = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}/invitations`,
method: 'GET'
}, { requestController: requestOptions.requestController ?? null })
return normalizeCeremonyInvitationRows(invitations, normalizedGenealogyId)
},
async respondToCeremonyInvitation(genealogyId, ceremonyId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedCeremonyId = normalizeResourcePathId(ceremonyId, '礼仪活动标识')
const response = normalizeCeremonyInvitationResponsePayload(payload)
requireRemoteCeremony('活动邀请响应写入需要真实服务,当前本地预览不会伪造结果', 'REMOTE_WRITE_REQUIRED')
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}/invitations/me`,
method: 'PUT',
data: response
}, { requestController: requestOptions.requestController ?? null })
},
async getMyCeremonyInvitations(requestOptions = {}) {
requireRemoteCeremony('我的活动邀请读取需要真实服务,当前本地预览不会伪造结果', 'REMOTE_READ_REQUIRED')
const invitations = await requestStrict({
url: '/genealogy/app/genealogies/ceremony-invitations/mine',
method: 'GET'
}, { requestController: requestOptions.requestController ?? null })
return normalizeCeremonyInvitationRows(invitations)
},
async getCeremonyDetail(genealogyId, ceremonyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedCeremonyId = normalizeResourcePathId(ceremonyId, '礼仪活动标识')
requireRemoteCeremony('礼仪详情需要真实读取服务,当前本地预览不会伪造详情', 'REMOTE_READ_REQUIRED')
const ceremony = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}`,
method: 'GET'
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppCeremonyWithTypeLabel(
normalizeAppCeremony(ceremony, normalizedGenealogyId, normalizedCeremonyId)
)
},
async getCeremonyGifts(genealogyId, ceremonyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedCeremonyId = normalizeResourcePathId(ceremonyId, '礼仪活动标识')
requireRemoteCeremony('礼仪献礼需要真实读取服务,当前本地预览不会伪造列表', 'REMOTE_READ_REQUIRED')
const gifts = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}/gifts`,
method: 'GET'
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppCeremonyGifts(gifts, normalizedGenealogyId, normalizedCeremonyId)
}
}
+129
View File
@@ -0,0 +1,129 @@
import { createRequestError } from './request-client.js'
import { normalizeOptionalResponseText } from './response-normalizers.js'
import {
assertPlainPayload,
normalizeOssIdString,
normalizeOptionalText,
normalizeResourcePathId
} from './request-normalizers.js'
export const EARNING_WITHDRAWAL_STATUS_LABELS = Object.freeze({
PENDING: '等待审核',
APPROVED: '审核通过',
PAYING: '正在转账',
PAID: '已到账',
REJECTED: '未通过',
FAILED: '转账失败',
CANCELLED: '已取消'
})
const earningWithdrawalStatuses = new Set(
Object.keys(EARNING_WITHDRAWAL_STATUS_LABELS)
)
const normalizeEarningText = (value, field) =>
normalizeOptionalResponseText(value, field, '收益响应', 'EARNING_RESPONSE_INVALID')
const normalizeMoneyText = (value, label, { nullable = false, allowNegative = false } = {}) => {
if (nullable && (value === undefined || value === null || value === '')) return null
if (typeof value !== 'string') throw createRequestError(`${label}必须是金额字符串`, 'EARNING_RESPONSE_INVALID')
const text = value.trim()
const pattern = allowNegative ? /^-?(?:0|[1-9]\d{0,16})(?:\.\d{1,2})?$/ : /^(?:0|[1-9]\d{0,16})(?:\.\d{1,2})?$/
if (!pattern.test(text)) throw createRequestError(`${label}格式无效`, 'EARNING_RESPONSE_INVALID')
return Number(text).toFixed(2)
}
export const normalizeEarningSummary = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('收益汇总响应无效', 'EARNING_RESPONSE_INVALID')
}
if (value.currency !== 'CNY' || typeof value.withdrawalEnabled !== 'boolean') {
throw createRequestError('收益汇总缺少稳定字段', 'EARNING_RESPONSE_INVALID')
}
const rewardRateBps = value.rewardRateBps === undefined || value.rewardRateBps === null
? null
: value.rewardRateBps
if (rewardRateBps !== null && (!Number.isSafeInteger(rewardRateBps) || rewardRateBps < 0 || rewardRateBps > 10000)) {
throw createRequestError('收益比例无效', 'EARNING_RESPONSE_INVALID')
}
return {
availableAmount: normalizeMoneyText(value.availableAmount, '可用收益'),
frozenAmount: normalizeMoneyText(value.frozenAmount, '冻结收益'),
minimumWithdrawal: normalizeMoneyText(value.minimumWithdrawal, '最低提现金额', { nullable: true }),
rewardRateBps,
currency: value.currency,
withdrawalEnabled: value.withdrawalEnabled
}
}
export const normalizeEarningLedger = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('收益明细响应无效', 'EARNING_RESPONSE_INVALID')
}
if (value.currency !== 'CNY') throw createRequestError('收益币种无效', 'EARNING_RESPONSE_INVALID')
return {
ledgerId: normalizeResourcePathId(value.ledgerId, '收益明细标识'),
entryType: normalizeEarningText(value.entryType, 'entryType'),
availableDelta: normalizeMoneyText(value.availableDelta, '可用收益变动', { allowNegative: true }),
frozenDelta: normalizeMoneyText(value.frozenDelta, '冻结收益变动', { allowNegative: true }),
availableAfter: normalizeMoneyText(value.availableAfter, '变动后可用收益'),
frozenAfter: normalizeMoneyText(value.frozenAfter, '变动后冻结收益'),
currency: value.currency,
remark: normalizeEarningText(value.remark, 'remark'),
createTime: normalizeEarningText(value.createTime, 'createTime')
}
}
export const normalizeEarningWithdrawal = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('提现记录响应无效', 'EARNING_RESPONSE_INVALID')
}
const withdrawalStatus = normalizeEarningText(value.withdrawalStatus, 'withdrawalStatus')
if (!earningWithdrawalStatuses.has(withdrawalStatus) || value.currency !== 'CNY') {
throw createRequestError('提现记录状态或币种无效', 'EARNING_RESPONSE_INVALID')
}
return {
withdrawalId: normalizeResourcePathId(value.withdrawalId, '提现记录标识'),
withdrawalNo: normalizeEarningText(value.withdrawalNo, 'withdrawalNo'),
amount: normalizeMoneyText(value.amount, '提现金额'),
currency: value.currency,
payoutAccountName: normalizeEarningText(value.payoutAccountName, 'payoutAccountName'),
withdrawalStatus,
auditRemark: normalizeEarningText(value.auditRemark, 'auditRemark'),
payoutReference: normalizeEarningText(value.payoutReference, 'payoutReference'),
paidAt: normalizeEarningText(value.paidAt, 'paidAt'),
failureReason: normalizeEarningText(value.failureReason, 'failureReason'),
createTime: normalizeEarningText(value.createTime, 'createTime')
}
}
export const normalizeEarningPage = (value, rowNormalizer, label) => {
if (!value || typeof value !== 'object' || Array.isArray(value) || !Array.isArray(value.rows) || !Number.isSafeInteger(value.total) || value.total < 0) {
throw createRequestError(`${label}分页响应无效`, 'EARNING_RESPONSE_INVALID')
}
return { rows: value.rows.map(rowNormalizer), total: value.total }
}
export const normalizeEarningPageQuery = ({ pageNum = 1, pageSize = 20 } = {}) => {
if (!Number.isSafeInteger(pageNum) || pageNum < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100) {
throw new TypeError('收益分页参数无效')
}
return { pageNum, pageSize }
}
export const normalizeEarningWithdrawalPayload = (payload) => {
assertPlainPayload(payload, new Set(['requestId', 'amount', 'payoutQrOssId', 'payoutAccountName']), '提现申请')
const requestId = normalizeOptionalText(payload.requestId, 'requestId')
const payoutAccountName = normalizeOptionalText(payload.payoutAccountName, 'payoutAccountName')
if (!requestId || requestId.length > 64) throw new TypeError('提现请求号无效')
if (!payoutAccountName || payoutAccountName.length > 64) throw new TypeError('请填写不超过64个字符的收款人姓名')
const amount = normalizeMoneyText(payload.amount, '提现金额')
if (Number(amount) <= 0) throw new TypeError('提现金额必须大于0元')
const payoutQrOssId = normalizeOssIdString(payload.payoutQrOssId, 'payoutQrOssId')
return {
requestId,
amount: Number(amount),
payoutQrOssId,
payoutAccountName
}
}
+85
View File
@@ -0,0 +1,85 @@
import {
normalizeEarningLedger,
normalizeEarningPage,
normalizeEarningPageQuery,
normalizeEarningSummary,
normalizeEarningWithdrawal,
normalizeEarningWithdrawalPayload
} from './earning-contract.js'
import { normalizeResourcePathId } from './request-normalizers.js'
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import {
createRequestError,
requestStrict
} from './request-client.js'
const requireRemoteEarning = (label, operation) => {
if (hasRemoteConfig()) return
throw createRequestError(
`${label}${operation}需要真实服务,当前本地预览不会伪造结果`,
operation === '读取' ? 'REMOTE_READ_REQUIRED' : 'REMOTE_WRITE_REQUIRED'
)
}
export const earningApi = {
async getEarningSummary(requestOptions = {}) {
requireRemoteEarning('收益汇总', '读取')
const earningSummary = await requestStrict({
url: '/genealogy/app/earnings/summary',
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeEarningSummary(earningSummary)
},
async getEarningLedgerPage(query = {}, requestOptions = {}) {
requireRemoteEarning('收益明细', '读取')
const ledgerPage = await requestStrict({
url: '/genealogy/app/earnings/ledger',
method: 'GET',
data: normalizeEarningPageQuery(query)
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return normalizeEarningPage(ledgerPage, normalizeEarningLedger, '收益明细')
},
async getEarningWithdrawalPage(query = {}, requestOptions = {}) {
requireRemoteEarning('提现记录', '读取')
const withdrawalPage = await requestStrict({
url: '/genealogy/app/earnings/withdrawals',
method: 'GET',
data: normalizeEarningPageQuery(query)
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return normalizeEarningPage(withdrawalPage, normalizeEarningWithdrawal, '提现记录')
},
async requestEarningWithdrawal(payload, requestOptions = {}) {
requireRemoteEarning('提现申请', '写入')
const withdrawal = await requestStrict({
url: '/genealogy/app/earnings/withdrawals',
method: 'POST',
data: normalizeEarningWithdrawalPayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeEarningWithdrawal(withdrawal)
},
async cancelEarningWithdrawal(withdrawalId, requestOptions = {}) {
const normalizedWithdrawalId = normalizeResourcePathId(withdrawalId, '提现记录标识')
requireRemoteEarning('取消提现', '写入')
const withdrawal = await requestStrict({
url: `/genealogy/app/earnings/withdrawals/${normalizedWithdrawalId}/cancel`,
method: 'POST'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeEarningWithdrawal(withdrawal)
}
}
+155
View File
@@ -0,0 +1,155 @@
import { createRequestError } from './request-client.js'
import {
normalizeNormalDisableResponseStatus,
normalizeOptionalNonnegativeInteger,
normalizeOptionalNumericId,
normalizeResponseText
} from './response-normalizers.js'
import {
assertPlainPayload,
normalizeOptionalNormalDisableStatus,
normalizeOptionalSafeInteger,
normalizeOptionalText,
normalizeOssIdString,
normalizeResourcePathId
} from './request-normalizers.js'
import {
normalizeBusinessFileAccess,
normalizeContentProtectionCapabilities,
normalizeResourceCapabilities
} from './business-file-contract.js'
export const normalizeArticleCategoryOptions = (value) => {
if (!Array.isArray(value)) {
throw createRequestError('谱文分类响应不是列表', 'ARTICLE_CATEGORY_RESPONSE_INVALID')
}
const categories = value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('谱文分类响应包含无效条目', 'ARTICLE_CATEGORY_RESPONSE_INVALID')
}
const id = normalizeOptionalNumericId(item.categoryId, '谱文分类标识', 'ARTICLE_CATEGORY_RESPONSE_INVALID')
const name = normalizeResponseText(item.categoryName, 'categoryName')
if (!id || !name || typeof item.enabled !== 'boolean') {
throw createRequestError('谱文分类响应缺少稳定字段', 'ARTICLE_CATEGORY_RESPONSE_INVALID')
}
return {
id,
name,
enabled: item.enabled,
sortOrder: normalizeOptionalNonnegativeInteger(item.sortOrder, '谱文分类排序值', 'ARTICLE_CATEGORY_RESPONSE_INVALID')
}
})
if (new Set(categories.map((item) => item.id)).size !== categories.length) {
throw createRequestError('谱文分类响应包含重复标识', 'ARTICLE_CATEGORY_RESPONSE_INVALID')
}
return categories
}
export const normalizePreviewFamilyArticles = (value, expectedGenealogyId) => {
if (!Array.isArray(value)) {
throw createRequestError('谱文响应不是列表', 'ARTICLE_RESPONSE_INVALID')
}
const rows = value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('谱文响应包含无效条目', 'ARTICLE_RESPONSE_INVALID')
}
const genealogyId = normalizeOptionalNumericId(item.genealogyId, '谱文家谱标识', 'ARTICLE_RESPONSE_INVALID')
if (genealogyId && genealogyId !== expectedGenealogyId) {
throw createRequestError('谱文归属与请求不匹配', 'ARTICLE_RESPONSE_INVALID')
}
const id = normalizeOptionalNumericId(item.id, '谱文标识', 'ARTICLE_RESPONSE_INVALID')
const title = normalizeResponseText(item.title, 'title')
if (!id || !title) {
throw createRequestError('谱文响应缺少标识或标题', 'ARTICLE_RESPONSE_INVALID')
}
return {
id,
genealogyId: expectedGenealogyId,
title,
summary: normalizeResponseText(item.summary, 'summary'),
content: '',
author: normalizeResponseText(item.author, 'author') || '家族成员',
time: normalizeResponseText(item.updatedAt, 'updatedAt'),
category: normalizeResponseText(item.category, 'category'),
coverOssId: normalizeOptionalNumericId(item.coverOssId, 'coverOssId', 'ARTICLE_RESPONSE_INVALID'),
viewCount: normalizeOptionalNonnegativeInteger(item.viewCount, '谱文阅读数', 'ARTICLE_RESPONSE_INVALID')
}
})
if (new Set(rows.map((item) => item.id)).size !== rows.length) {
throw createRequestError('谱文响应包含重复标识', 'ARTICLE_RESPONSE_INVALID')
}
return rows
}
export const normalizeAppArticle = (value, expectedGenealogyId, expectedArticleId = '') => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('谱文响应无效', 'ARTICLE_RESPONSE_INVALID')
}
const id = normalizeOptionalNumericId(value.articleId, '谱文标识', 'ARTICLE_RESPONSE_INVALID')
const genealogyId = normalizeOptionalNumericId(value.genealogyId, '谱文家谱标识', 'ARTICLE_RESPONSE_INVALID')
const title = normalizeResponseText(value.articleTitle, 'articleTitle')
if (!id || genealogyId !== expectedGenealogyId || !title || (expectedArticleId && id !== expectedArticleId)) {
throw createRequestError('谱文响应缺少稳定归属字段', 'ARTICLE_RESPONSE_INVALID')
}
return {
id,
genealogyId,
title,
summary: normalizeResponseText(value.articleSummary, 'articleSummary'),
content: normalizeResponseText(value.articleContent, 'articleContent'),
authorName: normalizeResponseText(value.authorName, 'authorName'),
author: normalizeResponseText(value.authorName, 'authorName') || '家族成员',
time: normalizeResponseText(value.publishTime, 'publishTime'),
categoryId: normalizeOptionalNumericId(value.categoryId, '谱文分类标识', 'ARTICLE_RESPONSE_INVALID'),
category: normalizeResponseText(value.categoryName, 'categoryName'),
coverFile: normalizeBusinessFileAccess(value.coverFile, '谱文封面', 'ARTICLE_RESPONSE_INVALID'),
viewCount: normalizeOptionalNonnegativeInteger(value.viewCount, '谱文阅读数', 'ARTICLE_RESPONSE_INVALID'),
sortOrder: normalizeOptionalNonnegativeInteger(value.sortOrder, '谱文排序值', 'ARTICLE_RESPONSE_INVALID'),
status: normalizeNormalDisableResponseStatus(value.status, '谱文状态', 'ARTICLE_RESPONSE_INVALID'),
...normalizeContentProtectionCapabilities(value, '谱文', 'ARTICLE_RESPONSE_INVALID'),
...normalizeResourceCapabilities(value, '谱文', 'ARTICLE_RESPONSE_INVALID')
}
}
export const normalizeAppArticles = (value, expectedGenealogyId) => {
if (!Array.isArray(value)) throw createRequestError('谱文响应不是列表', 'ARTICLE_RESPONSE_INVALID')
const articles = value.map((item) => normalizeAppArticle(item, expectedGenealogyId))
if (new Set(articles.map((item) => item.id)).size !== articles.length) {
throw createRequestError('谱文响应包含重复标识', 'ARTICLE_RESPONSE_INVALID')
}
return articles
}
export const normalizeArticleCreatePayload = (payload) => {
const allowedFields = new Set([
'categoryId',
'articleTitle',
'articleSummary',
'coverOssId',
'articleContent',
'authorName',
'sortOrder',
'status'
])
assertPlainPayload(payload, allowedFields, '谱文请求')
const articleTitle = typeof payload.articleTitle === 'string' ? payload.articleTitle.trim() : ''
const articleContent = typeof payload.articleContent === 'string' ? payload.articleContent.trim() : ''
if (!articleTitle) throw new TypeError('谱文标题不能为空')
if (!articleContent) throw new TypeError('谱文正文不能为空')
const normalizedPayload = { articleTitle, articleContent }
for (const field of ['articleSummary', 'authorName']) {
const normalizedText = normalizeOptionalText(payload[field], field)
if (normalizedText) normalizedPayload[field] = normalizedText
}
const status = normalizeOptionalNormalDisableStatus(payload.status, '谱文状态')
if (status) normalizedPayload.status = status
if (payload.categoryId !== undefined && payload.categoryId !== null && payload.categoryId !== '') {
normalizedPayload.categoryId = normalizeResourcePathId(payload.categoryId, '谱文分类标识')
}
const sortOrder = normalizeOptionalSafeInteger(payload.sortOrder, 'sortOrder')
if (sortOrder !== undefined) normalizedPayload.sortOrder = sortOrder
if (payload.coverOssId !== undefined && payload.coverOssId !== null && payload.coverOssId !== '') {
normalizedPayload.coverOssId = normalizeOssIdString(payload.coverOssId, 'coverOssId')
}
return normalizedPayload
}
+166
View File
@@ -0,0 +1,166 @@
import { listPreviewFamilyArticles } from '@/data/preview/family-content.js'
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import {
normalizeAppArticle,
normalizeAppArticles,
normalizeArticleCategoryOptions,
normalizeArticleCreatePayload,
normalizePreviewFamilyArticles
} from './family-article-contract.js'
import { normalizeGenealogyPathId } from './genealogy-contract.js'
import { normalizeResourcePathId } from './request-normalizers.js'
import {
contentAccessHeader,
normalizeContentAccessGrant,
normalizeContentPasswordPayload
} from './protected-content-contract.js'
import { createRequestError, requestStrict } from './request-client.js'
const requireRemoteArticle = (message, code) => {
if (hasRemoteConfig()) return
throw createRequestError(message, code)
}
const requestArticleContentProtection = async ({
genealogyId,
articleId,
method,
data,
requestOptions
}) => {
requireRemoteArticle('谱文内容保护需要真实服务,当前本地预览不会伪造结果', 'REMOTE_WRITE_REQUIRED')
return requestStrict({
url: `/genealogy/app/genealogies/${genealogyId}/articles/${articleId}/content-protection`,
method,
...(data === undefined ? {} : { data })
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
}
export const familyArticleApi = {
async createArticle(genealogyId, payload, requestOptions = {}) {
requireRemoteArticle('谱文创建需要真实服务,当前本地预览不会伪造创建成功', 'REMOTE_WRITE_REQUIRED')
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/articles`,
method: 'POST',
data: normalizeArticleCreatePayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
},
async getArticles(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
if (!hasRemoteConfig()) {
return normalizePreviewFamilyArticles(
listPreviewFamilyArticles(normalizedGenealogyId),
normalizedGenealogyId
)
}
const articles = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/articles`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeAppArticles(articles, normalizedGenealogyId)
},
async getArticleDetail(genealogyId, articleId, accessToken = '', requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedArticleId = normalizeResourcePathId(articleId, '谱文标识')
requireRemoteArticle('谱文详情需要真实读取服务,当前本地预览不会伪造详情', 'REMOTE_READ_REQUIRED')
const article = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/articles/${normalizedArticleId}`,
method: 'GET',
header: contentAccessHeader(accessToken)
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeAppArticle(article, normalizedGenealogyId, normalizedArticleId)
},
async updateArticle(genealogyId, articleId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedArticleId = normalizeResourcePathId(articleId, '谱文标识')
requireRemoteArticle('修改谱文需要真实服务,当前本地预览不会伪造保存成功', 'REMOTE_WRITE_REQUIRED')
const article = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/articles/${normalizedArticleId}`,
method: 'PUT',
data: normalizeArticleCreatePayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeAppArticle(article, normalizedGenealogyId, normalizedArticleId)
},
async deleteArticle(genealogyId, articleId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedArticleId = normalizeResourcePathId(articleId, '谱文标识')
requireRemoteArticle('删除谱文需要真实服务,当前本地预览不会伪造删除成功', 'REMOTE_WRITE_REQUIRED')
await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/articles/${normalizedArticleId}`,
method: 'DELETE'
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
},
async setArticlePassword(genealogyId, articleId, password, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedArticleId = normalizeResourcePathId(articleId, '谱文标识')
const passwordPayload = normalizeContentPasswordPayload(password)
await requestArticleContentProtection({
genealogyId: normalizedGenealogyId,
articleId: normalizedArticleId,
method: 'PUT',
data: passwordPayload,
requestOptions
})
return null
},
async unlockArticle(genealogyId, articleId, password, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedArticleId = normalizeResourcePathId(articleId, '谱文标识')
const passwordPayload = normalizeContentPasswordPayload(password)
requireRemoteArticle('谱文内容解锁需要真实服务,当前本地预览不会伪造结果', 'REMOTE_WRITE_REQUIRED')
const accessGrant = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/articles/${normalizedArticleId}/content-unlock`,
method: 'POST',
data: passwordPayload
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeContentAccessGrant(accessGrant)
},
async disableArticlePassword(genealogyId, articleId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedArticleId = normalizeResourcePathId(articleId, '谱文标识')
await requestArticleContentProtection({
genealogyId: normalizedGenealogyId,
articleId: normalizedArticleId,
method: 'DELETE',
requestOptions
})
return null
},
async getArticleCategories(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
requireRemoteArticle('谱文分类读取需要真实服务,当前本地预览不会伪造结果', 'REMOTE_READ_REQUIRED')
const categories = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/article-categories`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeArticleCategoryOptions(categories)
}
}
+252
View File
@@ -0,0 +1,252 @@
import { createRequestError } from './request-client.js'
import {
normalizeNormalDisableResponseStatus,
normalizeOptionalNonnegativeInteger,
normalizeOptionalNumericId,
normalizeResponseText
} from './response-normalizers.js'
import {
assertPlainPayload,
normalizeOptionalNormalDisableStatus,
normalizeOptionalOssIdList,
normalizeOptionalText
} from './request-normalizers.js'
import {
normalizeBusinessFileAccessRows,
normalizeResourceCapabilities
} from './business-file-contract.js'
export const normalizeFeedResourceId = (value, label) => {
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value)
throw createRequestError(`家族动态评论${label}无效`, 'FEED_COMMENT_RESPONSE_INVALID')
}
const normalizeFeedCommentText = (value, label, { required = false } = {}) => {
if (value === undefined || value === null) {
if (required) throw createRequestError(`家族动态评论缺少${label}`, 'FEED_COMMENT_RESPONSE_INVALID')
return ''
}
if (typeof value !== 'string') {
throw createRequestError(`家族动态评论${label}无效`, 'FEED_COMMENT_RESPONSE_INVALID')
}
const normalized = value.trim()
if (required && !normalized) {
throw createRequestError(`家族动态评论缺少${label}`, 'FEED_COMMENT_RESPONSE_INVALID')
}
return normalized
}
export const FEED_COMMENT_LEVEL = Object.freeze({
ROOT: 'root',
REPLY: 'reply'
})
const feedCommentLevels = new Set(Object.values(FEED_COMMENT_LEVEL))
const normalizeFeedParentCommentId = (value) => {
if (value === undefined || value === null || value === 0) return null
return normalizeFeedResourceId(value, '父评论标识')
}
export const normalizeFeedComments = (
value,
expectedGenealogyId,
expectedFeedId,
expectedLevel = ''
) => {
if (expectedLevel && !feedCommentLevels.has(expectedLevel)) {
throw new TypeError('预期评论层级无效')
}
if (!Array.isArray(value)) {
throw createRequestError('家族动态评论响应不是列表', 'FEED_COMMENT_RESPONSE_INVALID')
}
const comments = value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('家族动态评论包含无效条目', 'FEED_COMMENT_RESPONSE_INVALID')
}
const genealogyId = normalizeFeedResourceId(item.genealogyId, '家谱标识')
const feedId = normalizeFeedResourceId(item.feedId, '动态标识')
if (genealogyId !== expectedGenealogyId || feedId !== expectedFeedId) {
throw createRequestError('家族动态评论归属与请求不匹配', 'FEED_COMMENT_RESPONSE_INVALID')
}
const level = normalizeFeedCommentText(item.commentLevel, '评论层级', { required: true })
if (!feedCommentLevels.has(level) || (expectedLevel && level !== expectedLevel)) {
throw createRequestError('家族动态评论层级无效', 'FEED_COMMENT_RESPONSE_INVALID')
}
const parentCommentId = normalizeFeedParentCommentId(item.parentCommentId)
if (
(level === FEED_COMMENT_LEVEL.ROOT && parentCommentId !== null) ||
(level === FEED_COMMENT_LEVEL.REPLY && parentCommentId === null)
) {
throw createRequestError('家族动态评论父级与层级不匹配', 'FEED_COMMENT_RESPONSE_INVALID')
}
const deletedStatus = item.userDeleted === undefined || item.userDeleted === null
? '0'
: item.userDeleted
if (deletedStatus !== '0' && deletedStatus !== '1') {
throw createRequestError('家族动态评论删除状态无效', 'FEED_COMMENT_RESPONSE_INVALID')
}
const userDeleted = deletedStatus === '1'
if (item.canDelete !== undefined && typeof item.canDelete !== 'boolean') {
throw createRequestError('家族动态评论删除权限无效', 'FEED_COMMENT_RESPONSE_INVALID')
}
return {
id: normalizeFeedResourceId(item.commentId, '标识'),
author: normalizeFeedCommentText(item.appUserNickName, '用户昵称') || '未署名成员',
content: userDeleted
? '该评论已删除'
: normalizeFeedCommentText(item.commentContent, '评论内容', { required: true }),
time: normalizeFeedCommentText(item.createTime, '创建时间'),
parentCommentId,
parentAuthor: normalizeFeedCommentText(item.parentAppUserNickName, '被回复用户昵称'),
level,
status: normalizeNormalDisableResponseStatus(
item.status,
'评论状态',
'FEED_COMMENT_RESPONSE_INVALID'
),
userDeleted,
canDelete: item.canDelete === true,
replyCount: normalizeOptionalNonnegativeInteger(
item.replyCount,
'评论回复数',
'FEED_COMMENT_RESPONSE_INVALID'
)
}
})
if (new Set(comments.map((item) => item.id)).size !== comments.length) {
throw createRequestError('家族动态评论包含重复标识', 'FEED_COMMENT_RESPONSE_INVALID')
}
return comments
}
export const normalizeDirectFeedReplies = (value, expectedGenealogyId, expectedFeedId, expectedParentCommentId) => {
const replies = normalizeFeedComments(
value,
expectedGenealogyId,
expectedFeedId,
FEED_COMMENT_LEVEL.REPLY
)
if (replies.some((item) => item.parentCommentId !== expectedParentCommentId)) {
throw createRequestError('评论回复父级与请求不匹配', 'FEED_COMMENT_RESPONSE_INVALID')
}
return replies
}
export const normalizeFeedCommentPayload = (payload) => {
assertPlainPayload(payload, new Set(['parentCommentId', 'commentContent']), '家族动态评论请求')
const commentContent = normalizeFeedCommentText(payload.commentContent, '评论内容', { required: true })
if (Array.from(commentContent).length > 1000) throw new TypeError('家族动态评论不能超过 1000 个字符')
const normalizedPayload = { commentContent }
if (payload.parentCommentId !== undefined && payload.parentCommentId !== null) {
normalizedPayload.parentCommentId = normalizeFeedResourceId(payload.parentCommentId, '父评论标识')
}
return normalizedPayload
}
export const normalizeFeedCreatePayload = (payload) => {
const allowedFields = new Set(['feedType', 'feedContent', 'mediaOssIds', 'sortOrder', 'status'])
assertPlainPayload(payload, allowedFields, '家族动态请求')
if (typeof payload.feedContent !== 'string' || !payload.feedContent.trim()) {
throw new TypeError('家族动态内容不能为空')
}
const normalizedPayload = { feedContent: payload.feedContent.trim() }
const feedType = normalizeOptionalText(payload.feedType, 'feedType')
if (feedType) normalizedPayload.feedType = feedType
const status = normalizeOptionalNormalDisableStatus(payload.status, '动态状态')
if (status) normalizedPayload.status = status
const mediaOssIds = normalizeOptionalOssIdList(payload.mediaOssIds)
if (mediaOssIds) normalizedPayload.mediaOssIds = mediaOssIds
if (payload.sortOrder !== undefined && payload.sortOrder !== null && payload.sortOrder !== '') {
const sortOrder = typeof payload.sortOrder === 'number' ? payload.sortOrder : Number(payload.sortOrder)
if (!Number.isSafeInteger(sortOrder)) throw new TypeError('sortOrder 必须是安全整数')
normalizedPayload.sortOrder = sortOrder
}
return normalizedPayload
}
export const normalizeFamilyFeedRows = (value, expectedGenealogyId) => {
if (!Array.isArray(value)) {
throw createRequestError('家族动态响应不是列表', 'FEED_RESPONSE_INVALID')
}
const rows = value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('家族动态响应包含无效条目', 'FEED_RESPONSE_INVALID')
}
const genealogyId = normalizeOptionalNumericId(item.genealogyId, '动态家谱标识', 'FEED_RESPONSE_INVALID')
if (genealogyId && genealogyId !== expectedGenealogyId) {
throw createRequestError('家族动态归属与请求不匹配', 'FEED_RESPONSE_INVALID')
}
const id = normalizeOptionalNumericId(item.feedId, '动态标识', 'FEED_RESPONSE_INVALID')
const content = normalizeResponseText(item.feedContent, 'feedContent')
if (!id || !content) {
throw createRequestError('家族动态响应缺少标识或内容', 'FEED_RESPONSE_INVALID')
}
if (
item.likedByMe !== undefined &&
item.likedByMe !== null &&
typeof item.likedByMe !== 'boolean'
) {
throw createRequestError('动态点赞状态无效', 'FEED_RESPONSE_INVALID')
}
const pinned = normalizeResponseText(item.pinned, 'pinned')
if (pinned && pinned !== '0' && pinned !== '1') {
throw createRequestError('动态置顶状态无效', 'FEED_RESPONSE_INVALID')
}
return {
id,
genealogyId: expectedGenealogyId,
content,
publisher: normalizeResponseText(item.publisherNickName, 'publisherNickName') || '家族成员',
type: normalizeResponseText(item.feedType, 'feedType') || '文字动态',
time: normalizeResponseText(item.createTime, 'createTime'),
mediaFiles: normalizeBusinessFileAccessRows(item.mediaFiles, '动态媒体', 'FEED_RESPONSE_INVALID'),
likeCount: normalizeOptionalNonnegativeInteger(item.likeCount, '动态点赞数', 'FEED_RESPONSE_INVALID'),
commentCount: normalizeOptionalNonnegativeInteger(item.commentCount, '动态评论数', 'FEED_RESPONSE_INVALID'),
likedByMe: item.likedByMe ?? null,
pinned,
sortOrder: normalizeOptionalNonnegativeInteger(item.sortOrder, '动态排序值', 'FEED_RESPONSE_INVALID'),
status: normalizeNormalDisableResponseStatus(item.status, '动态状态', 'FEED_RESPONSE_INVALID'),
...normalizeResourceCapabilities(item, '家族动态', 'FEED_RESPONSE_INVALID')
}
})
if (new Set(rows.map((item) => item.id)).size !== rows.length) {
throw createRequestError('家族动态响应包含重复标识', 'FEED_RESPONSE_INVALID')
}
return rows
}
export const normalizeFamilyFeed = (value, expectedGenealogyId, expectedFeedId) => {
const [feed] = normalizeFamilyFeedRows([value], expectedGenealogyId)
if (feed.id !== expectedFeedId) {
throw createRequestError('动态详情响应标识不匹配', 'FEED_RESPONSE_INVALID')
}
return feed
}
export const normalizeFamilyFeedRecommendations = (value, expectedGenealogyId) => {
if (!Array.isArray(value)) {
throw createRequestError('动态推荐响应不是列表', 'FEED_RECOMMENDATION_RESPONSE_INVALID')
}
const reasonCodes = new Set([
'MANUAL_PRIORITY',
'FOLLOWED_INTERACTION',
'POPULAR_IN_GENEALOGY',
'RECENT_IN_GENEALOGY'
])
const feeds = value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item) || !reasonCodes.has(item.reasonCode)) {
throw createRequestError('动态推荐响应包含无效条目', 'FEED_RECOMMENDATION_RESPONSE_INVALID')
}
if (typeof item.reasonText !== 'string' || !item.reasonText.trim()) {
throw createRequestError('动态推荐响应缺少说明', 'FEED_RECOMMENDATION_RESPONSE_INVALID')
}
const [feed] = normalizeFamilyFeedRows([item.feed], expectedGenealogyId)
return { ...feed, recommendationReason: item.reasonText.trim() }
})
if (new Set(feeds.map((item) => item.id)).size !== feeds.length) {
throw createRequestError('动态推荐响应包含重复动态', 'FEED_RECOMMENDATION_RESPONSE_INVALID')
}
return feeds
}
+220
View File
@@ -0,0 +1,220 @@
import { listPreviewFamilyFeeds } from '@/data/preview/family-content.js'
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import {
FEED_COMMENT_LEVEL,
normalizeDirectFeedReplies,
normalizeFamilyFeed,
normalizeFamilyFeedRecommendations,
normalizeFamilyFeedRows,
normalizeFeedCommentPayload,
normalizeFeedComments,
normalizeFeedCreatePayload,
normalizeFeedResourceId
} from './family-feed-contract.js'
import { normalizeGenealogyPathId } from './genealogy-contract.js'
import { normalizeResourcePathId } from './request-normalizers.js'
import { createRequestError, requestStrict } from './request-client.js'
const requireRemoteFeed = (message, code) => {
if (hasRemoteConfig()) return
throw createRequestError(message, code)
}
const normalizePageQuery = (pageNum, pageSize, label) => {
if (!Number.isSafeInteger(pageNum) || pageNum < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1) {
throw new TypeError(`${label}分页参数无效`)
}
return { pageNum, pageSize }
}
const assertPageResponse = (page, label) => {
if (!page || typeof page !== 'object' || !Array.isArray(page.rows) || !Number.isSafeInteger(page.total)) {
throw createRequestError(`${label}分页响应无效`, 'PAGE_RESPONSE_INVALID')
}
}
export const familyFeedApi = {
async createFeedComment(genealogyId, feedId, payload, requestOptions = {}) {
requireRemoteFeed('动态评论需要真实服务,当前本地预览不会伪造提交成功', 'REMOTE_WRITE_REQUIRED')
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedFeedId = normalizeFeedResourceId(feedId, '动态标识')
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds/${normalizedFeedId}/comments`,
method: 'POST',
data: normalizeFeedCommentPayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
},
async getFeedRecommendations(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
if (!hasRemoteConfig()) {
return normalizeFamilyFeedRows(
listPreviewFamilyFeeds(normalizedGenealogyId),
normalizedGenealogyId
).map((feed) => ({ ...feed, recommendationReason: '家谱内最新动态' }))
}
const recommendations = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feed-recommendations`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeFamilyFeedRecommendations(recommendations, normalizedGenealogyId)
},
async getFeedDetail(genealogyId, feedId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedFeedId = normalizeFeedResourceId(feedId, '动态标识')
requireRemoteFeed('动态详情需要真实读取服务,当前本地预览不会伪造详情', 'REMOTE_READ_REQUIRED')
const feed = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds/${normalizedFeedId}`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeFamilyFeed(feed, normalizedGenealogyId, normalizedFeedId)
},
async updateFeed(genealogyId, feedId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedFeedId = normalizeFeedResourceId(feedId, '动态标识')
requireRemoteFeed('修改动态需要真实服务,当前本地预览不会伪造保存成功', 'REMOTE_WRITE_REQUIRED')
const feed = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds/${normalizedFeedId}`,
method: 'PUT',
data: normalizeFeedCreatePayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeFamilyFeed(feed, normalizedGenealogyId, normalizedFeedId)
},
async deleteFeed(genealogyId, feedId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedFeedId = normalizeFeedResourceId(feedId, '动态标识')
requireRemoteFeed('删除动态需要真实服务,当前本地预览不会伪造删除成功', 'REMOTE_WRITE_REQUIRED')
await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds/${normalizedFeedId}`,
method: 'DELETE'
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
},
async setFeedLike(genealogyId, feedId, liked, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedFeedId = normalizeFeedResourceId(feedId, '动态标识')
if (typeof liked !== 'boolean') throw new TypeError('动态点赞状态必须是布尔值')
requireRemoteFeed('动态点赞需要真实服务,当前本地预览不会伪造操作成功', 'REMOTE_WRITE_REQUIRED')
await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds/${normalizedFeedId}/likes`,
method: liked ? 'POST' : 'DELETE'
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
},
async createFeed(genealogyId, payload, requestOptions = {}) {
requireRemoteFeed('动态发布需要真实服务,当前本地预览不会伪造发布成功', 'REMOTE_WRITE_REQUIRED')
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const createdFeed = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds`,
method: 'POST',
data: normalizeFeedCreatePayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeFamilyFeedRows([createdFeed], normalizedGenealogyId)[0]
},
async getFeedPage(genealogyId, { pageNum = 1, pageSize = 20 } = {}, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const pageQuery = normalizePageQuery(pageNum, pageSize, '动态')
requireRemoteFeed('家族动态读取需要真实服务,当前本地预览不会伪造结果', 'REMOTE_READ_REQUIRED')
const feedPage = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds/page`,
method: 'GET',
data: pageQuery
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
assertPageResponse(feedPage, '动态')
return {
rows: normalizeFamilyFeedRows(feedPage.rows, normalizedGenealogyId),
total: feedPage.total
}
},
async getFeedCommentPage(genealogyId, feedId, { pageNum = 1, pageSize = 20 } = {}, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedFeedId = normalizeResourcePathId(feedId, '动态标识')
const pageQuery = normalizePageQuery(pageNum, pageSize, '评论')
requireRemoteFeed('动态评论读取需要真实服务,当前本地预览不会伪造结果', 'REMOTE_READ_REQUIRED')
const commentPage = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds/${normalizedFeedId}/comments/page`,
method: 'GET',
data: pageQuery
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
assertPageResponse(commentPage, '动态评论')
return {
rows: normalizeFeedComments(
commentPage.rows,
normalizedGenealogyId,
normalizedFeedId,
FEED_COMMENT_LEVEL.ROOT
),
total: commentPage.total
}
},
async getCommentReplyPage(genealogyId, feedId, commentId, { pageNum = 1, pageSize = 20 } = {}, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedFeedId = normalizeResourcePathId(feedId, '动态标识')
const normalizedCommentId = normalizeResourcePathId(commentId, '评论标识')
const pageQuery = normalizePageQuery(pageNum, pageSize, '回复')
requireRemoteFeed('评论回复读取需要真实服务,当前本地预览不会伪造结果', 'REMOTE_READ_REQUIRED')
const replyPage = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds/${normalizedFeedId}/comments/${normalizedCommentId}/replies/page`,
method: 'GET',
data: pageQuery
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
assertPageResponse(replyPage, '评论回复')
return {
rows: normalizeDirectFeedReplies(
replyPage.rows,
normalizedGenealogyId,
normalizedFeedId,
normalizedCommentId
),
total: replyPage.total
}
},
async deleteFeedComment(genealogyId, feedId, commentId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedFeedId = normalizeResourcePathId(feedId, '动态标识')
const normalizedCommentId = normalizeResourcePathId(commentId, '评论标识')
requireRemoteFeed('动态评论写入需要真实服务,当前本地预览不会伪造结果', 'REMOTE_WRITE_REQUIRED')
await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/feeds/${normalizedFeedId}/comments/${normalizedCommentId}`,
method: 'DELETE'
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
}
}
+191
View File
@@ -0,0 +1,191 @@
import { createRequestError } from './request-client.js'
import {
normalizeNormalDisableResponseStatus,
normalizeOptionalNonnegativeInteger,
normalizeOptionalNumericId,
normalizeResponseText
} from './response-normalizers.js'
import {
assertPlainPayload,
normalizeOptionalNormalDisableStatus,
normalizeOptionalSafeInteger,
normalizeOptionalText,
normalizeOssIdString
} from './request-normalizers.js'
import {
normalizeBusinessFileAccess,
normalizeResourceCapabilities
} from './business-file-contract.js'
export const normalizeAppVideo = (value, expectedGenealogyId, expectedVideoId = '') => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('家族视频响应无效', 'VIDEO_RESPONSE_INVALID')
}
const id = normalizeOptionalNumericId(value.videoId, '视频标识', 'VIDEO_RESPONSE_INVALID')
const genealogyId = normalizeOptionalNumericId(value.genealogyId, '视频家谱标识', 'VIDEO_RESPONSE_INVALID')
const title = normalizeResponseText(value.videoTitle, 'videoTitle')
if (!id || genealogyId !== expectedGenealogyId || !title || (expectedVideoId && id !== expectedVideoId)) {
throw createRequestError('家族视频响应缺少稳定字段', 'VIDEO_RESPONSE_INVALID')
}
if (typeof value.canEdit !== 'boolean' || typeof value.canDelete !== 'boolean') {
throw createRequestError('家族视频响应缺少权限字段', 'VIDEO_RESPONSE_INVALID')
}
return {
id,
genealogyId,
title,
description: normalizeResponseText(value.videoDesc, 'videoDesc'),
coverFile: normalizeBusinessFileAccess(value.coverFile, '视频封面', 'VIDEO_RESPONSE_INVALID'),
videoFile: normalizeBusinessFileAccess(value.videoFile, '视频文件', 'VIDEO_RESPONSE_INVALID', { required: true }),
durationSeconds: normalizeOptionalNonnegativeInteger(value.durationSeconds, '视频时长', 'VIDEO_RESPONSE_INVALID'),
publishTime: normalizeResponseText(value.publishTime, 'publishTime'),
viewCount: normalizeOptionalNonnegativeInteger(value.viewCount, '视频播放量', 'VIDEO_RESPONSE_INVALID'),
sortOrder: normalizeOptionalNonnegativeInteger(value.sortOrder, '视频排序值', 'VIDEO_RESPONSE_INVALID'),
status: normalizeNormalDisableResponseStatus(value.status, '视频状态', 'VIDEO_RESPONSE_INVALID'),
canEdit: value.canEdit,
canDelete: value.canDelete
}
}
export const normalizeAppVideos = (value, expectedGenealogyId) => {
if (!Array.isArray(value)) throw createRequestError('家族视频响应不是列表', 'VIDEO_RESPONSE_INVALID')
const videos = value.map((item) => normalizeAppVideo(item, expectedGenealogyId))
if (new Set(videos.map((item) => item.id)).size !== videos.length) {
throw createRequestError('家族视频响应包含重复标识', 'VIDEO_RESPONSE_INVALID')
}
return videos
}
export const normalizeAppAlbum = (value, expectedGenealogyId) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('相册响应无效', 'ALBUM_RESPONSE_INVALID')
}
const id = normalizeOptionalNumericId(value.albumId, '相册标识', 'ALBUM_RESPONSE_INVALID')
const genealogyId = normalizeOptionalNumericId(value.genealogyId, '相册家谱标识', 'ALBUM_RESPONSE_INVALID')
if (!id || genealogyId !== expectedGenealogyId) {
throw createRequestError('相册响应缺少稳定归属字段', 'ALBUM_RESPONSE_INVALID')
}
return {
id,
genealogyId,
name: normalizeResponseText(value.albumName, 'albumName') || '未命名相册',
description: normalizeResponseText(value.albumDesc, 'albumDesc'),
coverFile: normalizeBusinessFileAccess(value.coverFile, '相册封面', 'ALBUM_RESPONSE_INVALID'),
photoCount: normalizeOptionalNonnegativeInteger(value.photoCount, '相册照片数量', 'ALBUM_RESPONSE_INVALID'),
sortOrder: normalizeOptionalNonnegativeInteger(value.sortOrder, '相册排序值', 'ALBUM_RESPONSE_INVALID'),
status: normalizeNormalDisableResponseStatus(value.status, '相册状态', 'ALBUM_RESPONSE_INVALID'),
...normalizeResourceCapabilities(value, '相册', 'ALBUM_RESPONSE_INVALID')
}
}
export const normalizeAppAlbums = (value, expectedGenealogyId) => {
if (!Array.isArray(value)) throw createRequestError('相册响应不是列表', 'ALBUM_RESPONSE_INVALID')
const albums = value.map((item) => normalizeAppAlbum(item, expectedGenealogyId))
if (new Set(albums.map((item) => item.id)).size !== albums.length) {
throw createRequestError('相册响应包含重复标识', 'ALBUM_RESPONSE_INVALID')
}
return albums
}
export const projectPreviewAlbums = (value, expectedGenealogyId) =>
value.map((item) => ({
id: String(item.id),
genealogyId: expectedGenealogyId,
name: String(item.name || '未命名相册'),
description: String(item.description || ''),
coverFile: item.cover ? { accessUrl: String(item.cover) } : null,
photoCount: Number.isSafeInteger(item.photoCount) ? item.photoCount : 0,
canEdit: false,
canDelete: false
}))
const normalizeAppAlbumPhoto = (value, expectedGenealogyId, expectedAlbumId) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('相册照片响应无效', 'ALBUM_PHOTO_RESPONSE_INVALID')
}
const id = normalizeOptionalNumericId(value.photoId, '照片标识', 'ALBUM_PHOTO_RESPONSE_INVALID')
const genealogyId = normalizeOptionalNumericId(value.genealogyId, '照片家谱标识', 'ALBUM_PHOTO_RESPONSE_INVALID')
const albumId = normalizeOptionalNumericId(value.albumId, '照片相册标识', 'ALBUM_PHOTO_RESPONSE_INVALID')
if (!id || genealogyId !== expectedGenealogyId || albumId !== expectedAlbumId) {
throw createRequestError('相册照片响应缺少稳定归属字段', 'ALBUM_PHOTO_RESPONSE_INVALID')
}
return {
id,
genealogyId,
albumId,
title: normalizeResponseText(value.photoTitle, 'photoTitle') || '未命名照片',
description: normalizeResponseText(value.photoDesc, 'photoDesc'),
photographer: normalizeResponseText(value.photographer, 'photographer'),
shootTime: normalizeResponseText(value.shootTime, 'shootTime'),
photoFile: normalizeBusinessFileAccess(value.photoFile, '相册照片', 'ALBUM_PHOTO_RESPONSE_INVALID', { required: true }),
...normalizeResourceCapabilities(value, '相册照片', 'ALBUM_PHOTO_RESPONSE_INVALID')
}
}
export const normalizeAppAlbumPhotos = (value, expectedGenealogyId, expectedAlbumId) => {
if (!Array.isArray(value)) throw createRequestError('相册照片响应不是列表', 'ALBUM_PHOTO_RESPONSE_INVALID')
const photos = value.map((item) => normalizeAppAlbumPhoto(item, expectedGenealogyId, expectedAlbumId))
if (new Set(photos.map((item) => item.id)).size !== photos.length) {
throw createRequestError('相册照片响应包含重复标识', 'ALBUM_PHOTO_RESPONSE_INVALID')
}
return photos
}
export const normalizeAlbumCreatePayload = (payload) => {
const allowedFields = new Set(['albumName', 'albumDesc', 'coverOssId', 'sortOrder', 'status'])
assertPlainPayload(payload, allowedFields, '相册请求')
if (typeof payload.albumName !== 'string' || !payload.albumName.trim()) {
throw new TypeError('相册名称不能为空')
}
const normalizedPayload = { albumName: payload.albumName.trim() }
const albumDesc = normalizeOptionalText(payload.albumDesc, 'albumDesc')
if (albumDesc) normalizedPayload.albumDesc = albumDesc
const status = normalizeOptionalNormalDisableStatus(payload.status, '相册状态')
if (status) normalizedPayload.status = status
if (payload.coverOssId !== undefined && payload.coverOssId !== null && payload.coverOssId !== '') {
normalizedPayload.coverOssId = normalizeOssIdString(payload.coverOssId, 'coverOssId')
}
if (payload.sortOrder !== undefined && payload.sortOrder !== null && payload.sortOrder !== '') {
const sortOrder = typeof payload.sortOrder === 'number' ? payload.sortOrder : Number(payload.sortOrder)
if (!Number.isSafeInteger(sortOrder)) throw new TypeError('sortOrder 必须是安全整数')
normalizedPayload.sortOrder = sortOrder
}
return normalizedPayload
}
export const normalizeAlbumPhotoCreatePayload = (payload) => {
const allowedFields = new Set(['ossId', 'photoTitle', 'photoDesc', 'photographer', 'shootTime', 'sortOrder', 'status'])
assertPlainPayload(payload, allowedFields, '相册照片请求')
const ossId = normalizeOssIdString(payload.ossId, '相册照片 ossId')
const normalizedPayload = { ossId }
for (const field of ['photoTitle', 'photoDesc', 'photographer', 'shootTime']) {
const normalizedText = normalizeOptionalText(payload[field], field)
if (normalizedText) normalizedPayload[field] = normalizedText
}
const status = normalizeOptionalNormalDisableStatus(payload.status, '照片状态')
if (status) normalizedPayload.status = status
const sortOrder = normalizeOptionalSafeInteger(payload.sortOrder, 'sortOrder')
if (sortOrder !== undefined) normalizedPayload.sortOrder = sortOrder
return normalizedPayload
}
export const normalizeVideoPayload = (payload) => {
const allowedFields = new Set(['videoTitle', 'videoDesc', 'coverOssId', 'videoOssId', 'durationSeconds', 'sortOrder', 'status'])
assertPlainPayload(payload, allowedFields, '视频请求')
const videoTitle = normalizeOptionalText(payload.videoTitle, '视频标题')
if (!videoTitle) throw new TypeError('视频标题不能为空')
const videoOssId = normalizeOssIdString(payload.videoOssId, '视频文件 OSS ID')
const normalizedPayload = { videoTitle, videoOssId }
const videoDesc = normalizeOptionalText(payload.videoDesc, '视频说明')
if (videoDesc) normalizedPayload.videoDesc = videoDesc
if (payload.coverOssId !== undefined && payload.coverOssId !== null && payload.coverOssId !== '') {
normalizedPayload.coverOssId = normalizeOssIdString(payload.coverOssId, '视频封面 OSS ID')
}
for (const field of ['durationSeconds', 'sortOrder']) {
const normalizedInteger = normalizeOptionalSafeInteger(payload[field], field)
if (normalizedInteger !== undefined) normalizedPayload[field] = normalizedInteger
}
const status = normalizeOptionalNormalDisableStatus(payload.status, '视频状态')
if (status) normalizedPayload.status = status
return normalizedPayload
}
+187
View File
@@ -0,0 +1,187 @@
import { listPreviewFamilyAlbums } from '@/data/preview/family-content.js'
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import {
normalizeAlbumCreatePayload,
normalizeAlbumPhotoCreatePayload,
normalizeAppAlbum,
normalizeAppAlbumPhotos,
normalizeAppAlbums,
normalizeAppVideo,
normalizeAppVideos,
normalizeVideoPayload,
projectPreviewAlbums
} from './family-media-contract.js'
import { normalizeGenealogyPathId } from './genealogy-contract.js'
import { normalizeResourcePathId } from './request-normalizers.js'
import { createRequestError, requestStrict } from './request-client.js'
const requireRemoteMedia = (message, code) => {
if (hasRemoteConfig()) return
throw createRequestError(message, code)
}
export const familyMediaApi = {
async createAlbum(genealogyId, payload, requestOptions = {}) {
requireRemoteMedia('相册创建需要真实服务,当前本地预览不会伪造创建成功', 'REMOTE_WRITE_REQUIRED')
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/albums`,
method: 'POST',
data: normalizeAlbumCreatePayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
},
async updateAlbum(genealogyId, albumId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedAlbumId = normalizeResourcePathId(albumId, '相册标识')
requireRemoteMedia('修改相册需要真实服务,当前本地预览不会伪造保存成功', 'REMOTE_WRITE_REQUIRED')
const album = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/albums/${normalizedAlbumId}`,
method: 'PUT',
data: normalizeAlbumCreatePayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeAppAlbum(album, normalizedGenealogyId)
},
async deleteAlbum(genealogyId, albumId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedAlbumId = normalizeResourcePathId(albumId, '相册标识')
requireRemoteMedia('删除相册需要真实服务,当前本地预览不会伪造删除成功', 'REMOTE_WRITE_REQUIRED')
await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/albums/${normalizedAlbumId}`,
method: 'DELETE'
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
},
async createAlbumPhoto(genealogyId, albumId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedAlbumId = normalizeResourcePathId(albumId, '相册标识')
requireRemoteMedia('上传相册照片需要真实服务,当前本地预览不会伪造保存成功', 'REMOTE_WRITE_REQUIRED')
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/albums/${normalizedAlbumId}/photos`,
method: 'POST',
data: normalizeAlbumPhotoCreatePayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
},
async deleteAlbumPhoto(genealogyId, albumId, photoId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedAlbumId = normalizeResourcePathId(albumId, '相册标识')
const normalizedPhotoId = normalizeResourcePathId(photoId, '照片标识')
requireRemoteMedia('删除相册照片需要真实服务,当前本地预览不会伪造删除成功', 'REMOTE_WRITE_REQUIRED')
await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/albums/${normalizedAlbumId}/photos/${normalizedPhotoId}`,
method: 'DELETE'
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
},
async getAlbums(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
if (!hasRemoteConfig()) {
return projectPreviewAlbums(
listPreviewFamilyAlbums(normalizedGenealogyId),
normalizedGenealogyId
)
}
const albums = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/albums`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeAppAlbums(albums, normalizedGenealogyId)
},
async getAlbumPhotos(genealogyId, albumId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedAlbumId = normalizeResourcePathId(albumId, '相册标识')
requireRemoteMedia('相册照片需要真实读取服务,当前本地预览不会伪造照片列表', 'REMOTE_READ_REQUIRED')
const photos = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/albums/${normalizedAlbumId}/photos`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeAppAlbumPhotos(photos, normalizedGenealogyId, normalizedAlbumId)
},
async getVideos(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
requireRemoteMedia('家族视频读取需要真实服务,当前本地预览不会伪造结果', 'REMOTE_READ_REQUIRED')
const videos = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/videos`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeAppVideos(videos, normalizedGenealogyId)
},
async getVideoDetail(genealogyId, videoId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedVideoId = normalizeResourcePathId(videoId, '视频标识')
requireRemoteMedia('家族视频详情读取需要真实服务,当前本地预览不会伪造结果', 'REMOTE_READ_REQUIRED')
const video = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/videos/${normalizedVideoId}`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeAppVideo(video, normalizedGenealogyId, normalizedVideoId)
},
async createVideo(genealogyId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
requireRemoteMedia('家族视频写入需要真实服务,当前本地预览不会伪造结果', 'REMOTE_WRITE_REQUIRED')
const video = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/videos`,
method: 'POST',
data: normalizeVideoPayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeAppVideo(video, normalizedGenealogyId)
},
async updateVideo(genealogyId, videoId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedVideoId = normalizeResourcePathId(videoId, '视频标识')
requireRemoteMedia('家族视频写入需要真实服务,当前本地预览不会伪造结果', 'REMOTE_WRITE_REQUIRED')
const video = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/videos/${normalizedVideoId}`,
method: 'PUT',
data: normalizeVideoPayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeAppVideo(video, normalizedGenealogyId, normalizedVideoId)
},
async deleteVideo(genealogyId, videoId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedVideoId = normalizeResourcePathId(videoId, '视频标识')
requireRemoteMedia('家族视频写入需要真实服务,当前本地预览不会伪造结果', 'REMOTE_WRITE_REQUIRED')
await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/videos/${normalizedVideoId}`,
method: 'DELETE'
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
}
}
+32
View File
@@ -0,0 +1,32 @@
import { assertPlainPayload } from './request-normalizers.js'
export const FEEDBACK_TYPE_OPTIONS = Object.freeze([
Object.freeze({ value: 'bug', label: '功能问题' }),
Object.freeze({ value: 'advice', label: '使用建议' }),
Object.freeze({ value: 'complaint', label: '投诉反馈' }),
Object.freeze({ value: 'other', label: '其他' })
])
const feedbackTypes = new Set(FEEDBACK_TYPE_OPTIONS.map(({ value }) => value))
export const normalizeFeedbackPayload = (payload) => {
const allowedFields = new Set(['feedbackType', 'feedbackContent', 'contactInfo'])
assertPlainPayload(payload, allowedFields, '反馈请求')
if (typeof payload.feedbackContent !== 'string' || !payload.feedbackContent.trim()) {
throw new TypeError('反馈内容必须是非空字符串')
}
const normalizedPayload = { 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 normalizedText = payload[optionalField].trim()
if (!normalizedText) continue
if (optionalField === 'feedbackType' && !feedbackTypes.has(normalizedText)) {
throw new TypeError('feedbackType 必须为 advice、bug、complaint 或 other')
}
normalizedPayload[optionalField] = normalizedText
}
return normalizedPayload
}
+39
View File
@@ -0,0 +1,39 @@
import { hasRemoteConfig, resolveRuntimeMode } from '@/utils/runtime-config.js'
import { normalizeFeedbackPayload } from './feedback-contract.js'
import {
createRequestError,
requestStrict
} from './request-client.js'
export const feedbackApi = {
async submitFeedback(payload, requestOptions = {}) {
const feedback = normalizeFeedbackPayload(payload)
if (resolveRuntimeMode() !== 'remote') {
throw createRequestError('当前为本地预览模式,反馈未提交服务器', 'WRITE_UNAVAILABLE')
}
return requestStrict({
url: '/genealogy/app/feedback',
method: 'POST',
data: feedback
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
},
async getFeedback(requestOptions = {}) {
if (!hasRemoteConfig()) {
throw createRequestError('我的反馈读取需要真实服务,当前本地预览不会伪造结果', 'REMOTE_READ_REQUIRED')
}
const feedbackHistory = await requestStrict({
url: '/genealogy/app/feedback',
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
if (!Array.isArray(feedbackHistory)) {
throw createRequestError('我的反馈响应不是列表', 'LIST_RESPONSE_INVALID')
}
return feedbackHistory
}
}
+105
View File
@@ -0,0 +1,105 @@
import { createRequestError } from './request-client.js'
import { normalizeOptionalResponseText } from './response-normalizers.js'
import { assertPlainPayload, normalizeOptionalText } from './request-normalizers.js'
const normalizeFileResponseText = (value, field) =>
normalizeOptionalResponseText(value, field, '文件服务响应', 'FILE_RESPONSE_INVALID')
export const normalizeUploadId = (value, field) => {
if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${field}必须是非空字符串`)
return value.trim()
}
const normalizeUploadFileName = (value) => {
if (typeof value !== 'string' || !value.trim()) throw new TypeError('fileName必须是非空字符串')
return value.trim()
}
export const normalizeUploadMd5 = (value, field) => {
if (typeof value !== 'string' || !/^[a-f0-9]{32}$/i.test(value)) {
throw new TypeError(`${field}必须是 32 位十六进制 MD5`)
}
return value.toLowerCase()
}
const normalizeUploadSize = (value, field) => {
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${field}必须是正安全整数`)
return value
}
export const normalizeResumableInitPayload = (payload) => {
const allowedFields = new Set(['uploadId', 'fileName', 'fileMd5', 'totalSize', 'totalChunks', 'chunkSize', 'contentType'])
assertPlainPayload(payload, allowedFields, '文件初始化请求')
if (!Number.isInteger(payload.totalChunks) || payload.totalChunks <= 0 || payload.totalChunks > 2147483647) {
throw new TypeError('totalChunks必须是正 int32')
}
const normalizedPayload = {
uploadId: normalizeUploadId(payload.uploadId, 'uploadId'),
fileName: normalizeUploadFileName(payload.fileName),
fileMd5: normalizeUploadMd5(payload.fileMd5, 'fileMd5'),
totalSize: normalizeUploadSize(payload.totalSize, 'totalSize'),
totalChunks: payload.totalChunks,
chunkSize: normalizeUploadSize(payload.chunkSize, 'chunkSize')
}
const contentType = normalizeOptionalText(payload.contentType, 'contentType')
if (contentType) normalizedPayload.contentType = contentType
return normalizedPayload
}
export const normalizeResumableCompletePayload = (payload) => {
const allowedFields = new Set(['uploadId', 'fileName', 'fileMd5', 'totalSize', 'totalChunks'])
assertPlainPayload(payload, allowedFields, '文件完成请求')
if (!Number.isInteger(payload.totalChunks) || payload.totalChunks <= 0 || payload.totalChunks > 2147483647) {
throw new TypeError('totalChunks必须是正 int32')
}
return {
uploadId: normalizeUploadId(payload.uploadId, 'uploadId'),
fileName: normalizeUploadFileName(payload.fileName),
fileMd5: normalizeUploadMd5(payload.fileMd5, 'fileMd5'),
totalSize: normalizeUploadSize(payload.totalSize, 'totalSize'),
totalChunks: payload.totalChunks
}
}
const normalizeUploadOssId = (value, field) => {
const id = typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : value
if (typeof id !== 'string' || !/^[1-9]\d*$/.test(id)) {
throw createRequestError(`文件服务字段 ${field} 无效`, 'FILE_RESPONSE_INVALID')
}
return id
}
export const normalizeResumableInitResult = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw createRequestError('文件初始化响应格式无效', 'FILE_RESPONSE_INVALID')
}
if (typeof payload.instant !== 'boolean') {
throw createRequestError('文件初始化响应缺少 instant', 'FILE_RESPONSE_INVALID')
}
const ossId = payload.ossId === undefined || payload.ossId === null ? null : normalizeUploadOssId(payload.ossId, 'ossId')
if (payload.instant && !ossId) {
throw createRequestError('秒传响应缺少 ossId', 'FILE_RESPONSE_INVALID')
}
const uploadId = payload.instant && (payload.uploadId === undefined || payload.uploadId === null)
? null
: normalizeUploadId(payload.uploadId, '响应 uploadId')
return {
uploadId,
instant: payload.instant,
ossId,
url: normalizeFileResponseText(payload.url, 'url'),
fileName: normalizeFileResponseText(payload.fileName, 'fileName')
}
}
export const normalizeResumableCompleteResult = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw createRequestError('文件完成响应格式无效', 'FILE_RESPONSE_INVALID')
}
return {
ossId: normalizeUploadOssId(payload.ossId, 'ossId'),
url: normalizeFileResponseText(payload.url, 'url'),
thumbnailUrl: normalizeFileResponseText(payload.thumbnailUrl, 'thumbnailUrl'),
fileName: normalizeFileResponseText(payload.fileName, 'fileName')
}
}
+92
View File
@@ -0,0 +1,92 @@
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import {
normalizeResumableCompletePayload,
normalizeResumableCompleteResult,
normalizeResumableInitPayload,
normalizeResumableInitResult,
normalizeUploadId,
normalizeUploadMd5
} from './file-upload-contract.js'
import { assertPlainPayload } from './request-normalizers.js'
import {
createRequestError,
requestBrowserFileChunk,
requestNativeFileChunk,
requestStrict
} from './request-client.js'
const requireRemoteUpload = () => {
if (hasRemoteConfig()) return
throw createRequestError('文件上传需要真实服务,当前本地预览不伪造回执', 'REMOTE_WRITE_REQUIRED')
}
const normalizeChunkIndex = (value) => {
if (!Number.isInteger(value) || value < 0 || value > 2147483647) {
throw new TypeError('chunkIndex必须是非负 int32')
}
return value
}
export const fileUploadApi = {
async initializeResumableUpload(payload, requestOptions = {}) {
const uploadRequest = normalizeResumableInitPayload(payload)
requireRemoteUpload()
const uploadSession = await requestStrict({
url: '/genealogy/app/files/resumable/init',
method: 'POST',
data: uploadRequest
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeResumableInitResult(uploadSession)
},
async uploadResumableChunk(payload, requestOptions = {}) {
assertPlainPayload(
payload,
new Set(['uploadId', 'chunkIndex', 'chunkMd5', 'filePath']),
'文件分片请求'
)
if (typeof payload.filePath !== 'string' || !payload.filePath.trim()) {
throw new TypeError('filePath必须是非空字符串')
}
requireRemoteUpload()
return requestNativeFileChunk({
uploadId: normalizeUploadId(payload.uploadId, 'uploadId'),
chunkIndex: normalizeChunkIndex(payload.chunkIndex),
chunkMd5: normalizeUploadMd5(payload.chunkMd5, 'chunkMd5'),
filePath: payload.filePath.trim()
}, requestOptions)
},
async uploadBrowserResumableChunk(payload, file, requestOptions = {}) {
assertPlainPayload(
payload,
new Set(['uploadId', 'chunkIndex', 'chunkMd5']),
'浏览器文件分片请求'
)
if (!file || typeof file !== 'object') {
throw new TypeError('浏览器文件分片必须提供文件对象')
}
requireRemoteUpload()
return requestBrowserFileChunk({
uploadId: normalizeUploadId(payload.uploadId, 'uploadId'),
chunkIndex: normalizeChunkIndex(payload.chunkIndex),
chunkMd5: normalizeUploadMd5(payload.chunkMd5, 'chunkMd5'),
file
}, requestOptions)
},
async completeResumableUpload(payload, requestOptions = {}) {
const completionRequest = normalizeResumableCompletePayload(payload)
requireRemoteUpload()
const uploadedFile = await requestStrict({
url: '/genealogy/app/files/resumable/complete',
method: 'POST',
data: completionRequest
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeResumableCompleteResult(uploadedFile)
}
}
+414
View File
@@ -0,0 +1,414 @@
import {
fromApiGenealogyAccess,
isGenealogyJoinMode,
isGenealogyVisibility
} from '@/utils/genealogy/access-policy.js'
import { normalizeBusinessFileAccess } from './business-file-contract.js'
import { createRequestError } from './request-client.js'
import { assertPlainPayload, normalizeOssIdString } from './request-normalizers.js'
import {
normalizeOptionalNonnegativeInteger,
normalizeResponseText
} from './response-normalizers.js'
const normalizeGenealogyResponseText = (value, label) => normalizeResponseText(value, label, {
code: 'GENEALOGY_RESPONSE_INVALID',
subject: '我的家谱响应'
})
const normalizeGenealogySettingsResponseText = (value, label) => normalizeResponseText(value, label, {
code: 'GENEALOGY_SETTINGS_RESPONSE_INVALID',
subject: '家谱设置响应'
})
export const GENEALOGY_LIFECYCLE_STATUS = Object.freeze({
NORMAL: 'normal',
ARCHIVED: 'archived',
DELETE_PENDING: 'delete_pending'
})
const genealogyLifecycleStatuses = new Set(
Object.values(GENEALOGY_LIFECYCLE_STATUS)
)
const normalizeGenealogyLifecycleStatus = (value, code) => {
const status = normalizeResponseText(value, 'lifecycleStatus', {
code,
subject: '家谱响应'
})
if (!genealogyLifecycleStatuses.has(status)) {
throw createRequestError('家谱生命周期状态无效', code)
}
return status
}
const normalizeGenealogyAccessValue = (value, field, isValid, code) => {
const normalized = normalizeResponseText(value, field, {
code,
subject: '家谱响应'
})
if (normalized && !isValid(normalized)) {
throw createRequestError(`家谱字段 ${field} 无效`, code)
}
return normalized
}
const normalizeGenealogyCapabilities = (value, code) => {
const capabilities = {}
for (const field of ['canManage', 'canEditContent', 'canArchive', 'canRestore']) {
if (typeof value[field] !== 'boolean') {
throw createRequestError(`家谱权限字段 ${field} 无效`, code)
}
capabilities[field] = value[field]
}
return capabilities
}
const normalizeMyGenealogyId = (value) => {
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value)
throw createRequestError('我的家谱响应包含无效标识', 'GENEALOGY_RESPONSE_INVALID')
}
export const normalizeGenealogyPathId = (value) => {
const id = typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : value
if (typeof id !== 'string' || !/^[1-9]\d*$/.test(id)) {
throw createRequestError('家谱标识无效', 'GENEALOGY_ID_INVALID')
}
return id
}
export const normalizeCreatedGenealogy = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('创建家谱响应无效', 'GENEALOGY_RESPONSE_INVALID')
}
const id = value.genealogyId
if (typeof id === 'string' && /^[1-9]\d*$/.test(id)) return { id }
if (typeof id === 'number' && Number.isSafeInteger(id) && id > 0) return { id: String(id) }
throw createRequestError('创建家谱响应缺少有效标识', 'GENEALOGY_RESPONSE_INVALID')
}
export const normalizeGenealogySettings = (value, expectedGenealogyId) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('家谱设置响应无效', 'GENEALOGY_SETTINGS_RESPONSE_INVALID')
}
const id = normalizeMyGenealogyId(value.genealogyId)
if (id !== expectedGenealogyId) {
throw createRequestError('家谱设置响应标识不匹配', 'GENEALOGY_SETTINGS_RESPONSE_INVALID')
}
const genealogyName = normalizeGenealogySettingsResponseText(value.genealogyName, 'genealogyName')
const surname = normalizeGenealogySettingsResponseText(value.surname, 'surname')
const regionCode = normalizeGenealogySettingsResponseText(value.regionCode, 'regionCode')
if (!genealogyName || !surname || !regionCode) {
throw createRequestError('家谱设置响应缺少名称、姓氏或地区', 'GENEALOGY_SETTINGS_RESPONSE_INVALID')
}
const visibility = normalizeGenealogyAccessValue(
value.visibility,
'visibility',
isGenealogyVisibility,
'GENEALOGY_SETTINGS_RESPONSE_INVALID'
)
const joinMode = normalizeGenealogyAccessValue(
value.joinMode,
'joinMode',
isGenealogyJoinMode,
'GENEALOGY_SETTINGS_RESPONSE_INVALID'
)
const capabilities = normalizeGenealogyCapabilities(
value,
'GENEALOGY_SETTINGS_RESPONSE_INVALID'
)
return {
id,
genealogyName,
surname,
ancestralHall: normalizeGenealogySettingsResponseText(value.ancestralHall, 'ancestralHall'),
originPlace: normalizeGenealogySettingsResponseText(value.originPlace, 'originPlace'),
regionCode,
regionName: normalizeGenealogySettingsResponseText(value.regionName, 'regionName'),
regionFullName: normalizeGenealogySettingsResponseText(value.regionFullName, 'regionFullName'),
addressDetail: normalizeGenealogySettingsResponseText(value.addressDetail, 'addressDetail'),
coverFile: normalizeBusinessFileAccess(
value.coverFile,
'家谱封面',
'GENEALOGY_SETTINGS_RESPONSE_INVALID'
),
intro: normalizeGenealogySettingsResponseText(value.intro, 'intro'),
accessPreset: fromApiGenealogyAccess({ visibility, joinMode }),
visibility,
joinMode,
memberCount: normalizeOptionalNonnegativeInteger(value.memberCount, '家谱成员数量', 'GENEALOGY_SETTINGS_RESPONSE_INVALID'),
personCount: normalizeOptionalNonnegativeInteger(value.personCount, '家谱人物数量', 'GENEALOGY_SETTINGS_RESPONSE_INVALID'),
...capabilities,
lifecycleStatus: normalizeGenealogyLifecycleStatus(
value.lifecycleStatus,
'GENEALOGY_SETTINGS_RESPONSE_INVALID'
),
archivedAt: normalizeGenealogySettingsResponseText(value.archivedAt, 'archivedAt')
}
}
export const normalizeAppGenealogy = (item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('我的家谱响应包含无效条目', 'GENEALOGY_RESPONSE_INVALID')
}
const name = normalizeGenealogyResponseText(item.genealogyName, 'genealogyName')
if (!name) {
throw createRequestError('我的家谱响应缺少家谱名称', 'GENEALOGY_RESPONSE_INVALID')
}
const capabilities = normalizeGenealogyCapabilities(item, 'GENEALOGY_RESPONSE_INVALID')
if (!Number.isSafeInteger(item.memberCount) || item.memberCount < 0) {
throw createRequestError('我的家谱响应成员数量无效', 'GENEALOGY_RESPONSE_INVALID')
}
const personCount = item.personCount ?? null
if (personCount !== null && (!Number.isSafeInteger(personCount) || personCount < 0)) {
throw createRequestError('我的家谱响应世系人数无效', 'GENEALOGY_RESPONSE_INVALID')
}
const visibility = normalizeGenealogyAccessValue(
item.visibility,
'visibility',
isGenealogyVisibility,
'GENEALOGY_RESPONSE_INVALID'
)
const joinMode = normalizeGenealogyAccessValue(
item.joinMode,
'joinMode',
isGenealogyJoinMode,
'GENEALOGY_RESPONSE_INVALID'
)
return {
id: normalizeMyGenealogyId(item.genealogyId),
name,
surname: normalizeGenealogyResponseText(item.surname, 'surname'),
hall: normalizeGenealogyResponseText(item.ancestralHall, 'ancestralHall'),
location:
normalizeGenealogyResponseText(item.regionFullName, 'regionFullName') ||
normalizeGenealogyResponseText(item.regionName, 'regionName') ||
normalizeGenealogyResponseText(item.originPlace, 'originPlace') ||
normalizeGenealogyResponseText(item.addressDetail, 'addressDetail') ||
'地区待补',
memberCount: item.memberCount,
personCount,
accessPreset: fromApiGenealogyAccess({ visibility, joinMode }),
...capabilities,
lifecycleStatus: normalizeGenealogyLifecycleStatus(
item.lifecycleStatus,
'GENEALOGY_RESPONSE_INVALID'
),
archivedAt: normalizeGenealogyResponseText(item.archivedAt, 'archivedAt'),
intro: normalizeGenealogyResponseText(item.intro, 'intro'),
joinTime: normalizeGenealogyResponseText(item.joinTime, 'joinTime')
}
}
export const normalizeMyGenealogies = (value) => {
if (!Array.isArray(value)) {
throw createRequestError('我的家谱响应不是列表', 'GENEALOGY_RESPONSE_INVALID')
}
const normalized = value.map((item) => {
const genealogy = normalizeAppGenealogy(item)
return {
id: genealogy.id,
name: genealogy.name,
surname: genealogy.surname,
hall: genealogy.hall,
location: genealogy.location,
memberCount: genealogy.memberCount,
canManage: genealogy.canManage,
canEditContent: genealogy.canEditContent,
lifecycleStatus: genealogy.lifecycleStatus,
archivedAt: genealogy.archivedAt,
canArchive: genealogy.canArchive,
canRestore: genealogy.canRestore
}
})
if (new Set(normalized.map((genealogy) => genealogy.id)).size !== normalized.length) {
throw createRequestError('我的家谱响应包含重复标识', 'GENEALOGY_RESPONSE_INVALID')
}
return normalized
}
export const normalizeGenealogyOrderIds = (value) => {
if (!Array.isArray(value)) {
throw createRequestError('家谱排序必须是列表', 'GENEALOGY_ORDER_INVALID')
}
const ids = value.map((item) => normalizeMyGenealogyId(item))
if (new Set(ids).size !== ids.length) {
throw createRequestError('家谱排序不能包含重复家谱', 'GENEALOGY_ORDER_INVALID')
}
return ids
}
export const normalizePublicGenealogies = (value) => {
if (!Array.isArray(value)) {
throw createRequestError('公开家谱响应不是列表', 'PUBLIC_GENEALOGY_RESPONSE_INVALID')
}
const genealogies = value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('公开家谱响应包含无效条目', 'PUBLIC_GENEALOGY_RESPONSE_INVALID')
}
const id = normalizeMyGenealogyId(item.genealogyId)
const name = normalizeGenealogyResponseText(item.genealogyName, 'genealogyName')
if (!name) {
throw createRequestError('公开家谱响应缺少家谱名称', 'PUBLIC_GENEALOGY_RESPONSE_INVALID')
}
const memberCount = normalizeOptionalNonnegativeInteger(
item.memberCount,
'公开家谱成员数量',
'PUBLIC_GENEALOGY_RESPONSE_INVALID'
)
if (item.canManage !== undefined && typeof item.canManage !== 'boolean') {
throw createRequestError('公开家谱管理权限无效', 'PUBLIC_GENEALOGY_RESPONSE_INVALID')
}
return {
id,
name,
surname: normalizeGenealogyResponseText(item.surname, 'surname'),
location:
normalizeGenealogyResponseText(item.regionFullName, 'regionFullName') ||
normalizeGenealogyResponseText(item.regionName, 'regionName') ||
normalizeGenealogyResponseText(item.originPlace, 'originPlace') ||
normalizeGenealogyResponseText(item.addressDetail, 'addressDetail'),
intro: normalizeGenealogyResponseText(item.intro, 'intro'),
memberCount,
canManage: item.canManage === true
}
})
if (new Set(genealogies.map((genealogy) => genealogy.id)).size !== genealogies.length) {
throw createRequestError('公开家谱响应包含重复标识', 'PUBLIC_GENEALOGY_RESPONSE_INVALID')
}
return genealogies
}
export const projectPreviewPublicGenealogies = (value) =>
value.map((item) => ({
id: String(item.id),
name: String(item.name || '未命名家谱'),
surname: String(item.surname || ''),
location: String(item.location || ''),
intro: String(item.publicDescription || ''),
memberCount: Number.isSafeInteger(item.memberCount) ? item.memberCount : 0,
canManage: false
}))
const normalizeGenealogyWriteText = (value, field, { required = false } = {}) => {
if (value === undefined || value === null) {
if (required) throw new TypeError(`创建家谱缺少 ${field}`)
return ''
}
if (typeof value !== 'string') throw new TypeError(`创建家谱字段 ${field} 必须是字符串`)
const normalized = value.trim()
if (required && !normalized) throw new TypeError(`创建家谱缺少 ${field}`)
return normalized
}
const GENEALOGY_WRITE_FIELDS = new Set([
'genealogyName',
'surname',
'regionCode',
'ancestralHall',
'originPlace',
'addressDetail',
'intro',
'coverOssId',
'visibility',
'joinMode'
])
const assertGenealogyWritePayload = (payload, operation) => {
assertPlainPayload(payload, GENEALOGY_WRITE_FIELDS, `${operation}家谱请求`)
}
export const normalizeGenealogyCreatePayload = (payload) => {
assertGenealogyWritePayload(payload, '创建')
const normalizedPayload = {
genealogyName: normalizeGenealogyWriteText(payload.genealogyName, '谱名', { required: true }),
surname: normalizeGenealogyWriteText(payload.surname, '姓氏', { required: true }),
regionCode: normalizeGenealogyWriteText(payload.regionCode, '地区代码', { required: true })
}
for (const field of ['ancestralHall', 'originPlace', 'addressDetail', 'intro']) {
const normalizedText = normalizeGenealogyWriteText(payload[field], field)
if (normalizedText) normalizedPayload[field] = normalizedText
}
const visibility = normalizeGenealogyWriteText(payload.visibility, 'visibility')
if (visibility) {
if (!isGenealogyVisibility(visibility)) throw new TypeError('创建家谱字段 visibility 无效')
normalizedPayload.visibility = visibility
}
const joinMode = normalizeGenealogyWriteText(payload.joinMode, 'joinMode')
if (joinMode) {
if (!isGenealogyJoinMode(joinMode)) throw new TypeError('创建家谱字段 joinMode 无效')
normalizedPayload.joinMode = joinMode
}
if (payload.coverOssId !== undefined && payload.coverOssId !== null && payload.coverOssId !== '') {
normalizedPayload.coverOssId = normalizeOssIdString(payload.coverOssId, '创建家谱字段 coverOssId')
}
return normalizedPayload
}
export const normalizeGenealogyUpdatePayload = (payload) => {
assertGenealogyWritePayload(payload, '更新')
const normalizedPayload = {}
for (const field of [
'genealogyName',
'surname',
'regionCode',
'ancestralHall',
'originPlace',
'addressDetail',
'intro'
]) {
if (!Object.prototype.hasOwnProperty.call(payload, field)) continue
const normalizedText = normalizeGenealogyWriteText(payload[field], field)
if (normalizedText) normalizedPayload[field] = normalizedText
}
if (Object.prototype.hasOwnProperty.call(payload, 'visibility')) {
const visibility = normalizeGenealogyWriteText(payload.visibility, 'visibility')
if (!isGenealogyVisibility(visibility)) throw new TypeError('更新家谱字段 visibility 无效')
normalizedPayload.visibility = visibility
}
if (Object.prototype.hasOwnProperty.call(payload, 'joinMode')) {
const joinMode = normalizeGenealogyWriteText(payload.joinMode, 'joinMode')
if (!isGenealogyJoinMode(joinMode)) throw new TypeError('更新家谱字段 joinMode 无效')
normalizedPayload.joinMode = joinMode
}
if (Object.prototype.hasOwnProperty.call(payload, 'coverOssId') && payload.coverOssId !== null && payload.coverOssId !== '') {
normalizedPayload.coverOssId = normalizeOssIdString(payload.coverOssId, '更新家谱字段 coverOssId')
}
if (!Object.keys(normalizedPayload).length) throw new TypeError('请至少填写一项需要更新的家谱信息')
return normalizedPayload
}
export const normalizeGenealogyQuota = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('家谱配额响应无效', 'GENEALOGY_QUOTA_RESPONSE_INVALID')
}
const normalizeUsage = (field) => {
if (!Number.isSafeInteger(value[field]) || value[field] < 0) {
throw createRequestError(`家谱配额字段 ${field} 无效`, 'GENEALOGY_QUOTA_RESPONSE_INVALID')
}
return value[field]
}
const normalizeLimit = (field) => {
if (!Number.isSafeInteger(value[field]) || value[field] < -1) {
throw createRequestError(`家谱配额字段 ${field} 无效`, 'GENEALOGY_QUOTA_RESPONSE_INVALID')
}
return value[field]
}
const normalizePermission = (field) => {
if (typeof value[field] !== 'boolean') {
throw createRequestError(`家谱配额字段 ${field} 无效`, 'GENEALOGY_QUOTA_RESPONSE_INVALID')
}
return value[field]
}
return {
createUsed: normalizeUsage('createUsed'),
createLimit: normalizeLimit('createLimit'),
createRemaining: normalizeLimit('createRemaining'),
canCreate: normalizePermission('canCreate'),
joinUsed: normalizeUsage('joinUsed'),
joinLimit: normalizeLimit('joinLimit'),
joinRemaining: normalizeLimit('joinRemaining'),
canJoin: normalizePermission('canJoin')
}
}
+164
View File
@@ -0,0 +1,164 @@
import { createRequestError } from './request-client.js'
import {
normalizeOptionalNumericId,
normalizeOptionalResponseText
} from './response-normalizers.js'
import {
assertPlainPayload,
normalizeOptionalText,
normalizeResourcePathId
} from './request-normalizers.js'
import { normalizeLineagePersonIdentity } from './lineage-person-contract.js'
export const GENEALOGY_MEMBER_ROLE = Object.freeze({
OWNER: 'owner',
ADMIN: 'admin',
EDITOR: 'editor',
MEMBER: 'member',
VISITOR: 'visitor'
})
export const GENEALOGY_MEMBER_ROLE_LABELS = Object.freeze({
[GENEALOGY_MEMBER_ROLE.OWNER]: '谱主',
[GENEALOGY_MEMBER_ROLE.ADMIN]: '管理员',
[GENEALOGY_MEMBER_ROLE.EDITOR]: '可编辑成员',
[GENEALOGY_MEMBER_ROLE.MEMBER]: '普通成员',
[GENEALOGY_MEMBER_ROLE.VISITOR]: '访客'
})
const genealogyMemberRoles = new Set(Object.values(GENEALOGY_MEMBER_ROLE))
const editableGenealogyMemberRoles = new Set([
GENEALOGY_MEMBER_ROLE.ADMIN,
GENEALOGY_MEMBER_ROLE.EDITOR,
GENEALOGY_MEMBER_ROLE.MEMBER
])
const normalizeMemberOptionText = (value, field) =>
normalizeOptionalResponseText(value, field, '成员候选', 'MEMBER_OPTIONS_RESPONSE_INVALID')
const normalizeMemberText = (value, field) =>
normalizeOptionalResponseText(value, field, '家谱成员', 'MEMBER_LIST_RESPONSE_INVALID')
export const normalizeMemberUpdatePayload = (payload) => {
assertPlainPayload(payload, new Set(['memberName', 'relationName', 'roleType', 'lineagePersonId']), '成员更新请求')
const normalizedPayload = {}
const memberName = normalizeOptionalText(payload.memberName, 'memberName')
if (memberName && memberName.length > 50) throw new TypeError('memberName 超出长度限制')
if (memberName) normalizedPayload.memberName = memberName
if (Object.prototype.hasOwnProperty.call(payload, 'relationName')) {
if (typeof payload.relationName !== 'string') throw new TypeError('relationName必须是字符串')
const relationName = payload.relationName.trim()
if (relationName.length > 100) throw new TypeError('relationName 超出长度限制')
normalizedPayload.relationName = relationName
}
const roleType = normalizeOptionalText(payload.roleType, 'roleType')
if (roleType) {
if (!editableGenealogyMemberRoles.has(roleType)) {
throw new TypeError('roleType 仅允许 admin、editor 或 member')
}
normalizedPayload.roleType = roleType
}
if (payload.lineagePersonId !== undefined && payload.lineagePersonId !== null && payload.lineagePersonId !== '') {
normalizedPayload.lineagePersonId = normalizeResourcePathId(payload.lineagePersonId, '世系人物标识')
}
if (!Object.keys(normalizedPayload).length) throw new TypeError('请至少填写一项要更新的成员信息')
return normalizedPayload
}
export const normalizeGenealogyMemberOptions = (value) => {
if (!Array.isArray(value)) {
throw createRequestError('成员候选响应不是数组', 'MEMBER_OPTIONS_RESPONSE_INVALID')
}
const seenUserIds = new Set()
return value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('成员候选包含无效条目', 'MEMBER_OPTIONS_RESPONSE_INVALID')
}
const memberId = normalizeResourcePathId(item.memberId, '成员候选标识')
const appUserId = normalizeLineagePersonIdentity(item.appUserId, '绑定用户标识')
if (seenUserIds.has(appUserId)) {
throw createRequestError('成员候选包含重复业务用户', 'MEMBER_OPTIONS_RESPONSE_INVALID')
}
seenUserIds.add(appUserId)
const memberName = normalizeMemberOptionText(item.memberName, 'memberName')
if (!memberName) {
throw createRequestError('成员候选缺少显示名称', 'MEMBER_OPTIONS_RESPONSE_INVALID')
}
if (typeof item.eligible !== 'boolean') {
throw createRequestError('成员候选 eligible 无效', 'MEMBER_OPTIONS_RESPONSE_INVALID')
}
return {
memberId,
appUserId,
memberName,
relationName: normalizeMemberOptionText(item.relationName, 'relationName'),
roleType: normalizeMemberOptionText(item.roleType, 'roleType'),
eligible: item.eligible,
disabledReason: normalizeMemberOptionText(item.disabledReason, 'disabledReason')
}
})
}
const normalizeGenealogyMemberCapabilities = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('成员操作权限无效', 'MEMBER_LIST_RESPONSE_INVALID')
}
const capabilities = {}
for (const field of ['canEdit', 'canRemove', 'canLeave', 'canTransferOwner']) {
if (typeof value[field] !== 'boolean') {
throw createRequestError(`成员操作权限字段 ${field} 无效`, 'MEMBER_LIST_RESPONSE_INVALID')
}
capabilities[field] = value[field]
}
return capabilities
}
export const normalizeGenealogyMembers = (value, expectedGenealogyId) => {
if (!Array.isArray(value)) {
throw createRequestError('家谱成员响应不是数组', 'MEMBER_LIST_RESPONSE_INVALID')
}
const seenIds = new Set()
return value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('家谱成员包含无效条目', 'MEMBER_LIST_RESPONSE_INVALID')
}
const memberId = normalizeResourcePathId(item.memberId, '成员标识')
if (seenIds.has(memberId)) {
throw createRequestError('家谱成员包含重复标识', 'MEMBER_LIST_RESPONSE_INVALID')
}
seenIds.add(memberId)
const genealogyId = normalizeResourcePathId(item.genealogyId, '家谱标识')
if (genealogyId !== expectedGenealogyId) {
throw createRequestError('家谱成员归属与请求不匹配', 'MEMBER_LIST_RESPONSE_INVALID')
}
const roleType = normalizeMemberText(item.roleType, 'roleType')
if (!genealogyMemberRoles.has(roleType)) {
throw createRequestError('家谱成员角色无效', 'MEMBER_LIST_RESPONSE_INVALID')
}
const memberName = normalizeMemberText(item.memberName, 'memberName')
if (!memberName) {
throw createRequestError('家谱成员缺少显示名称', 'MEMBER_LIST_RESPONSE_INVALID')
}
return {
memberId,
genealogyId,
genealogyNo: normalizeMemberText(item.genealogyNo, 'genealogyNo'),
genealogyName: normalizeMemberText(item.genealogyName, 'genealogyName'),
surname: normalizeMemberText(item.surname, 'surname'),
appUserId: normalizeOptionalNumericId(item.appUserId, '成员用户标识', 'MEMBER_LIST_RESPONSE_INVALID'),
appUserNickName: normalizeMemberText(item.appUserNickName, 'appUserNickName'),
lineagePersonId: normalizeOptionalNumericId(item.lineagePersonId, '世系人物标识', 'MEMBER_LIST_RESPONSE_INVALID'),
lineagePersonNo: normalizeMemberText(item.lineagePersonNo, 'lineagePersonNo'),
lineagePersonName: normalizeMemberText(item.lineagePersonName, 'lineagePersonName'),
memberName,
roleType,
relationName: normalizeMemberText(item.relationName, 'relationName'),
joinSource: normalizeMemberText(item.joinSource, 'joinSource'),
inviterUserId: normalizeOptionalNumericId(item.inviterUserId, '邀请人用户标识', 'MEMBER_LIST_RESPONSE_INVALID'),
inviterNickName: normalizeMemberText(item.inviterNickName, 'inviterNickName'),
joinTime: normalizeMemberText(item.joinTime, 'joinTime'),
status: normalizeMemberText(item.status, 'status'),
capabilities: normalizeGenealogyMemberCapabilities(item.capabilities)
}
})
}
+116
View File
@@ -0,0 +1,116 @@
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import { normalizeGenealogyPathId } from './genealogy-contract.js'
import {
normalizeGenealogyMemberOptions,
normalizeGenealogyMembers,
normalizeMemberUpdatePayload
} from './genealogy-member-contract.js'
import { normalizeResourcePathId } from './request-normalizers.js'
import { createRequestError, requestStrict } from './request-client.js'
const requireRemoteMembership = (label, operation) => {
if (hasRemoteConfig()) return
throw createRequestError(
`${label}${operation}需要真实服务,当前本地预览不会伪造结果`,
operation === '读取' ? 'REMOTE_READ_REQUIRED' : 'REMOTE_WRITE_REQUIRED'
)
}
const writeMembership = (url, method, data, label, requestOptions) => {
requireRemoteMembership(label, '写入')
return requestStrict({
url,
method,
...(data === undefined ? {} : { data })
}, {
requestController: requestOptions.requestController ?? null
})
}
const deleteMembership = async (url, label, requestOptions) => {
requireRemoteMembership(label, '写入')
await requestStrict({ url, method: 'DELETE' }, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
}
export const genealogyMemberApi = {
async getMembers(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
requireRemoteMembership('家谱成员', '读取')
const members = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/members`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeGenealogyMembers(members, normalizedGenealogyId)
},
async getMemberOptions(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
requireRemoteMembership('成员选项', '读取')
const memberOptions = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/members/options`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeGenealogyMemberOptions(memberOptions)
},
async updateMember(genealogyId, memberId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedMemberId = normalizeResourcePathId(memberId, '成员标识')
const member = await writeMembership(
`/genealogy/app/genealogies/${normalizedGenealogyId}/members/${normalizedMemberId}`,
'PUT',
normalizeMemberUpdatePayload(payload),
'家谱成员',
requestOptions
)
return normalizeGenealogyMembers([member], normalizedGenealogyId)[0]
},
async unlinkMemberLineagePerson(genealogyId, memberId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedMemberId = normalizeResourcePathId(memberId, '成员标识')
return deleteMembership(
`/genealogy/app/genealogies/${normalizedGenealogyId}/members/${normalizedMemberId}/lineage-person`,
'成员世系绑定',
requestOptions
)
},
async removeMember(genealogyId, memberId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedMemberId = normalizeResourcePathId(memberId, '成员标识')
return deleteMembership(
`/genealogy/app/genealogies/${normalizedGenealogyId}/members/${normalizedMemberId}`,
'家谱成员',
requestOptions
)
},
async leaveGenealogy(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return deleteMembership(
`/genealogy/app/genealogies/${normalizedGenealogyId}/members/me`,
'退出家谱',
requestOptions
)
},
async transferGenealogyOwner(genealogyId, targetMemberId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return writeMembership(
`/genealogy/app/genealogies/${normalizedGenealogyId}/members/owner-transfer`,
'PUT',
{ targetMemberId: normalizeResourcePathId(targetMemberId, '目标成员标识') },
'家谱所有者转让',
requestOptions
)
}
}
@@ -0,0 +1,117 @@
import { createRequestError } from './request-client.js'
import { normalizeResponseText } from './response-normalizers.js'
import {
assertPlainPayload,
normalizeOptionalText,
normalizeResourcePathId
} from './request-normalizers.js'
import { normalizeGenealogyPathId } from './genealogy-contract.js'
export const JOIN_APPLICATION_STATUS = Object.freeze({
PENDING: '0',
APPROVED: '1',
REJECTED: '2',
CANCELLED: '3'
})
export const JOIN_APPLICATION_STATUS_LABELS = Object.freeze({
[JOIN_APPLICATION_STATUS.PENDING]: '审核中',
[JOIN_APPLICATION_STATUS.APPROVED]: '已通过',
[JOIN_APPLICATION_STATUS.REJECTED]: '未通过',
[JOIN_APPLICATION_STATUS.CANCELLED]: '已撤回'
})
const joinApplicationAuditStatuses = new Set([
JOIN_APPLICATION_STATUS.APPROVED,
JOIN_APPLICATION_STATUS.REJECTED
])
export const normalizeJoinApplicationPayload = (payload) => {
assertPlainPayload(payload, new Set(['applicantName', 'phone', 'relationDesc', 'applyReason']), '加入申请请求')
const normalizedPayload = {}
for (const field of ['applicantName', 'phone', 'relationDesc', 'applyReason']) {
const normalizedText = normalizeOptionalText(payload[field], field)
if (normalizedText) normalizedPayload[field] = normalizedText
}
return normalizedPayload
}
export const normalizeGenealogyInvitationToken = (value) => {
if (typeof value !== 'string') throw new TypeError('邀请码必须是文本')
const token = value.trim()
if (!token) throw new TypeError('请输入邀请码')
if (token.length > 128) throw new TypeError('邀请码长度不能超过128个字符')
return token
}
export const normalizeGenealogyInvitationRedeemPayload = (payload) => {
assertPlainPayload(payload, new Set(['token']), '家谱邀请码兑换请求')
return { token: normalizeGenealogyInvitationToken(payload.token) }
}
export const normalizeGenealogyInvitation = (value, expectedStatus = '') => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('家谱邀请码响应无效', 'GENEALOGY_INVITATION_RESPONSE_INVALID')
}
const genealogyId = normalizeGenealogyPathId(value.genealogyId)
const genealogyName = normalizeResponseText(value.genealogyName, 'genealogyName')
const status = normalizeResponseText(value.status, 'status')
const expiresAt = normalizeResponseText(value.expiresAt, 'expiresAt')
if (!genealogyName || !status || !expiresAt) {
throw createRequestError('家谱邀请码响应缺少可展示信息', 'GENEALOGY_INVITATION_RESPONSE_INVALID')
}
if (expectedStatus && status !== expectedStatus) {
throw createRequestError('家谱邀请码状态与当前操作不匹配', 'GENEALOGY_INVITATION_RESPONSE_INVALID')
}
const redemptionResult = normalizeResponseText(value.redemptionResult, 'redemptionResult')
if (expectedStatus === 'REDEEMED' && !['DIRECT_MEMBER', 'PENDING_APPLY'].includes(redemptionResult)) {
throw createRequestError('家谱邀请码兑换结果无效', 'GENEALOGY_INVITATION_RESPONSE_INVALID')
}
return {
genealogyId,
genealogyName,
status,
expiresAt,
redemptionResult
}
}
export const normalizeIssuedGenealogyInvitation = (value) => {
const invitation = normalizeGenealogyInvitation(value, 'ACTIVE')
return {
...invitation,
token: normalizeGenealogyInvitationToken(value.token)
}
}
const genealogyInvitationStatuses = new Set(['ACTIVE', 'REDEEMED', 'REVOKED', 'EXPIRED'])
export const normalizeMyGenealogyInvitations = (value) => {
if (!Array.isArray(value)) {
throw createRequestError('我的家谱邀请响应不是列表', 'GENEALOGY_INVITATION_RESPONSE_INVALID')
}
const invitations = value.map((item) => {
const invitation = normalizeGenealogyInvitation(item)
const id = normalizeResourcePathId(item.inviteId, '家谱邀请标识')
if (!genealogyInvitationStatuses.has(invitation.status)) {
throw createRequestError('家谱邀请状态无效', 'GENEALOGY_INVITATION_RESPONSE_INVALID')
}
return { id, ...invitation }
})
if (new Set(invitations.map((item) => item.id)).size !== invitations.length) {
throw createRequestError('我的家谱邀请响应包含重复标识', 'GENEALOGY_INVITATION_RESPONSE_INVALID')
}
return invitations
}
export const normalizeJoinAuditPayload = (payload) => {
assertPlainPayload(payload, new Set(['status', 'auditRemark']), '加入申请审核请求')
const status = normalizeOptionalText(payload.status, '审核状态')
if (!joinApplicationAuditStatuses.has(status)) {
throw new TypeError('加入申请审核状态只能是通过或拒绝')
}
const normalizedPayload = { status }
const auditRemark = normalizeOptionalText(payload.auditRemark, '审核备注')
if (auditRemark) normalizedPayload.auditRemark = auditRemark
return normalizedPayload
}
@@ -0,0 +1,236 @@
import {
createPreviewMyJoinApplications,
createPreviewPendingJoinApplications,
previewCurrentUser
} from '@/data/preview/genealogy-membership.js'
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import { normalizeGenealogyPathId } from './genealogy-contract.js'
import {
JOIN_APPLICATION_STATUS,
normalizeGenealogyInvitation,
normalizeGenealogyInvitationRedeemPayload,
normalizeGenealogyInvitationToken,
normalizeIssuedGenealogyInvitation,
normalizeJoinApplicationPayload,
normalizeJoinAuditPayload,
normalizeMyGenealogyInvitations
} from './genealogy-membership-contract.js'
import { normalizeResourcePathId } from './request-normalizers.js'
import { createRequestError, requestStrict } from './request-client.js'
const requireRemoteMembership = (label, operation) => {
if (hasRemoteConfig()) return
throw createRequestError(
`${label}${operation}需要真实服务,当前本地预览不会伪造结果`,
operation === '读取' ? 'REMOTE_READ_REQUIRED' : 'REMOTE_WRITE_REQUIRED'
)
}
const previewMyJoinApplications = createPreviewMyJoinApplications()
const previewPendingJoinApplications = createPreviewPendingJoinApplications()
let nextPreviewApplicationId = Date.now()
const snapshotPreviewJoinApplications = (applications) =>
applications.map((application) => ({ ...application }))
const createPreviewApplicationId = () => String(nextPreviewApplicationId++)
const readMembershipList = async (url, label, requestOptions) => {
requireRemoteMembership(label, '读取')
const rows = await requestStrict({ url, method: 'GET' }, {
requestController: requestOptions.requestController ?? null
})
if (!Array.isArray(rows)) {
throw createRequestError(`${label}响应不是列表`, 'LIST_RESPONSE_INVALID')
}
return rows
}
const writeMembership = (url, method, data, label, requestOptions) => {
requireRemoteMembership(label, '写入')
return requestStrict({
url,
method,
...(data === undefined ? {} : { data })
}, {
requestController: requestOptions.requestController ?? null
})
}
export const genealogyMembershipApi = {
async previewGenealogyInvitation(token, requestOptions = {}) {
const normalizedToken = normalizeGenealogyInvitationToken(token)
requireRemoteMembership('家谱邀请码', '读取')
const invitation = await requestStrict({
url: `/genealogy/app/genealogies/invitations/preview?token=${encodeURIComponent(normalizedToken)}`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeGenealogyInvitation(invitation, 'ACTIVE')
},
async issueGenealogyInvitation(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const invitationResponse = await writeMembership(
`/genealogy/app/genealogies/${normalizedGenealogyId}/invitations`,
'POST',
undefined,
'家谱邀请码签发',
requestOptions
)
const invitation = normalizeIssuedGenealogyInvitation(invitationResponse)
if (invitation.genealogyId !== normalizedGenealogyId) {
throw createRequestError(
'家谱邀请码归属与当前家谱不匹配',
'GENEALOGY_INVITATION_RESPONSE_INVALID'
)
}
return invitation
},
async getMyGenealogyInvitations(requestOptions = {}) {
requireRemoteMembership('我的家谱邀请', '读取')
const invitations = await requestStrict({
url: '/genealogy/app/genealogies/invitations/mine',
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeMyGenealogyInvitations(invitations)
},
async revokeGenealogyInvitation(inviteId, requestOptions = {}) {
const normalizedInviteId = normalizeResourcePathId(inviteId, '家谱邀请标识')
requireRemoteMembership('家谱邀请码撤销', '写入')
await requestStrict({
url: `/genealogy/app/genealogies/invitations/${normalizedInviteId}`,
method: 'DELETE'
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
},
async redeemGenealogyInvitation(payload, requestOptions = {}) {
const invitation = await writeMembership(
'/genealogy/app/genealogies/invitations/redeem',
'POST',
normalizeGenealogyInvitationRedeemPayload(payload),
'家谱邀请码兑换',
requestOptions
)
return normalizeGenealogyInvitation(invitation, 'REDEEMED')
},
async applyToJoin(genealogyId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const applicationDraft = normalizeJoinApplicationPayload(payload)
if (hasRemoteConfig()) {
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/join-applies`,
method: 'POST',
data: applicationDraft
}, {
requestController: requestOptions.requestController ?? null
})
}
previewMyJoinApplications.unshift({
id: createPreviewApplicationId(),
genealogyId: normalizedGenealogyId,
name: applicationDraft.applicantName || previewCurrentUser.name,
phone: applicationDraft.phone || previewCurrentUser.phone,
relation: applicationDraft.relationDesc || '关系待补充',
reason: applicationDraft.applyReason || '',
appliedAt: '刚刚',
status: JOIN_APPLICATION_STATUS.PENDING
})
return null
},
async getMyJoinApplications(requestOptions = {}) {
if (!hasRemoteConfig()) {
return snapshotPreviewJoinApplications(previewMyJoinApplications)
}
return readMembershipList(
'/genealogy/app/genealogies/join-applies/mine',
'我的加入申请',
requestOptions
)
},
async withdrawJoinApplication(applicationId, requestOptions = {}) {
const normalizedApplicationId = normalizeResourcePathId(applicationId, '加入申请标识')
if (!hasRemoteConfig()) {
const application = previewMyJoinApplications.find(
(candidate) =>
candidate.id === normalizedApplicationId &&
candidate.status === JOIN_APPLICATION_STATUS.PENDING
)
if (!application) {
throw createRequestError(
'待撤回申请不存在或已处理',
'PREVIEW_JOIN_APPLICATION_NOT_FOUND'
)
}
application.status = JOIN_APPLICATION_STATUS.CANCELLED
return null
}
await requestStrict({
url: `/genealogy/app/genealogies/join-applies/${normalizedApplicationId}`,
method: 'DELETE'
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
},
async getPendingApplications(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
if (!hasRemoteConfig()) {
return snapshotPreviewJoinApplications(
previewPendingJoinApplications.filter(
(application) =>
application.genealogyId === normalizedGenealogyId &&
application.status === JOIN_APPLICATION_STATUS.PENDING
)
)
}
return readMembershipList(
`/genealogy/app/genealogies/${normalizedGenealogyId}/join-applies/pending`,
'待审核申请',
requestOptions
)
},
async auditApplication(genealogyId, applicationId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedApplicationId = normalizeResourcePathId(applicationId, '加入申请标识')
const auditDecision = normalizeJoinAuditPayload(payload)
if (hasRemoteConfig()) {
await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/join-applies/${normalizedApplicationId}/audit`,
method: 'PUT',
data: auditDecision
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
}
const application = previewPendingJoinApplications.find(
(candidate) =>
candidate.id === normalizedApplicationId &&
candidate.genealogyId === normalizedGenealogyId &&
candidate.status === JOIN_APPLICATION_STATUS.PENDING
)
if (!application) {
throw createRequestError(
'待审核申请不存在、已处理或不属于当前家谱',
'PREVIEW_JOIN_APPLICATION_NOT_FOUND'
)
}
Object.assign(application, auditDecision)
return null
}
}
+169
View File
@@ -0,0 +1,169 @@
import { listPreviewPublicGenealogies } from '@/data/preview/genealogies.js'
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import {
normalizeAppGenealogy,
normalizeCreatedGenealogy,
normalizeGenealogyCreatePayload,
normalizeGenealogyOrderIds,
normalizeGenealogyPathId,
normalizeGenealogyQuota,
normalizeGenealogySettings,
normalizeGenealogyUpdatePayload,
normalizeMyGenealogies,
normalizePublicGenealogies,
projectPreviewPublicGenealogies
} from './genealogy-contract.js'
import {
createRequestError,
requestStrict
} from './request-client.js'
const requireRemoteGenealogy = (label, operation) => {
if (hasRemoteConfig()) return
throw createRequestError(
`${label}${operation}需要真实服务,当前本地预览不会伪造结果`,
operation === '读取' ? 'REMOTE_READ_REQUIRED' : 'REMOTE_WRITE_REQUIRED'
)
}
const writeGenealogyState = (genealogyId, action, label, requestOptions) => {
requireRemoteGenealogy(label, '写入')
return requestStrict({
url: `/genealogy/app/genealogies/${genealogyId}/${action}`,
method: 'PUT'
}, {
requestController: requestOptions.requestController ?? null
})
}
export const genealogyApi = {
async getPublicGenealogies(requestOptions = {}) {
if (!hasRemoteConfig()) {
return projectPreviewPublicGenealogies(listPreviewPublicGenealogies())
}
const publicGenealogyRows = await requestStrict({
url: '/genealogy/app/genealogies/public',
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizePublicGenealogies(publicGenealogyRows)
},
async getMyGenealogies(requestOptions = {}) {
requireRemoteGenealogy('我的家谱', '读取')
const genealogyRows = await requestStrict({
url: '/genealogy/app/genealogies/mine',
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeMyGenealogies(genealogyRows)
},
async saveMyGenealogyOrder(genealogyIds, requestOptions = {}) {
const orderedIds = normalizeGenealogyOrderIds(genealogyIds)
requireRemoteGenealogy('保存家谱排序', '写入')
const confirmedGenealogies = normalizeMyGenealogies(await requestStrict({
url: '/genealogy/app/genealogies/mine/order',
method: 'PUT',
data: { genealogyIds: orderedIds }
}, {
requestController: requestOptions.requestController ?? null
}))
if (
confirmedGenealogies.length !== orderedIds.length ||
confirmedGenealogies.some((genealogy, index) => genealogy.id !== orderedIds[index])
) {
throw createRequestError('家谱排序回读与提交顺序不一致', 'GENEALOGY_ORDER_RESPONSE_INVALID')
}
return confirmedGenealogies
},
async getGenealogyQuota(requestOptions = {}) {
requireRemoteGenealogy('家谱配额', '读取')
const quota = await requestStrict({
url: '/genealogy/app/genealogies/quota',
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeGenealogyQuota(quota)
},
async createGenealogy(payload, requestOptions = {}) {
const genealogyDraft = normalizeGenealogyCreatePayload(payload)
requireRemoteGenealogy('创建家谱', '写入')
const createdGenealogy = await requestStrict({
url: '/genealogy/app/genealogies',
method: 'POST',
data: genealogyDraft
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeCreatedGenealogy(createdGenealogy)
},
async getGenealogySettings(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
requireRemoteGenealogy('家谱设置', '读取')
const genealogySettings = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeGenealogySettings(genealogySettings, normalizedGenealogyId)
},
async updateGenealogy(genealogyId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const genealogyChanges = normalizeGenealogyUpdatePayload(payload)
requireRemoteGenealogy('更新家谱', '写入')
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}`,
method: 'PUT',
data: genealogyChanges
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
},
async archiveGenealogy(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const genealogy = await writeGenealogyState(
normalizedGenealogyId,
'archive',
'家谱归档',
requestOptions
)
return normalizeAppGenealogy(genealogy)
},
async restoreGenealogy(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const genealogy = await writeGenealogyState(
normalizedGenealogyId,
'restore',
'家谱恢复',
requestOptions
)
return normalizeAppGenealogy(genealogy)
},
async getOverview(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
requireRemoteGenealogy('家谱概览', '读取')
const overview = normalizeAppGenealogy(await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/overview`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
}))
if (overview.id !== normalizedGenealogyId) {
throw createRequestError('家谱概览响应标识不匹配', 'GENEALOGY_RESPONSE_INVALID')
}
return overview
}
}
+106
View File
@@ -0,0 +1,106 @@
import { createRequestError } from './request-client.js'
import { NORMAL_DISABLE_STATUS } from './normal-disable-status.js'
import { assertPlainPayload } from './request-normalizers.js'
export const GENERATION_POEM_STATUS = Object.freeze({
ACTIVE: NORMAL_DISABLE_STATUS.NORMAL,
DISABLED: NORMAL_DISABLE_STATUS.DISABLED
})
const normalizeGenerationPoemId = (value, label) => {
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value)
throw createRequestError(`字辈${label}无效`, 'GENERATION_POEM_RESPONSE_INVALID')
}
const normalizeGenerationPoemNumber = (value, label) => {
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return value
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) {
const normalized = Number(value)
if (Number.isSafeInteger(normalized)) return normalized
}
throw createRequestError(`字辈${label}无效`, 'GENERATION_POEM_RESPONSE_INVALID')
}
const normalizeGenerationPoemText = (value, label, { required = false, maxLength = null } = {}) => {
if (value === undefined || value === null) {
if (required) throw createRequestError(`字辈缺少${label}`, 'GENERATION_POEM_RESPONSE_INVALID')
return ''
}
if (typeof value !== 'string') throw createRequestError(`字辈${label}无效`, 'GENERATION_POEM_RESPONSE_INVALID')
const normalized = value.trim()
if (required && !normalized) throw createRequestError(`字辈缺少${label}`, 'GENERATION_POEM_RESPONSE_INVALID')
if (maxLength !== null && Array.from(normalized).length > maxLength) {
throw createRequestError(`字辈${label}超出合同长度`, 'GENERATION_POEM_RESPONSE_INVALID')
}
return normalized
}
const normalizeGenerationPoemStatus = (value) => {
if (Object.values(GENERATION_POEM_STATUS).includes(value)) return value
throw createRequestError('字辈状态无效', 'GENERATION_POEM_RESPONSE_INVALID')
}
export const normalizeGenerationPoemRows = (value, expectedGenealogyId) => {
if (!Array.isArray(value)) throw createRequestError('字辈响应不是列表', 'GENERATION_POEM_RESPONSE_INVALID')
const rows = value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('字辈响应包含无效条目', 'GENERATION_POEM_RESPONSE_INVALID')
}
const genealogyId = normalizeGenerationPoemId(item.genealogyId, '家谱标识')
if (genealogyId !== expectedGenealogyId) {
throw createRequestError('字辈响应家谱标识与请求不匹配', 'GENERATION_POEM_RESPONSE_INVALID')
}
return {
poemId: normalizeGenerationPoemId(item.poemId, '标识'),
genealogyId,
genealogyNo: normalizeGenerationPoemText(item.genealogyNo, '家谱编号'),
genealogyName: normalizeGenerationPoemText(item.genealogyName, '家谱名称'),
generationNo: normalizeGenerationPoemNumber(item.generationNo, '世代'),
generationText: normalizeGenerationPoemText(item.generationText, '文字', { required: true, maxLength: 50 }),
description: normalizeGenerationPoemText(item.description, '说明', { maxLength: 500 }),
sortOrder: item.sortOrder,
status: normalizeGenerationPoemStatus(item.status),
}
})
if (new Set(rows.map((item) => item.poemId)).size !== rows.length) {
throw createRequestError('字辈响应包含重复标识', 'GENERATION_POEM_RESPONSE_INVALID')
}
if (new Set(rows.map((item) => item.generationNo)).size !== rows.length) {
throw createRequestError('字辈响应包含重复世代', 'GENERATION_POEM_RESPONSE_INVALID')
}
return rows.sort((left, right) => left.generationNo - right.generationNo)
}
export const normalizeGenerationPoemBatchPayload = (payload) => {
assertPlainPayload(payload, new Set(['poemText', 'disableMissing']), '字辈批量请求')
const poemText = normalizeGenerationPoemText(payload.poemText, '内容', { required: true, maxLength: 26000 })
const normalizedPayload = { poemText }
if (payload.disableMissing !== undefined) {
if (typeof payload.disableMissing !== 'boolean') throw new TypeError('字辈停用策略必须是布尔值')
normalizedPayload.disableMissing = payload.disableMissing
}
return normalizedPayload
}
export const normalizeGenerationPoemPreview = (value, expectedGenealogyId) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('字辈批量预览响应不是对象', 'GENERATION_POEM_RESPONSE_INVALID')
}
const genealogyId = normalizeGenerationPoemId(value.genealogyId, '预览家谱标识')
if (genealogyId !== expectedGenealogyId) {
throw createRequestError('字辈预览家谱标识与请求不匹配', 'GENERATION_POEM_RESPONSE_INVALID')
}
const counts = ['createCount', 'updateCount', 'keepCount', 'disableCount']
const preview = { genealogyId, items: Array.isArray(value.items) ? value.items : null }
for (const field of counts) {
if (!Number.isSafeInteger(value[field]) || value[field] < 0) {
throw createRequestError(`字辈预览${field}无效`, 'GENERATION_POEM_RESPONSE_INVALID')
}
preview[field] = value[field]
}
if (preview.items === null) {
throw createRequestError('字辈预览缺少明细列表', 'GENERATION_POEM_RESPONSE_INVALID')
}
return preview
}
+76
View File
@@ -0,0 +1,76 @@
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import {
normalizeGenerationPoemBatchPayload,
normalizeGenerationPoemPreview,
normalizeGenerationPoemRows
} from './generation-poem-contract.js'
import { normalizeGenealogyPathId } from './genealogy-contract.js'
import { createRequestError, requestStrict } from './request-client.js'
const requireRemoteGenerationPoem = (message, code) => {
if (hasRemoteConfig()) return
throw createRequestError(message, code)
}
export const generationPoemApi = {
async getGenerationPoems(genealogyId, requestOptions = {}) {
requireRemoteGenerationPoem(
'字辈列表需要真实读取服务,当前本地预览不会伪造字辈数据',
'REMOTE_READ_REQUIRED'
)
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const generationPoems = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/generation-poems`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeGenerationPoemRows(generationPoems, normalizedGenealogyId)
},
async getGenerationPoemManagement(genealogyId, requestOptions = {}) {
requireRemoteGenerationPoem(
'字辈维护列表需要真实读取服务,当前本地预览不会伪造管理权限',
'REMOTE_READ_REQUIRED'
)
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const generationPoems = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/generation-poems/management`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeGenerationPoemRows(generationPoems, normalizedGenealogyId)
},
async previewGenerationPoemBatch(genealogyId, payload, requestOptions = {}) {
requireRemoteGenerationPoem(
'字辈批量预览需要真实服务,当前本地预览不会伪造差异结果',
'REMOTE_READ_REQUIRED'
)
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const preview = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/generation-poems/batch/preview`,
method: 'POST',
data: normalizeGenerationPoemBatchPayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeGenerationPoemPreview(preview, normalizedGenealogyId)
},
async saveGenerationPoemBatch(genealogyId, payload, requestOptions = {}) {
requireRemoteGenerationPoem(
'字辈批量保存需要真实服务,当前本地预览不会伪造保存成功',
'REMOTE_WRITE_REQUIRED'
)
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/generation-poems/batch/save`,
method: 'POST',
data: normalizeGenerationPoemBatchPayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
}
}
+399
View File
@@ -0,0 +1,399 @@
import { createRequestError } from './request-client.js'
import {
normalizeNormalDisableResponseStatus,
normalizeOptionalCurrencyAmount,
normalizeOptionalNonnegativeInteger,
normalizeOptionalNumericId,
normalizeResponseText
} from './response-normalizers.js'
import {
assertPlainPayload,
normalizeOptionalNormalDisableStatus,
normalizeOptionalOssIdList,
normalizeOptionalSafeInteger,
normalizeOptionalText,
normalizeOssIdString,
normalizeResourcePathId
} from './request-normalizers.js'
import {
normalizeBusinessFileAccessRows,
normalizeContentProtectionCapabilities,
normalizeResourceCapabilities
} from './business-file-contract.js'
const freezeOptions = (options) =>
Object.freeze(options.map((option) => Object.freeze(option)))
export const MERIT_TYPE_OPTIONS = freezeOptions([
{ value: 'donation', label: '捐赠' },
{ value: 'repair', label: '修祠' },
{ value: 'public', label: '公益' },
{ value: 'other', label: '其他' }
])
export const LIFE_EVENT_TYPE_OPTIONS = freezeOptions([
{ value: 'BIRTH', label: '出生' },
{ value: 'EDUCATION', label: '求学' },
{ value: 'CAREER', label: '事业' },
{ value: 'MARRIAGE', label: '婚姻' },
{ value: 'MIGRATION', label: '迁居' },
{ value: 'HONOR', label: '荣誉' },
{ value: 'MAJOR_ACHIEVEMENT', label: '重要成就' },
{ value: 'DEATH', label: '逝世' },
{ value: 'OTHER', label: '其他' }
])
export const LIFE_EVENT_DATE_PRECISION_OPTIONS = freezeOptions([
{ value: 'YEAR', label: '只填年份' },
{ value: 'MONTH', label: '填到月份' },
{ value: 'DAY', label: '填到日期' }
])
const GROWTH_RECORD_FALLBACK_LABELS = Object.freeze({
birth: '出生',
preschool: '学龄前',
school: '求学',
'first-step': '第一次成长',
marriage: '婚姻',
career: '事业',
other: '其他'
})
const MERIT_TYPE_LABELS = Object.freeze(
Object.fromEntries(MERIT_TYPE_OPTIONS.map(({ value, label }) => [value, label]))
)
export const normalizeAppGrowthRecord = (value, expectedGenealogyId, expectedRecordId = '') => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('成长记录响应无效', 'GROWTH_RECORD_RESPONSE_INVALID')
}
const id = normalizeOptionalNumericId(value.recordId, '成长记录标识', 'GROWTH_RECORD_RESPONSE_INVALID')
const genealogyId = normalizeOptionalNumericId(value.genealogyId, '成长记录家谱标识', 'GROWTH_RECORD_RESPONSE_INVALID')
if (!id || genealogyId !== expectedGenealogyId || (expectedRecordId && id !== expectedRecordId)) {
throw createRequestError('成长记录响应缺少稳定归属字段', 'GROWTH_RECORD_RESPONSE_INVALID')
}
const type = normalizeResponseText(value.recordType, 'recordType')
return {
id,
genealogyId,
lineagePersonId: normalizeOptionalNumericId(value.lineagePersonId, '成长记录人物标识', 'GROWTH_RECORD_RESPONSE_INVALID'),
personName: normalizeResponseText(value.lineagePersonName, 'lineagePersonName') || '未关联人物',
type,
typeLabel: GROWTH_RECORD_FALLBACK_LABELS[type] || '',
title: normalizeResponseText(value.recordTitle, 'recordTitle') || '未命名记录',
content: normalizeResponseText(value.recordContent, 'recordContent'),
recordDate: normalizeResponseText(value.recordDate, 'recordDate'),
remindTime: normalizeResponseText(value.remindTime, 'remindTime'),
date: normalizeResponseText(value.recordDate, 'recordDate') || normalizeResponseText(value.remindTime, 'remindTime'),
mediaFiles: normalizeBusinessFileAccessRows(value.mediaFiles, '成长记录媒体', 'GROWTH_RECORD_RESPONSE_INVALID'),
sortOrder: normalizeOptionalNonnegativeInteger(value.sortOrder, '成长记录排序值', 'GROWTH_RECORD_RESPONSE_INVALID'),
status: normalizeNormalDisableResponseStatus(value.status, '成长记录状态', 'GROWTH_RECORD_RESPONSE_INVALID'),
...normalizeContentProtectionCapabilities(value, '成长记录', 'GROWTH_RECORD_RESPONSE_INVALID'),
...normalizeResourceCapabilities(value, '成长记录', 'GROWTH_RECORD_RESPONSE_INVALID')
}
}
export const normalizeAppGrowthRecords = (value, expectedGenealogyId) => {
if (!Array.isArray(value)) throw createRequestError('成长记录响应不是列表', 'GROWTH_RECORD_RESPONSE_INVALID')
const records = value.map((item) => normalizeAppGrowthRecord(item, expectedGenealogyId))
if (new Set(records.map((item) => item.id)).size !== records.length) {
throw createRequestError('成长记录响应包含重复标识', 'GROWTH_RECORD_RESPONSE_INVALID')
}
return records
}
export const projectPreviewGrowthRecords = (value, expectedGenealogyId) =>
value.map((item) => ({
id: String(item.recordId),
genealogyId: expectedGenealogyId,
lineagePersonId: item.lineagePersonId ? String(item.lineagePersonId) : null,
personName: String(item.lineagePersonName || '未关联人物'),
type: String(item.recordType || ''),
typeLabel: GROWTH_RECORD_FALLBACK_LABELS[String(item.recordType || '')] || '',
title: String(item.recordTitle || '未命名记录'),
content: String(item.recordContent || ''),
date: String(item.recordDate || item.remindTime || ''),
canEdit: false,
canDelete: false
}))
const lifeEventTypes = new Set(LIFE_EVENT_TYPE_OPTIONS.map(({ value }) => value))
const lifeEventDatePrecisions = new Set(
LIFE_EVENT_DATE_PRECISION_OPTIONS.map(({ value }) => value)
)
const normalizeLifeEventDate = (value, label, code) => {
const normalized = normalizeResponseText(value, label)
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
throw createRequestError(`${label}无效`, code)
}
const [year, month, day] = normalized.split('-').map(Number)
const parsed = new Date(Date.UTC(year, month - 1, day))
if (parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day) {
throw createRequestError(`${label}无效`, code)
}
return normalized
}
export const normalizeAppLifeEvent = (value, expectedGenealogyId, expectedLineagePersonId, expectedEventId = '') => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('人生大事响应无效', 'LIFE_EVENT_RESPONSE_INVALID')
}
const id = normalizeOptionalNumericId(value.eventId, '人生大事标识', 'LIFE_EVENT_RESPONSE_INVALID')
const genealogyId = normalizeOptionalNumericId(value.genealogyId, '人生大事家谱标识', 'LIFE_EVENT_RESPONSE_INVALID')
const lineagePersonId = normalizeOptionalNumericId(value.lineagePersonId, '人生大事人物标识', 'LIFE_EVENT_RESPONSE_INVALID')
if (
!id ||
genealogyId !== expectedGenealogyId ||
lineagePersonId !== expectedLineagePersonId ||
(expectedEventId && id !== expectedEventId)
) {
throw createRequestError('人生大事响应缺少稳定归属字段', 'LIFE_EVENT_RESPONSE_INVALID')
}
const type = normalizeResponseText(value.eventType, 'eventType')
const datePrecision = normalizeResponseText(value.datePrecision, 'datePrecision')
const title = normalizeResponseText(value.eventTitle, 'eventTitle')
if (!lifeEventTypes.has(type) || !lifeEventDatePrecisions.has(datePrecision) || !title) {
throw createRequestError('人生大事响应缺少有效必填字段', 'LIFE_EVENT_RESPONSE_INVALID')
}
return {
id,
genealogyId,
lineagePersonId,
type,
title,
content: normalizeResponseText(value.eventContent, 'eventContent'),
date: normalizeLifeEventDate(value.eventDate, 'eventDate', 'LIFE_EVENT_RESPONSE_INVALID'),
datePrecision,
place: normalizeResponseText(value.eventPlace, 'eventPlace'),
sourceDescription: normalizeResponseText(value.sourceDescription, 'sourceDescription'),
mediaFiles: normalizeBusinessFileAccessRows(value.mediaFiles, '人生大事媒体', 'LIFE_EVENT_RESPONSE_INVALID'),
...normalizeResourceCapabilities(value, '人生大事', 'LIFE_EVENT_RESPONSE_INVALID')
}
}
export const normalizeAppLifeEvents = (value, expectedGenealogyId, expectedLineagePersonId) => {
if (!Array.isArray(value)) throw createRequestError('人生大事响应不是列表', 'LIFE_EVENT_RESPONSE_INVALID')
const events = value.map((item) => normalizeAppLifeEvent(item, expectedGenealogyId, expectedLineagePersonId))
if (new Set(events.map((item) => item.id)).size !== events.length) {
throw createRequestError('人生大事响应包含重复标识', 'LIFE_EVENT_RESPONSE_INVALID')
}
return events
}
export const normalizeAppRelativeRecord = (value, expectedGenealogyId, expectedRelativeId = '') => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('亲友往来响应无效', 'RELATIVE_RECORD_RESPONSE_INVALID')
}
const id = normalizeOptionalNumericId(value.relativeId, '亲友记录标识', 'RELATIVE_RECORD_RESPONSE_INVALID')
const genealogyId = normalizeOptionalNumericId(value.genealogyId, '亲友记录家谱标识', 'RELATIVE_RECORD_RESPONSE_INVALID')
if (!id || genealogyId !== expectedGenealogyId || (expectedRelativeId && id !== expectedRelativeId)) {
throw createRequestError('亲友往来响应缺少稳定归属字段', 'RELATIVE_RECORD_RESPONSE_INVALID')
}
return {
id,
genealogyId,
name: normalizeResponseText(value.relativeName, 'relativeName') || '未命名亲友',
relation: normalizeResponseText(value.relationName, 'relationName'),
event: normalizeResponseText(value.eventName, 'eventName'),
time: normalizeResponseText(value.eventTime, 'eventTime'),
eventTime: normalizeResponseText(value.eventTime, 'eventTime'),
amount: normalizeOptionalCurrencyAmount(value.giftAmount, '亲友礼金金额', 'RELATIVE_RECORD_RESPONSE_INVALID'),
content: normalizeResponseText(value.recordContent, 'recordContent'),
mediaFiles: normalizeBusinessFileAccessRows(value.mediaFiles, '亲友往来媒体', 'RELATIVE_RECORD_RESPONSE_INVALID'),
sortOrder: normalizeOptionalNonnegativeInteger(value.sortOrder, '亲友往来排序值', 'RELATIVE_RECORD_RESPONSE_INVALID'),
status: normalizeNormalDisableResponseStatus(value.status, '亲友往来状态', 'RELATIVE_RECORD_RESPONSE_INVALID'),
...normalizeResourceCapabilities(value, '亲友往来', 'RELATIVE_RECORD_RESPONSE_INVALID')
}
}
export const normalizeAppRelativeRecords = (value, expectedGenealogyId) => {
if (!Array.isArray(value)) throw createRequestError('亲友往来响应不是列表', 'RELATIVE_RECORD_RESPONSE_INVALID')
const records = value.map((item) => normalizeAppRelativeRecord(item, expectedGenealogyId))
if (new Set(records.map((item) => item.id)).size !== records.length) {
throw createRequestError('亲友往来响应包含重复标识', 'RELATIVE_RECORD_RESPONSE_INVALID')
}
return records
}
export const normalizeAppMemo = (value, expectedGenealogyId, expectedMemoId = '') => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('备忘录响应无效', 'MEMO_RESPONSE_INVALID')
}
const id = normalizeOptionalNumericId(value.memoId, '备忘标识', 'MEMO_RESPONSE_INVALID')
const genealogyId = normalizeOptionalNumericId(value.genealogyId, '备忘家谱标识', 'MEMO_RESPONSE_INVALID')
if (!id || genealogyId !== expectedGenealogyId || (expectedMemoId && id !== expectedMemoId)) {
throw createRequestError('备忘录响应缺少稳定归属字段', 'MEMO_RESPONSE_INVALID')
}
return {
id,
genealogyId,
title: normalizeResponseText(value.memoTitle, 'memoTitle') || '未命名备忘',
remindTime: normalizeResponseText(value.remindTime, 'remindTime'),
content: normalizeResponseText(value.memoContent, 'memoContent'),
completed: normalizeResponseText(value.completed, 'completed'),
mediaFiles: normalizeBusinessFileAccessRows(value.mediaFiles, '备忘媒体', 'MEMO_RESPONSE_INVALID'),
sortOrder: normalizeOptionalNonnegativeInteger(value.sortOrder, '备忘排序值', 'MEMO_RESPONSE_INVALID'),
status: normalizeNormalDisableResponseStatus(value.status, '备忘状态', 'MEMO_RESPONSE_INVALID'),
...normalizeResourceCapabilities(value, '备忘录', 'MEMO_RESPONSE_INVALID')
}
}
export const normalizeAppMemos = (value, expectedGenealogyId) => {
if (!Array.isArray(value)) throw createRequestError('备忘录响应不是列表', 'MEMO_RESPONSE_INVALID')
const memos = value.map((item) => normalizeAppMemo(item, expectedGenealogyId))
if (new Set(memos.map((item) => item.id)).size !== memos.length) {
throw createRequestError('备忘录响应包含重复标识', 'MEMO_RESPONSE_INVALID')
}
return memos
}
export const normalizeAppMeritRecord = (value, expectedGenealogyId, expectedMeritId = '') => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('功德记录响应无效', 'MERIT_RECORD_RESPONSE_INVALID')
}
const id = normalizeOptionalNumericId(value.meritId, '功德记录标识', 'MERIT_RECORD_RESPONSE_INVALID')
const genealogyId = normalizeOptionalNumericId(value.genealogyId, '功德记录家谱标识', 'MERIT_RECORD_RESPONSE_INVALID')
if (!id || genealogyId !== expectedGenealogyId || (expectedMeritId && id !== expectedMeritId)) {
throw createRequestError('功德记录响应缺少稳定归属字段', 'MERIT_RECORD_RESPONSE_INVALID')
}
const type = normalizeResponseText(value.meritType, 'meritType')
const typeLabel = type ? (MERIT_TYPE_LABELS[type] || type) : ''
return {
id,
genealogyId,
donor: normalizeResponseText(value.donorName, 'donorName') || '未署名',
title: normalizeResponseText(value.meritTitle, 'meritTitle') || '未命名功德',
type,
typeLabel,
amount: normalizeOptionalCurrencyAmount(value.amount, '功德金额', 'MERIT_RECORD_RESPONSE_INVALID', { required: true }),
time: normalizeResponseText(value.meritTime, 'meritTime'),
content: normalizeResponseText(value.meritContent, 'meritContent'),
sortOrder: normalizeOptionalNonnegativeInteger(value.sortOrder, '功德记录排序值', 'MERIT_RECORD_RESPONSE_INVALID'),
status: normalizeNormalDisableResponseStatus(value.status, '功德记录状态', 'MERIT_RECORD_RESPONSE_INVALID'),
...normalizeResourceCapabilities(value, '功德记录', 'MERIT_RECORD_RESPONSE_INVALID')
}
}
export const normalizeAppMeritRecords = (value, expectedGenealogyId) => {
if (!Array.isArray(value)) throw createRequestError('功德记录响应不是列表', 'MERIT_RECORD_RESPONSE_INVALID')
const records = value.map((item) => normalizeAppMeritRecord(item, expectedGenealogyId))
if (new Set(records.map((item) => item.id)).size !== records.length) {
throw createRequestError('功德记录响应包含重复标识', 'MERIT_RECORD_RESPONSE_INVALID')
}
return records
}
export const normalizeRelativeRecordCreatePayload = (payload) => {
const allowedFields = new Set(['relativeName', 'relationName', 'eventName', 'eventTime', 'giftAmount', 'recordContent', 'mediaOssIds', 'sortOrder', 'status'])
assertPlainPayload(payload, allowedFields, '亲友往来请求')
const relativeName = typeof payload.relativeName === 'string' ? payload.relativeName.trim() : ''
if (!relativeName) throw new TypeError('亲友姓名不能为空')
const normalizedPayload = { relativeName }
for (const field of ['relationName', 'eventName', 'eventTime', 'recordContent']) {
const normalizedText = normalizeOptionalText(payload[field], field)
if (normalizedText) normalizedPayload[field] = normalizedText
}
const status = normalizeOptionalNormalDisableStatus(payload.status, '亲友往来状态')
if (status) normalizedPayload.status = status
if (payload.giftAmount !== undefined && payload.giftAmount !== '' && payload.giftAmount !== null) {
const giftAmount = typeof payload.giftAmount === 'number' ? payload.giftAmount : Number(payload.giftAmount)
if (!Number.isFinite(giftAmount)) {
throw new TypeError('亲友往来礼金金额必须是有限数字')
}
normalizedPayload.giftAmount = giftAmount
}
const mediaOssIds = normalizeOptionalOssIdList(payload.mediaOssIds)
if (mediaOssIds) normalizedPayload.mediaOssIds = mediaOssIds
const sortOrder = normalizeOptionalSafeInteger(payload.sortOrder, 'sortOrder')
if (sortOrder !== undefined) normalizedPayload.sortOrder = sortOrder
return normalizedPayload
}
export const normalizeGrowthRecordCreatePayload = (payload) => {
const allowedFields = new Set(['lineagePersonId', 'recordType', 'recordTitle', 'recordContent', 'recordDate', 'remindTime', 'mediaOssIds', 'sortOrder', 'status'])
assertPlainPayload(payload, allowedFields, '成长记录请求')
const recordTitle = typeof payload.recordTitle === 'string' ? payload.recordTitle.trim() : ''
if (!recordTitle) throw new TypeError('成长记录标题不能为空')
const normalizedPayload = { recordTitle }
for (const field of ['recordType', 'recordContent', 'recordDate', 'remindTime']) {
const normalizedText = normalizeOptionalText(payload[field], field)
if (normalizedText) normalizedPayload[field] = normalizedText
}
const status = normalizeOptionalNormalDisableStatus(payload.status, '成长记录状态')
if (status) normalizedPayload.status = status
if (payload.lineagePersonId !== undefined && payload.lineagePersonId !== null && payload.lineagePersonId !== '') {
normalizedPayload.lineagePersonId = normalizeResourcePathId(payload.lineagePersonId, '关联人物标识')
}
const mediaOssIds = normalizeOptionalOssIdList(payload.mediaOssIds)
if (mediaOssIds) normalizedPayload.mediaOssIds = mediaOssIds
const sortOrder = normalizeOptionalSafeInteger(payload.sortOrder, 'sortOrder')
if (sortOrder !== undefined) normalizedPayload.sortOrder = sortOrder
return normalizedPayload
}
export const normalizeLifeEventPayload = (payload) => {
const allowedFields = new Set(['eventType', 'eventTitle', 'eventContent', 'eventDate', 'datePrecision', 'eventPlace', 'sourceDescription', 'mediaOssIds'])
assertPlainPayload(payload, allowedFields, '人生大事请求')
const eventType = normalizeOptionalText(payload.eventType, 'eventType')
const eventTitle = normalizeOptionalText(payload.eventTitle, 'eventTitle')
const datePrecision = normalizeOptionalText(payload.datePrecision, 'datePrecision')
if (!lifeEventTypes.has(eventType) || !eventTitle || !lifeEventDatePrecisions.has(datePrecision)) {
throw new TypeError('请完整填写人生大事的类型、标题和日期精度')
}
const eventDate = normalizeLifeEventDate(payload.eventDate, 'eventDate', 'LIFE_EVENT_REQUEST_INVALID')
const normalizedPayload = { eventType, eventTitle, eventDate, datePrecision }
for (const field of ['eventContent', 'eventPlace', 'sourceDescription']) {
const normalizedText = normalizeOptionalText(payload[field], field)
if (normalizedText) normalizedPayload[field] = normalizedText
}
if (payload.mediaOssIds !== undefined && payload.mediaOssIds !== null) {
if (!Array.isArray(payload.mediaOssIds)) throw new TypeError('mediaOssIds 必须是文件标识数组')
const mediaOssIds = payload.mediaOssIds.map((item) => normalizeOssIdString(item, 'mediaOssIds'))
if (new Set(mediaOssIds).size !== mediaOssIds.length) throw new TypeError('mediaOssIds 不能重复')
if (mediaOssIds.length) normalizedPayload.mediaOssIds = mediaOssIds
}
return normalizedPayload
}
export const normalizeMemoCreatePayload = (payload) => {
const allowedFields = new Set(['memoTitle', 'memoContent', 'remindTime', 'completed', 'mediaOssIds', 'sortOrder', 'status'])
assertPlainPayload(payload, allowedFields, '备忘请求')
const memoTitle = typeof payload.memoTitle === 'string' ? payload.memoTitle.trim() : ''
if (!memoTitle) throw new TypeError('备忘标题不能为空')
const normalizedPayload = { memoTitle }
for (const field of ['memoContent', 'remindTime', 'completed']) {
const normalizedText = normalizeOptionalText(payload[field], field)
if (normalizedText) normalizedPayload[field] = normalizedText
}
const status = normalizeOptionalNormalDisableStatus(payload.status, '备忘状态')
if (status) normalizedPayload.status = status
const mediaOssIds = normalizeOptionalOssIdList(payload.mediaOssIds)
if (mediaOssIds) normalizedPayload.mediaOssIds = mediaOssIds
const sortOrder = normalizeOptionalSafeInteger(payload.sortOrder, 'sortOrder')
if (sortOrder !== undefined) normalizedPayload.sortOrder = sortOrder
return normalizedPayload
}
export const normalizeMeritRecordCreatePayload = (payload) => {
const allowedFields = new Set(['donorName', 'meritTitle', 'meritType', 'meritContent', 'amount', 'meritTime', 'sortOrder', 'status'])
assertPlainPayload(payload, allowedFields, '功德记录请求')
const donorName = typeof payload.donorName === 'string' ? payload.donorName.trim() : ''
const meritTitle = typeof payload.meritTitle === 'string' ? payload.meritTitle.trim() : ''
if (!donorName || !meritTitle) throw new TypeError('功德记录捐赠人和标题不能为空')
const normalizedPayload = { donorName, meritTitle }
for (const field of ['meritType', 'meritContent', 'meritTime']) {
const normalizedText = normalizeOptionalText(payload[field], field)
if (normalizedText) normalizedPayload[field] = normalizedText
}
const status = normalizeOptionalNormalDisableStatus(payload.status, '功德记录状态')
if (status) normalizedPayload.status = status
if (payload.amount !== undefined && payload.amount !== '' && payload.amount !== null) {
const amount = typeof payload.amount === 'number' ? payload.amount : Number(payload.amount)
if (!Number.isFinite(amount)) throw new TypeError('功德金额必须是有限数字')
normalizedPayload.amount = amount
}
const sortOrder = normalizeOptionalSafeInteger(payload.sortOrder, 'sortOrder')
if (sortOrder !== undefined) normalizedPayload.sortOrder = sortOrder
return normalizedPayload
}
+348
View File
@@ -0,0 +1,348 @@
import { listPreviewGrowthRecords } from '@/data/preview/records.js'
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import { normalizeGenealogyPathId } from './genealogy-contract.js'
import {
normalizeAppGrowthRecord,
normalizeAppGrowthRecords,
normalizeAppLifeEvent,
normalizeAppLifeEvents,
normalizeAppMemo,
normalizeAppMemos,
normalizeAppMeritRecord,
normalizeAppMeritRecords,
normalizeAppRelativeRecord,
normalizeAppRelativeRecords,
normalizeGrowthRecordCreatePayload,
normalizeLifeEventPayload,
normalizeMemoCreatePayload,
normalizeMeritRecordCreatePayload,
normalizeRelativeRecordCreatePayload,
projectPreviewGrowthRecords
} from './life-record-contract.js'
import { normalizeResourcePathId } from './request-normalizers.js'
import {
contentAccessHeader,
normalizeContentAccessGrant,
normalizeContentPasswordPayload
} from './protected-content-contract.js'
import { createRequestError, requestStrict } from './request-client.js'
const requireRemoteLifeRecord = (message, code) => {
if (hasRemoteConfig()) return
throw createRequestError(message, code)
}
const writeLifeRecord = (url, method, data, label, requestOptions) => {
requireRemoteLifeRecord(`${label}需要真实服务,当前本地预览不会伪造操作成功`, 'REMOTE_WRITE_REQUIRED')
return requestStrict({ url, method, data }, {
requestController: requestOptions.requestController ?? null
})
}
const deleteLifeRecord = async (url, label, requestOptions) => {
requireRemoteLifeRecord(`${label}需要真实服务,当前本地预览不会伪造删除成功`, 'REMOTE_WRITE_REQUIRED')
await requestStrict({ url, method: 'DELETE' }, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
}
export const lifeRecordApi = {
async createRelativeRecord(genealogyId, payload, requestOptions = {}) {
requireRemoteLifeRecord('亲友往来创建需要真实服务,当前本地预览不会伪造创建成功', 'REMOTE_WRITE_REQUIRED')
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/relative-records`,
method: 'POST',
data: normalizeRelativeRecordCreatePayload(payload)
}, { requestController: requestOptions.requestController ?? null })
},
async updateRelativeRecord(genealogyId, relativeId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedRelativeId = normalizeResourcePathId(relativeId, '亲友记录标识')
requireRemoteLifeRecord('修改亲友记录需要真实服务,当前本地预览不会伪造保存成功', 'REMOTE_WRITE_REQUIRED')
const relativeRecord = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/relative-records/${normalizedRelativeId}`,
method: 'PUT',
data: normalizeRelativeRecordCreatePayload(payload)
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppRelativeRecord(relativeRecord, normalizedGenealogyId, normalizedRelativeId)
},
async deleteRelativeRecord(genealogyId, relativeId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedRelativeId = normalizeResourcePathId(relativeId, '亲友记录标识')
return deleteLifeRecord(
`/genealogy/app/genealogies/${normalizedGenealogyId}/relative-records/${normalizedRelativeId}`,
'删除亲友记录',
requestOptions
)
},
async getRelativeRecords(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
requireRemoteLifeRecord('亲友记录需要真实读取服务,当前本地预览不会伪造列表', 'REMOTE_READ_REQUIRED')
const relativeRecords = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/relative-records`,
method: 'GET'
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppRelativeRecords(relativeRecords, normalizedGenealogyId)
},
async getRelativeRecordDetail(genealogyId, relativeId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedRelativeId = normalizeResourcePathId(relativeId, '亲友记录标识')
requireRemoteLifeRecord('亲友记录详情需要真实读取服务,当前本地预览不会伪造详情', 'REMOTE_READ_REQUIRED')
const relativeRecord = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/relative-records/${normalizedRelativeId}`,
method: 'GET'
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppRelativeRecord(relativeRecord, normalizedGenealogyId, normalizedRelativeId)
},
async createGrowthRecord(genealogyId, payload, requestOptions = {}) {
requireRemoteLifeRecord('成长记录创建需要真实服务,当前本地预览不会伪造创建成功', 'REMOTE_WRITE_REQUIRED')
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/growth-records`,
method: 'POST',
data: normalizeGrowthRecordCreatePayload(payload)
}, { requestController: requestOptions.requestController ?? null })
},
async updateGrowthRecord(genealogyId, recordId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedRecordId = normalizeResourcePathId(recordId, '成长记录标识')
requireRemoteLifeRecord('修改成长记录需要真实服务,当前本地预览不会伪造保存成功', 'REMOTE_WRITE_REQUIRED')
const growthRecord = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/growth-records/${normalizedRecordId}`,
method: 'PUT',
data: normalizeGrowthRecordCreatePayload(payload)
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppGrowthRecord(growthRecord, normalizedGenealogyId, normalizedRecordId)
},
async deleteGrowthRecord(genealogyId, recordId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedRecordId = normalizeResourcePathId(recordId, '成长记录标识')
return deleteLifeRecord(
`/genealogy/app/genealogies/${normalizedGenealogyId}/growth-records/${normalizedRecordId}`,
'删除成长记录',
requestOptions
)
},
async getGrowthRecords(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
if (!hasRemoteConfig()) {
return projectPreviewGrowthRecords(
listPreviewGrowthRecords(normalizedGenealogyId),
normalizedGenealogyId
)
}
const growthRecords = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/growth-records`,
method: 'GET'
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppGrowthRecords(growthRecords, normalizedGenealogyId)
},
async getGrowthRecordDetail(genealogyId, recordId, accessToken = '', requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedRecordId = normalizeResourcePathId(recordId, '成长记录标识')
requireRemoteLifeRecord('成长记录详情需要真实读取服务,当前本地预览不会伪造详情', 'REMOTE_READ_REQUIRED')
const growthRecord = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/growth-records/${normalizedRecordId}`,
method: 'GET',
header: contentAccessHeader(accessToken)
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppGrowthRecord(growthRecord, normalizedGenealogyId, normalizedRecordId)
},
async setGrowthRecordPassword(genealogyId, recordId, password, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedRecordId = normalizeResourcePathId(recordId, '成长记录标识')
const passwordPayload = normalizeContentPasswordPayload(password)
await writeLifeRecord(
`/genealogy/app/genealogies/${normalizedGenealogyId}/growth-records/${normalizedRecordId}/content-protection`,
'PUT',
passwordPayload,
'成长记录内容密码设置',
requestOptions
)
return null
},
async unlockGrowthRecord(genealogyId, recordId, password, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedRecordId = normalizeResourcePathId(recordId, '成长记录标识')
const passwordPayload = normalizeContentPasswordPayload(password)
const accessGrant = await writeLifeRecord(
`/genealogy/app/genealogies/${normalizedGenealogyId}/growth-records/${normalizedRecordId}/content-unlock`,
'POST',
passwordPayload,
'成长记录内容解锁',
requestOptions
)
return normalizeContentAccessGrant(accessGrant)
},
async disableGrowthRecordPassword(genealogyId, recordId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedRecordId = normalizeResourcePathId(recordId, '成长记录标识')
return deleteLifeRecord(
`/genealogy/app/genealogies/${normalizedGenealogyId}/growth-records/${normalizedRecordId}/content-protection`,
'成长记录内容密码停用',
requestOptions
)
},
async createLifeEvent(genealogyId, lineagePersonId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedLineagePersonId = normalizeResourcePathId(lineagePersonId, '人物标识')
requireRemoteLifeRecord('人生大事创建需要真实服务,当前本地预览不会伪造创建成功', 'REMOTE_WRITE_REQUIRED')
const lifeEvent = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage-persons/${normalizedLineagePersonId}/life-events`,
method: 'POST',
data: normalizeLifeEventPayload(payload)
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppLifeEvent(lifeEvent, normalizedGenealogyId, normalizedLineagePersonId)
},
async updateLifeEvent(genealogyId, lineagePersonId, eventId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedLineagePersonId = normalizeResourcePathId(lineagePersonId, '人物标识')
const normalizedEventId = normalizeResourcePathId(eventId, '人生大事标识')
const lifeEventChanges = normalizeLifeEventPayload(payload)
const lifeEvent = await writeLifeRecord(
`/genealogy/app/genealogies/${normalizedGenealogyId}/lineage-persons/${normalizedLineagePersonId}/life-events/${normalizedEventId}`,
'PUT',
lifeEventChanges,
'人生大事更新',
requestOptions
)
return normalizeAppLifeEvent(lifeEvent, normalizedGenealogyId, normalizedLineagePersonId, normalizedEventId)
},
async deleteLifeEvent(genealogyId, lineagePersonId, eventId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedLineagePersonId = normalizeResourcePathId(lineagePersonId, '人物标识')
const normalizedEventId = normalizeResourcePathId(eventId, '人生大事标识')
return deleteLifeRecord(
`/genealogy/app/genealogies/${normalizedGenealogyId}/lineage-persons/${normalizedLineagePersonId}/life-events/${normalizedEventId}`,
'人生大事删除',
requestOptions
)
},
async getLifeEvents(genealogyId, lineagePersonId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedLineagePersonId = normalizeResourcePathId(lineagePersonId, '人物标识')
requireRemoteLifeRecord('人生大事需要真实读取服务,当前本地预览不会伪造列表', 'REMOTE_READ_REQUIRED')
const lifeEvents = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage-persons/${normalizedLineagePersonId}/life-events`,
method: 'GET'
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppLifeEvents(lifeEvents, normalizedGenealogyId, normalizedLineagePersonId)
},
async createMemo(genealogyId, payload, requestOptions = {}) {
requireRemoteLifeRecord('备忘创建需要真实服务,当前本地预览不会伪造创建成功', 'REMOTE_WRITE_REQUIRED')
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/memos`,
method: 'POST',
data: normalizeMemoCreatePayload(payload)
}, { requestController: requestOptions.requestController ?? null })
},
async updateMemo(genealogyId, memoId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedMemoId = normalizeResourcePathId(memoId, '备忘标识')
requireRemoteLifeRecord('修改备忘需要真实服务,当前本地预览不会伪造保存成功', 'REMOTE_WRITE_REQUIRED')
const memo = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/memos/${normalizedMemoId}`,
method: 'PUT',
data: normalizeMemoCreatePayload(payload)
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppMemo(memo, normalizedGenealogyId, normalizedMemoId)
},
async deleteMemo(genealogyId, memoId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedMemoId = normalizeResourcePathId(memoId, '备忘标识')
return deleteLifeRecord(`/genealogy/app/genealogies/${normalizedGenealogyId}/memos/${normalizedMemoId}`, '删除备忘', requestOptions)
},
async getMemos(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
requireRemoteLifeRecord('备忘列表需要真实读取服务,当前本地预览不会伪造列表', 'REMOTE_READ_REQUIRED')
const memos = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/memos`,
method: 'GET'
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppMemos(memos, normalizedGenealogyId)
},
async getMemoDetail(genealogyId, memoId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedMemoId = normalizeResourcePathId(memoId, '备忘标识')
requireRemoteLifeRecord('备忘详情需要真实读取服务,当前本地预览不会伪造详情', 'REMOTE_READ_REQUIRED')
const memo = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/memos/${normalizedMemoId}`,
method: 'GET'
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppMemo(memo, normalizedGenealogyId, normalizedMemoId)
},
async createMeritRecord(genealogyId, payload, requestOptions = {}) {
requireRemoteLifeRecord('功德记录创建需要真实服务,当前本地预览不会伪造创建成功', 'REMOTE_WRITE_REQUIRED')
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/merit-records`,
method: 'POST',
data: normalizeMeritRecordCreatePayload(payload)
}, { requestController: requestOptions.requestController ?? null })
},
async updateMeritRecord(genealogyId, meritId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedMeritId = normalizeResourcePathId(meritId, '功德记录标识')
requireRemoteLifeRecord('修改功德记录需要真实服务,当前本地预览不会伪造保存成功', 'REMOTE_WRITE_REQUIRED')
const meritRecord = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/merit-records/${normalizedMeritId}`,
method: 'PUT',
data: normalizeMeritRecordCreatePayload(payload)
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppMeritRecord(meritRecord, normalizedGenealogyId, normalizedMeritId)
},
async deleteMeritRecord(genealogyId, meritId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedMeritId = normalizeResourcePathId(meritId, '功德记录标识')
return deleteLifeRecord(`/genealogy/app/genealogies/${normalizedGenealogyId}/merit-records/${normalizedMeritId}`, '删除功德记录', requestOptions)
},
async getMeritRecords(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
requireRemoteLifeRecord('功德记录需要真实读取服务,当前本地预览不会伪造列表', 'REMOTE_READ_REQUIRED')
const meritRecords = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/merit-records`,
method: 'GET'
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppMeritRecords(meritRecords, normalizedGenealogyId)
},
async getMeritRecordDetail(genealogyId, meritId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedMeritId = normalizeResourcePathId(meritId, '功德记录标识')
requireRemoteLifeRecord('功德记录详情需要真实读取服务,当前本地预览不会伪造详情', 'REMOTE_READ_REQUIRED')
const meritRecord = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/merit-records/${normalizedMeritId}`,
method: 'GET'
}, { requestController: requestOptions.requestController ?? null })
return normalizeAppMeritRecord(meritRecord, normalizedGenealogyId, normalizedMeritId)
}
}
+224
View File
@@ -0,0 +1,224 @@
import { normalizeOptionalSafeInteger } from './request-normalizers.js'
import { createRequestError } from './request-client.js'
import { LINEAGE_PERSON_OPTIONS } from './lineage-person-options.js'
const optionLabels = (options) => Object.freeze(
Object.fromEntries(options.map(({ value, label }) => [value, label]))
)
const lineagePersonError = (message) =>
createRequestError(message, 'LINEAGE_PERSON_RESPONSE_INVALID')
export const normalizeLineagePersonIdentity = (value, label) => {
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value)
throw lineagePersonError(`成员详情${label}无效`)
}
const normalizeOptionalLineagePersonIdentity = (value, label) => {
if (value === undefined || value === null || value === '') return ''
return normalizeLineagePersonIdentity(value, label)
}
export const normalizeLineagePersonText = (value, label, { required = false } = {}) => {
if (value === undefined || value === null) {
if (required) throw lineagePersonError(`成员详情缺少${label}`)
return ''
}
if (typeof value !== 'string') throw lineagePersonError(`成员详情${label}无效`)
const normalized = value.trim()
if (required && !normalized) throw lineagePersonError(`成员详情缺少${label}`)
return normalized
}
const normalizeLineageSpouseNames = (value) =>
normalizeLineagePersonText(value, '配偶姓名')
.replace(/\s*[(]P\d{15,}[)]/g, '')
.trim()
const LINEAGE_PERSON_SEX_LABELS = optionLabels(LINEAGE_PERSON_OPTIONS.sex)
const LINEAGE_PERSON_STATUS_LABELS = optionLabels(
LINEAGE_PERSON_OPTIONS.personStatus
)
const LINEAGE_BOOLEAN_LABELS = optionLabels(LINEAGE_PERSON_OPTIONS.lunar)
const lineageBindingModes = new Set(
LINEAGE_PERSON_OPTIONS.bindingMode.map(({ value }) => value)
)
const normalizeLineagePersonDictionaryLabel = (value, label, labels) => {
const normalized = normalizeLineagePersonText(value, label)
if (!normalized) return ''
const display = labels[normalized]
if (!display) throw lineagePersonError(`成员详情${label}字典值无效`)
return display
}
export const normalizeLineagePersonDate = (value, label) => {
const normalized = normalizeLineagePersonText(value, label)
if (!normalized) return ''
if (!/^\d{4}-\d{2}-\d{2}(?:[T\s].*)?$/.test(normalized)) {
throw lineagePersonError(`成员详情${label}无效`)
}
const datePart = normalized.slice(0, 10)
const parsed = new Date(`${datePart}T00:00:00Z`)
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== datePart) {
throw lineagePersonError(`成员详情${label}无效`)
}
return datePart
}
export const normalizeLineagePersonDetail = (value, expectedGenealogyId, expectedPersonId) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw lineagePersonError('成员详情响应不是对象')
}
const genealogyId = normalizeLineagePersonIdentity(value.genealogyId, '家谱标识')
const id = normalizeLineagePersonIdentity(value.personId, '人物标识')
if (genealogyId !== expectedGenealogyId || id !== expectedPersonId) {
throw lineagePersonError('成员详情响应标识与请求不匹配')
}
if (!Number.isSafeInteger(value.generation) || value.generation < 1) {
throw lineagePersonError('成员详情世代无效')
}
const name = normalizeLineagePersonText(value.name, '姓名', { required: true })
const appUserId = normalizeOptionalLineagePersonIdentity(value.appUserId, '绑定用户标识')
const bindingMode = value.bindingMode === undefined || value.bindingMode === null || value.bindingMode === ''
? (appUserId ? 'SPECIFIED' : 'NONE')
: normalizeLineagePersonText(value.bindingMode, '身份认领方式')
if (!lineageBindingModes.has(bindingMode)) {
throw lineagePersonError('成员详情身份认领方式无效')
}
const personNo = normalizeLineagePersonText(value.personNo, '人物编号')
const generationName = normalizeLineagePersonText(value.generationName, '字辈')
const aliasName = normalizeLineagePersonText(value.aliasName, '别名或曾用名')
const avatarOssId = normalizeOptionalLineagePersonIdentity(value.avatarOssId, '头像文件标识')
const birthDate = normalizeLineagePersonDate(value.birthDate, '出生日期')
const deathDate = normalizeLineagePersonDate(value.deathDate, '逝世日期')
const birthLunar = normalizeLineagePersonText(value.birthLunar, '出生农历')
const deathLunar = normalizeLineagePersonText(value.deathLunar, '逝世农历')
const birthPlace = normalizeLineagePersonText(value.birthPlace, '出生地')
const deathPlace = normalizeLineagePersonText(value.deathPlace, '逝世地')
const burialPlace = normalizeLineagePersonText(value.burialPlace, '安葬地')
const spouseNames = normalizeLineageSpouseNames(value.spouseNames)
const personStatus = normalizeLineagePersonText(value.personStatus, '人物状态')
const sex = normalizeLineagePersonText(value.sex, '性别')
const sexLabel = normalizeLineagePersonDictionaryLabel(sex, '性别', LINEAGE_PERSON_SEX_LABELS)
const personStatusLabel = normalizeLineagePersonDictionaryLabel(personStatus, '人物状态', LINEAGE_PERSON_STATUS_LABELS)
const birthLunarLabel = normalizeLineagePersonDictionaryLabel(birthLunar, '出生农历', LINEAGE_BOOLEAN_LABELS)
const deathLunarLabel = normalizeLineagePersonDictionaryLabel(deathLunar, '逝世农历', LINEAGE_BOOLEAN_LABELS)
const biography = normalizeLineagePersonText(value.biography, '生平')
const remark = normalizeLineagePersonText(value.remark, '备注')
const relationName = normalizeLineagePersonText(value.relationName, '关系显示名称')
for (const field of ['canDisable', 'canCreateDocument', 'canManageDocuments']) {
if (value[field] !== undefined && typeof value[field] !== 'boolean') {
throw lineagePersonError(`成员详情权限字段 ${field} 无效`)
}
}
const sortOrder = value.sortOrder === undefined || value.sortOrder === null
? null
: normalizeOptionalSafeInteger(value.sortOrder, '人物排序值')
const relatives = []
for (const relation of [
{ id: value.fatherId, name: value.fatherName, label: '父亲' },
{ id: value.motherId, name: value.motherName, label: '母亲' }
]) {
if (relation.id === undefined || relation.id === null) continue
const relativeId = normalizeLineagePersonIdentity(relation.id, `${relation.label}标识`)
if (relativeId === id || relatives.some((relative) => relative.id === relativeId)) {
throw lineagePersonError('成员详情亲属标识冲突')
}
relatives.push({
id: relativeId,
name: normalizeLineagePersonText(relation.name, `${relation.label}姓名`) || relation.label,
relation: relation.label
})
}
return {
id,
genealogyId,
genealogyName: normalizeLineagePersonText(value.genealogyName, '家谱名称') || '当前家谱',
name,
appUserId,
bindingMode,
personNo,
aliasName,
generation: value.generation,
generationName,
avatarOssId,
relation: value.generation === 1 ? '始祖' : '家谱成员',
branch: generationName
? (generationName.endsWith('字辈') ? generationName : `${generationName}字辈`)
: '字辈待补',
sex,
sexLabel,
birthDate,
birthLunar,
birthLunarLabel,
deathDate,
deathLunar,
deathLunarLabel,
years: birthDate || deathDate ? `${birthDate}${deathDate}` : '生卒待补',
birthplace: birthPlace,
deathPlace,
burialPlace,
spouseNames,
personStatus,
personStatusLabel,
biography,
remark,
relationName,
sortOrder,
status: personStatus === '1' ? 'deceased' : 'normal',
canDisable: value.canDisable === true,
disabledReason: normalizeLineagePersonText(value.disabledReason, '不可停用原因'),
canCreateDocument: value.canCreateDocument === true,
canManageDocuments: value.canManageDocuments === true,
relatives
}
}
export const normalizeLineagePersonPage = (value, expectedGenealogyId) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw lineagePersonError('成员分页响应不是对象')
}
if (!Array.isArray(value.rows)) throw lineagePersonError('成员分页缺少 rows')
if (!Number.isSafeInteger(value.total) || value.total < 0) {
throw lineagePersonError('成员分页 total 无效')
}
const rows = value.rows.map((item) =>
normalizeLineagePersonDetail(
item,
expectedGenealogyId,
normalizeLineagePersonIdentity(item?.personId, '人物标识')
))
if (new Set(rows.map((person) => person.id)).size !== rows.length) {
throw lineagePersonError('成员分页包含重复人物标识')
}
return { rows, total: value.total }
}
export const normalizeLineagePersonOptions = (value, expectedGenealogyId) => {
if (!Array.isArray(value)) throw lineagePersonError('世系人物选项响应不是列表')
const options = value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw lineagePersonError('世系人物选项包含无效条目')
}
const id = normalizeLineagePersonIdentity(item.personId, '人物标识')
const genealogyId = normalizeLineagePersonIdentity(item.genealogyId, '家谱标识')
if (genealogyId !== expectedGenealogyId) {
throw lineagePersonError('世系人物选项归属与请求不匹配')
}
return {
id,
name: normalizeLineagePersonText(item.name, '姓名', { required: true }),
personNo: normalizeLineagePersonText(item.personNo, '人物编号'),
generation: Number.isSafeInteger(item.generation) && item.generation > 0
? item.generation
: null,
generationName: normalizeLineagePersonText(item.generationName, '字辈')
}
})
if (new Set(options.map((person) => person.id)).size !== options.length) {
throw lineagePersonError('世系人物选项包含重复人物标识')
}
return options
}
+42
View File
@@ -0,0 +1,42 @@
const createFrozenOptions = (options) =>
Object.freeze(options.map((option) => Object.freeze(option)))
export const LINEAGE_RELATION_TYPE = Object.freeze({
FATHER: 'FATHER',
MOTHER: 'MOTHER',
SPOUSE: 'SPOUSE',
SIBLING: 'SIBLING',
SON: 'SON',
DAUGHTER: 'DAUGHTER'
})
export const LINEAGE_RELATION_OPTIONS = createFrozenOptions([
{ label: '父亲', value: LINEAGE_RELATION_TYPE.FATHER },
{ label: '母亲', value: LINEAGE_RELATION_TYPE.MOTHER },
{ label: '配偶', value: LINEAGE_RELATION_TYPE.SPOUSE },
{ label: '兄弟姐妹', value: LINEAGE_RELATION_TYPE.SIBLING },
{ label: '儿子', value: LINEAGE_RELATION_TYPE.SON },
{ label: '女儿', value: LINEAGE_RELATION_TYPE.DAUGHTER }
])
export const LINEAGE_PERSON_OPTIONS = Object.freeze({
sex: createFrozenOptions([
{ label: '男', value: '0' },
{ label: '女', value: '1' },
{ label: '未知', value: '2' }
]),
lunar: createFrozenOptions([
{ label: '否', value: '0' },
{ label: '是', value: '1' }
]),
personStatus: createFrozenOptions([
{ label: '健在', value: '0' },
{ label: '已故', value: '1' },
{ label: '未知', value: '2' }
]),
bindingMode: createFrozenOptions([
{ label: '不绑定账号', value: 'NONE' },
{ label: '绑定我的账号', value: 'SELF' },
{ label: '绑定指定成员', value: 'SPECIFIED' }
])
})
+176
View File
@@ -0,0 +1,176 @@
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import { normalizeGenealogyPathId } from './genealogy-contract.js'
import {
normalizeLineagePersonDetail,
normalizeLineagePersonIdentity,
normalizeLineagePersonOptions,
normalizeLineagePersonPage
} from './lineage-person-contract.js'
import { normalizeLineageTree } from './lineage-tree-contract.js'
import {
lineageRelationPath,
normalizeLineageWritePayload
} from './lineage-write-contract.js'
import { normalizeResourcePathId } from './request-normalizers.js'
import { createRequestError, requestStrict } from './request-client.js'
const requireRemoteLineage = (message, code) => {
if (hasRemoteConfig()) return
throw createRequestError(message, code)
}
export const lineageApi = {
async getTree(genealogyId, requestOptions = {}) {
requireRemoteLineage(
'世系树需要真实读取服务,当前本地预览不会伪造人物节点',
'REMOTE_READ_REQUIRED'
)
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const tree = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage/tree`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeLineageTree(tree)
},
async getPerson(genealogyId, personId, requestOptions = {}) {
requireRemoteLineage(
'人物详情需要真实读取服务,当前本地预览不会伪造成员资料',
'REMOTE_READ_REQUIRED'
)
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedPersonId = normalizeLineagePersonIdentity(personId, '人物标识')
const person = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage/persons/${normalizedPersonId}`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeLineagePersonDetail(person, normalizedGenealogyId, normalizedPersonId)
},
async getPersonPage(genealogyId, { pageNum = 1, pageSize = 10, keyword = '' } = {}, requestOptions = {}) {
requireRemoteLineage(
'成员目录需要真实读取服务,当前本地预览不会伪造目录数据',
'REMOTE_READ_REQUIRED'
)
if (!Number.isSafeInteger(pageNum) || pageNum < 1) throw new TypeError('成员目录页码无效')
if (!Number.isSafeInteger(pageSize) || pageSize < 1) throw new TypeError('成员目录页大小无效')
if (typeof keyword !== 'string') throw new TypeError('成员目录关键词无效')
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const page = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage/persons/page`,
method: 'GET',
data: { pageNum, pageSize, ...(keyword.trim() ? { keyword: keyword.trim() } : {}) }
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return normalizeLineagePersonPage(page, normalizedGenealogyId)
},
async createPerson(genealogyId, payload, requestOptions = {}) {
requireRemoteLineage(
'人物创建接口在本地预览模式不可用,当前内容不会保存',
'WRITE_UNAVAILABLE'
)
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage/persons`,
method: 'POST',
data: normalizeLineageWritePayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
},
async createRelatedPerson(genealogyId, personId, relationType, payload, requestOptions = {}) {
requireRemoteLineage(
'人物关系写入接口在本地预览模式不可用,当前内容不会保存',
'WRITE_UNAVAILABLE'
)
const relationPath = lineageRelationPath[relationType]
if (!relationPath) throw new TypeError('人物关系类型不属于当前合同')
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedPersonId = normalizeLineagePersonIdentity(personId, '人物标识')
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage/persons/${normalizedPersonId}/${relationPath}`,
method: 'POST',
data: normalizeLineageWritePayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
},
async updatePerson(genealogyId, personId, payload, requestOptions = {}) {
requireRemoteLineage(
'人物编辑接口在本地预览模式不可用,当前内容不会保存',
'WRITE_UNAVAILABLE'
)
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedPersonId = normalizeLineagePersonIdentity(personId, '人物标识')
return requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage/persons/${normalizedPersonId}`,
method: 'PUT',
data: normalizeLineageWritePayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
},
async deletePerson(genealogyId, personId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedPersonId = normalizeResourcePathId(personId, '人物标识')
requireRemoteLineage(
'世系人物写入需要真实服务,当前本地预览不会伪造结果',
'REMOTE_WRITE_REQUIRED'
)
await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage/persons/${normalizedPersonId}`,
method: 'DELETE'
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
},
async updatePersonSortOrder(genealogyId, personId, sortOrder, requestOptions = {}) {
requireRemoteLineage(
'排行调整接口在本地预览模式不可用,当前内容不会保存',
'WRITE_UNAVAILABLE'
)
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedPersonId = normalizeLineagePersonIdentity(personId, '人物标识')
const normalizedSortOrder = Number(sortOrder)
if (!Number.isSafeInteger(normalizedSortOrder)) throw new TypeError('排序值必须是整数')
const person = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage/persons/${normalizedPersonId}/sort-order`,
method: 'PUT',
data: { sortOrder: normalizedSortOrder }
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeLineagePersonDetail(person, normalizedGenealogyId, normalizedPersonId)
},
async getLineagePersonOptions(genealogyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
requireRemoteLineage(
'世系人物选项需要真实读取服务,当前本地预览不会伪造候选项',
'REMOTE_READ_REQUIRED'
)
const options = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage/persons/options`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeLineagePersonOptions(options, normalizedGenealogyId)
}
}
+119
View File
@@ -0,0 +1,119 @@
import { createRequestError } from './request-client.js'
const lineageTreeError = (message) =>
createRequestError(message, 'LINEAGE_TREE_RESPONSE_INVALID')
const normalizeLineagePersonId = (value) => {
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value)
throw lineageTreeError('世系树包含无效人物标识')
}
const normalizeLineageText = (value, label, { required = false } = {}) => {
if (value === undefined || value === null) {
if (required) throw lineageTreeError(`世系树缺少${label}`)
return ''
}
if (typeof value !== 'string') throw lineageTreeError(`世系树${label}无效`)
const normalized = value.trim()
if (required && !normalized) throw lineageTreeError(`世系树缺少${label}`)
return normalized
}
const lineageDatePart = (value, label) => {
const normalized = normalizeLineageText(value, label)
if (!normalized) return ''
if (!/^\d{4}-\d{2}-\d{2}/.test(normalized)) {
throw lineageTreeError(`世系树${label}无效`)
}
if (normalized.length === 10) return normalized
const instant = new Date(normalized)
if (Number.isNaN(instant.getTime())) throw lineageTreeError(`世系树${label}无效`)
const chinaTime = new Date(instant.getTime() + 8 * 60 * 60 * 1000)
const year = chinaTime.getUTCFullYear()
const month = String(chinaTime.getUTCMonth() + 1).padStart(2, '0')
const day = String(chinaTime.getUTCDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const compactLineageYears = (birthDate, deathDate) => {
const birthYear = birthDate ? birthDate.slice(0, 4) : ''
const deathYear = deathDate ? deathDate.slice(0, 4) : ''
return birthYear || deathYear ? `${birthYear}${deathYear}` : '生卒待补'
}
export const normalizeLineageTree = (value) => {
if (!Array.isArray(value)) throw lineageTreeError('世系树响应不是列表')
const seen = new Set()
const normalized = []
const normalizedById = new Map()
const appendNode = (node, parentId, relationOverride = '', spouseOf = '') => {
if (!node || typeof node !== 'object' || Array.isArray(node)) {
throw lineageTreeError('世系树包含无效节点')
}
const id = normalizeLineagePersonId(node.personId)
if (seen.has(id)) throw lineageTreeError('世系树包含重复人物标识')
seen.add(id)
if (seen.size > 5000) throw lineageTreeError('世系树节点数量超出客户端上限')
if (!Number.isSafeInteger(node.generation) || node.generation < 1) {
throw lineageTreeError('世系树人物世代无效')
}
const generationName = normalizeLineageText(node.generationName, '字辈')
const relationName = normalizeLineageText(node.relationName, '人物关系')
const birthDate = lineageDatePart(node.birthDate, '出生日期')
const deathDate = lineageDatePart(node.deathDate, '逝世日期')
const normalizedNode = {
id,
parentId,
...(spouseOf ? { spouseOf } : {}),
name: normalizeLineageText(node.name, '人物姓名', { required: true }),
relation:
relationOverride ||
(parentId
? (relationName && relationName !== '配偶' ? relationName : '后代')
: relationName || '始祖'),
generation: node.generation,
branch: generationName
? (generationName.endsWith('字辈') ? generationName : `${generationName}字辈`)
: '字辈待补',
years: birthDate || deathDate ? `${birthDate}${deathDate}` : '生卒待补',
treeYears: compactLineageYears(birthDate, deathDate),
sex: normalizeLineageText(node.sex, '性别'),
personStatus: normalizeLineageText(node.personStatus, '人物状态')
}
normalized.push(normalizedNode)
normalizedById.set(id, normalizedNode)
return id
}
const walk = (node, parentId = null, depth = 0) => {
if (depth > 64) throw lineageTreeError('世系树深度超出客户端上限')
if (!node || typeof node !== 'object' || Array.isArray(node)) {
throw lineageTreeError('世系树包含无效节点')
}
const spouses = node.spouses ?? []
const children = node.children ?? []
if (!Array.isArray(spouses) || !Array.isArray(children)) {
throw lineageTreeError('世系树亲属集合无效')
}
const id = normalizeLineagePersonId(node.personId)
const knownSpouseId = spouses
.map((spouse) => normalizeLineagePersonId(spouse?.personId))
.find((spouseId) => normalizedById.has(spouseId)) || ''
if (seen.has(id)) {
if (knownSpouseId) return id
throw lineageTreeError('世系树包含重复人物标识')
}
const knownSpouse = knownSpouseId ? normalizedById.get(knownSpouseId) : null
const nodeParentId = knownSpouse ? knownSpouse.parentId : parentId
const appendedId = appendNode(node, nodeParentId, knownSpouse ? '配偶' : '', knownSpouseId)
spouses.forEach((spouse) => {
const spouseId = normalizeLineagePersonId(spouse?.personId)
if (seen.has(spouseId)) return
appendNode(spouse, nodeParentId, '配偶', appendedId)
})
children.forEach((child) => walk(child, knownSpouseId || appendedId, depth + 1))
}
value.forEach((root) => walk(root))
return normalized
}
+127
View File
@@ -0,0 +1,127 @@
import { assertPlainPayload, normalizeOssIdString } from './request-normalizers.js'
import {
LINEAGE_PERSON_OPTIONS,
LINEAGE_RELATION_TYPE
} from './lineage-person-options.js'
import {
normalizeLineagePersonDate,
normalizeLineagePersonIdentity,
normalizeLineagePersonText
} from './lineage-person-contract.js'
const lineageWriteOptionValues = Object.freeze({
bindingMode: new Set(LINEAGE_PERSON_OPTIONS.bindingMode.map(({ value }) => value)),
sex: new Set(LINEAGE_PERSON_OPTIONS.sex.map(({ value }) => value)),
birthLunar: new Set(LINEAGE_PERSON_OPTIONS.lunar.map(({ value }) => value)),
deathLunar: new Set(LINEAGE_PERSON_OPTIONS.lunar.map(({ value }) => value)),
personStatus: new Set(LINEAGE_PERSON_OPTIONS.personStatus.map(({ value }) => value))
})
export const normalizeLineageWritePayload = (payload) => {
const allowedFields = new Set([
'bindingMode',
'appUserId',
'personNo',
'name',
'aliasName',
'sex',
'generationName',
'fatherId',
'motherId',
'avatarOssId',
'birthDate',
'birthLunar',
'birthPlace',
'deathDate',
'deathLunar',
'deathPlace',
'burialPlace',
'biography',
'remark',
'relationName',
'generation',
'personStatus',
'sortOrder'
])
assertPlainPayload(payload, allowedFields, '人物写入请求')
const name = normalizeLineagePersonText(payload.name, '姓名', { required: true })
if (name.length > 20) throw new TypeError('人物姓名长度超出当前页面合同')
const bindingMode = normalizeLineagePersonText(payload.bindingMode, '身份认领方式')
if (!lineageWriteOptionValues.bindingMode.has(bindingMode)) {
throw new TypeError('人物身份认领方式必须是 NONE、SELF 或 SPECIFIED')
}
const normalizedPayload = { bindingMode, name }
if (bindingMode === 'SPECIFIED') {
if (payload.appUserId === undefined || payload.appUserId === null || payload.appUserId === '') {
throw new TypeError('指定业务用户绑定必须提供业务用户标识')
}
normalizedPayload.appUserId = normalizeLineagePersonIdentity(payload.appUserId, '绑定用户标识')
} else if (payload.appUserId !== undefined && payload.appUserId !== null && payload.appUserId !== '') {
throw new TypeError(`${bindingMode} 身份认领不能提供业务用户标识`)
}
for (const [field, label] of [
['fatherId', '父亲标识'],
['motherId', '母亲标识']
]) {
if (payload[field] === undefined || payload[field] === null || payload[field] === '') continue
normalizedPayload[field] = normalizeLineagePersonIdentity(payload[field], label)
}
if (payload.avatarOssId !== undefined && payload.avatarOssId !== null && payload.avatarOssId !== '') {
normalizedPayload.avatarOssId = normalizeOssIdString(payload.avatarOssId, '头像文件标识')
}
if (payload.generation !== undefined) {
if (!Number.isSafeInteger(payload.generation) || payload.generation < 1) {
throw new TypeError('人物世代必须是正安全整数')
}
normalizedPayload.generation = payload.generation
}
for (const [field, label, maxLength] of [
['personNo', '人物编号', null],
['sex', '性别', null],
['generationName', '字辈', 12],
['aliasName', '别名或曾用名', null],
['birthLunar', '出生农历', null],
['birthPlace', '出生地', null],
['deathLunar', '逝世农历', null],
['deathPlace', '逝世地', null],
['burialPlace', '安葬地', null],
['biography', '人物简介', 500],
['remark', '备注', null],
['relationName', '关系显示名称', null],
['personStatus', '人物状态', null]
]) {
if (payload[field] === undefined) continue
const normalizedText = normalizeLineagePersonText(payload[field], label)
if (maxLength && normalizedText.length > maxLength) throw new TypeError(`人物${label}长度超出当前页面合同`)
if (normalizedText && lineageWriteOptionValues[field] && !lineageWriteOptionValues[field].has(normalizedText)) {
throw new TypeError(`人物${label}字典值无效`)
}
if (normalizedText) normalizedPayload[field] = normalizedText
}
for (const [field, label] of [
['birthDate', '出生日期'],
['deathDate', '离世日期']
]) {
if (payload[field] === undefined) continue
const normalizedDate = normalizeLineagePersonDate(payload[field], label)
if (normalizedDate) normalizedPayload[field] = normalizedDate
}
if (normalizedPayload.birthDate && normalizedPayload.deathDate && normalizedPayload.deathDate < normalizedPayload.birthDate) {
throw new TypeError('人物离世日期不能早于出生日期')
}
if (payload.sortOrder !== undefined && payload.sortOrder !== null && payload.sortOrder !== '') {
const sortOrder = typeof payload.sortOrder === 'number' ? payload.sortOrder : Number(payload.sortOrder)
if (!Number.isSafeInteger(sortOrder)) throw new TypeError('人物排序值必须是安全整数')
normalizedPayload.sortOrder = sortOrder
}
return normalizedPayload
}
export const lineageRelationPath = Object.freeze({
[LINEAGE_RELATION_TYPE.FATHER]: 'parents',
[LINEAGE_RELATION_TYPE.MOTHER]: 'parents',
[LINEAGE_RELATION_TYPE.SPOUSE]: 'spouses',
[LINEAGE_RELATION_TYPE.SIBLING]: 'siblings',
[LINEAGE_RELATION_TYPE.SON]: 'children',
[LINEAGE_RELATION_TYPE.DAUGHTER]: 'children'
})
+8
View File
@@ -0,0 +1,8 @@
export const NORMAL_DISABLE_STATUS = Object.freeze({
NORMAL: '0',
DISABLED: '1'
})
const normalDisableStatuses = new Set(Object.values(NORMAL_DISABLE_STATUS))
export const isNormalDisableStatus = (value) => normalDisableStatuses.has(value)
+79
View File
@@ -0,0 +1,79 @@
import { createRequestError } from './request-client.js'
import {
normalizeNormalDisableResponseStatus,
normalizeOptionalNumericId
} from './response-normalizers.js'
const normalizeNotificationText = (value, field) => {
if (value === undefined || value === null) return ''
if (typeof value !== 'string') {
throw createRequestError(`通知详情字段 ${field} 无效`, 'NOTIFICATION_DETAIL_RESPONSE_INVALID')
}
return value
}
const NOTIFICATION_TYPE_LABELS = Object.freeze({
join_apply: '加入申请',
memo_reminder: '备忘提醒',
family_feed: '家族圈互动',
CEREMONY_INVITE: '礼仪邀约'
})
const notificationTypes = new Set(Object.keys(NOTIFICATION_TYPE_LABELS))
const notificationTypeLabel = (noticeType) => NOTIFICATION_TYPE_LABELS[noticeType] || '通知'
export const normalizeNotificationDetail = (value, expectedNotificationId = '') => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('通知详情响应格式无效', 'NOTIFICATION_DETAIL_RESPONSE_INVALID')
}
const notificationId = normalizeOptionalNumericId(value.notificationId, '通知标识', 'NOTIFICATION_DETAIL_RESPONSE_INVALID')
if (!notificationId || (expectedNotificationId && notificationId !== expectedNotificationId)) {
throw createRequestError('通知详情响应标识不匹配', 'NOTIFICATION_DETAIL_RESPONSE_INVALID')
}
const noticeType = normalizeNotificationText(value.noticeType, 'noticeType')
if (!notificationTypes.has(noticeType)) {
throw createRequestError('通知详情字段 noticeType 无效', 'NOTIFICATION_DETAIL_RESPONSE_INVALID')
}
const noticeTypeLabel = normalizeNotificationText(value.noticeTypeLabel, 'noticeTypeLabel').trim()
return {
notificationId,
genealogyId: normalizeOptionalNumericId(value.genealogyId, '通知家谱标识', 'NOTIFICATION_DETAIL_RESPONSE_INVALID'),
genealogyNo: normalizeNotificationText(value.genealogyNo, 'genealogyNo'),
genealogyName: normalizeNotificationText(value.genealogyName, 'genealogyName'),
senderUserId: normalizeOptionalNumericId(value.senderUserId, '通知发送人标识', 'NOTIFICATION_DETAIL_RESPONSE_INVALID'),
senderNickName: normalizeNotificationText(value.senderNickName, 'senderNickName'),
noticeType,
noticeTypeLabel: noticeTypeLabel || notificationTypeLabel(noticeType),
noticeTitle: normalizeNotificationText(value.noticeTitle, 'noticeTitle'),
noticeContent: normalizeNotificationText(value.noticeContent, 'noticeContent'),
bizType: normalizeNotificationText(value.bizType, 'bizType'),
bizId: normalizeOptionalNumericId(value.bizId, '通知关联业务标识', 'NOTIFICATION_DETAIL_RESPONSE_INVALID'),
bizSummary: normalizeNotificationText(value.bizSummary, 'bizSummary'),
publishTime: normalizeNotificationText(value.publishTime, 'publishTime'),
readStatus: (() => {
const readStatus = normalizeNotificationText(value.readStatus, 'readStatus')
if (!['0', '1'].includes(readStatus)) {
throw createRequestError('通知详情字段 readStatus 无效', 'NOTIFICATION_DETAIL_RESPONSE_INVALID')
}
return readStatus
})(),
readTime: normalizeNotificationText(value.readTime, 'readTime'),
status: normalizeNormalDisableResponseStatus(
value.status,
'通知状态',
'NOTIFICATION_DETAIL_RESPONSE_INVALID'
),
remark: normalizeNotificationText(value.remark, 'remark')
}
}
export const normalizeNotifications = (value) => {
if (!Array.isArray(value)) {
throw createRequestError('通知列表响应不是列表', 'NOTIFICATION_LIST_RESPONSE_INVALID')
}
const notifications = value.map((notification) => normalizeNotificationDetail(notification))
if (new Set(notifications.map((notification) => notification.notificationId)).size !== notifications.length) {
throw createRequestError('通知列表响应包含重复标识', 'NOTIFICATION_LIST_RESPONSE_INVALID')
}
return notifications
}
+82
View File
@@ -0,0 +1,82 @@
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import {
normalizeNotificationDetail,
normalizeNotifications
} from './notification-contract.js'
import { normalizeResourcePathId } from './request-normalizers.js'
import {
createRequestError,
requestStrict
} from './request-client.js'
const requireRemoteNotifications = (label, operation = '读取') => {
if (hasRemoteConfig()) return
throw createRequestError(
`${label}${operation}需要真实服务,当前本地预览不会伪造结果`,
operation === '读取' ? 'REMOTE_READ_REQUIRED' : 'REMOTE_WRITE_REQUIRED'
)
}
export const notificationApi = {
async getNotifications(requestOptions = {}) {
requireRemoteNotifications('消息通知')
const notificationRows = await requestStrict({
url: '/genealogy/app/notifications',
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeNotifications(notificationRows)
},
async getNotificationDetail(notificationId, requestOptions = {}) {
const normalizedNotificationId = normalizeResourcePathId(notificationId, '通知标识')
requireRemoteNotifications('通知详情')
const notificationDetail = await requestStrict({
url: `/genealogy/app/notifications/${normalizedNotificationId}`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeNotificationDetail(notificationDetail, normalizedNotificationId)
},
async getUnreadNotificationCount(requestOptions = {}) {
requireRemoteNotifications('未读通知数量')
const unreadCount = await requestStrict({
url: '/genealogy/app/notifications/unread-count',
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
if (!Number.isSafeInteger(unreadCount) || unreadCount < 0) {
throw createRequestError('未读通知数量响应无效', 'NOTIFICATION_COUNT_RESPONSE_INVALID')
}
return unreadCount
},
async markNotificationRead(notificationId, requestOptions = {}) {
const normalizedNotificationId = normalizeResourcePathId(notificationId, '通知标识')
requireRemoteNotifications('通知已读状态', '写入')
await requestStrict({
url: `/genealogy/app/notifications/${normalizedNotificationId}/read`,
method: 'POST'
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
},
async markAllNotificationsRead(requestOptions = {}) {
requireRemoteNotifications('全部通知已读状态', '写入')
await requestStrict({
url: '/genealogy/app/notifications/read-all',
method: 'POST'
}, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
}
}
+150
View File
@@ -0,0 +1,150 @@
import { createRequestError } from './request-client.js'
import {
normalizeOptionalNumericId,
normalizeOptionalResponseText
} from './response-normalizers.js'
import {
assertPlainPayload,
normalizeOptionalNormalDisableStatus,
normalizeOptionalText,
normalizeResourcePathId
} from './request-normalizers.js'
const normalizePersonDocumentText = (value, field) =>
normalizeOptionalResponseText(value, field, '重要证件响应', 'PERSON_DOCUMENT_RESPONSE_INVALID')
export const PERSON_DOCUMENT_TYPE_OPTIONS = Object.freeze([
Object.freeze({ value: 'id_card', label: '身份证' }),
Object.freeze({ value: 'household_register', label: '户口簿' }),
Object.freeze({ value: 'birth_certificate', label: '出生证明' }),
Object.freeze({ value: 'marriage_certificate', label: '结婚证' }),
Object.freeze({ value: 'other', label: '其他证件' })
])
export const PERSON_DOCUMENT_RESOURCE_USAGE = Object.freeze({
FRONT: 'FRONT',
BACK: 'BACK',
ATTACHMENT: 'ATTACHMENT'
})
export const PERSON_DOCUMENT_RESOURCE_USAGE_LABELS = Object.freeze({
[PERSON_DOCUMENT_RESOURCE_USAGE.FRONT]: '正面',
[PERSON_DOCUMENT_RESOURCE_USAGE.BACK]: '反面',
[PERSON_DOCUMENT_RESOURCE_USAGE.ATTACHMENT]: '附件'
})
const personDocumentTypes = new Set(
PERSON_DOCUMENT_TYPE_OPTIONS.map(({ value }) => value)
)
const personDocumentResourceUsages = new Set(
Object.values(PERSON_DOCUMENT_RESOURCE_USAGE)
)
export const normalizePersonDocumentResource = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('证件文件响应无效', 'PERSON_DOCUMENT_RESPONSE_INVALID')
}
const usageType = normalizePersonDocumentText(value.usageType, 'usageType')
if (!personDocumentResourceUsages.has(usageType)) {
throw createRequestError('证件文件用途无效', 'PERSON_DOCUMENT_RESPONSE_INVALID')
}
return {
resourceId: normalizeResourcePathId(value.resourceId, '证件文件标识'),
usageType,
sortOrder: Number.isSafeInteger(value.sortOrder) ? value.sortOrder : null,
status: normalizePersonDocumentText(value.status, 'status')
}
}
export const normalizePersonDocument = (value, expectedGenealogyId) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('重要证件响应无效', 'PERSON_DOCUMENT_RESPONSE_INVALID')
}
const genealogyId = normalizeResourcePathId(value.genealogyId, '家谱标识')
if (genealogyId !== expectedGenealogyId) {
throw createRequestError('重要证件归属与请求不匹配', 'PERSON_DOCUMENT_RESPONSE_INVALID')
}
const documentType = normalizePersonDocumentText(value.documentType, 'documentType')
if (!personDocumentTypes.has(documentType)) {
throw createRequestError('重要证件类型无效', 'PERSON_DOCUMENT_RESPONSE_INVALID')
}
const documentTitle = normalizePersonDocumentText(value.documentTitle, 'documentTitle')
if (!documentTitle) throw createRequestError('重要证件缺少标题', 'PERSON_DOCUMENT_RESPONSE_INVALID')
for (const field of ['contentProtected', 'contentUnlocked', 'canEdit', 'canDelete']) {
if (typeof value[field] !== 'boolean') {
throw createRequestError(`重要证件权限字段 ${field} 无效`, 'PERSON_DOCUMENT_RESPONSE_INVALID')
}
}
if (!Array.isArray(value.resources)) {
throw createRequestError('重要证件文件列表无效', 'PERSON_DOCUMENT_RESPONSE_INVALID')
}
return {
documentId: normalizeResourcePathId(value.documentId, '重要证件标识'),
genealogyId,
lineagePersonId: normalizeResourcePathId(value.lineagePersonId, '世系人物标识'),
lineagePersonName: normalizePersonDocumentText(value.lineagePersonName, 'lineagePersonName'),
documentType,
documentTitle,
maskedIdentifier: normalizePersonDocumentText(value.maskedIdentifier, 'maskedIdentifier'),
description: normalizePersonDocumentText(value.description, 'description'),
uploaderUserId: normalizeOptionalNumericId(value.uploaderUserId, '上传人标识', 'PERSON_DOCUMENT_RESPONSE_INVALID'),
sortOrder: Number.isSafeInteger(value.sortOrder) ? value.sortOrder : null,
status: normalizePersonDocumentText(value.status, 'status'),
contentProtected: value.contentProtected,
contentUnlocked: value.contentUnlocked,
canEdit: value.canEdit,
canDelete: value.canDelete,
resources: value.resources.map(normalizePersonDocumentResource)
}
}
export const normalizePersonDocuments = (value, expectedGenealogyId) => {
if (!Array.isArray(value)) throw createRequestError('重要证件响应不是列表', 'PERSON_DOCUMENT_RESPONSE_INVALID')
const rows = value.map((item) => normalizePersonDocument(item, expectedGenealogyId))
if (new Set(rows.map((item) => item.documentId)).size !== rows.length) {
throw createRequestError('重要证件包含重复标识', 'PERSON_DOCUMENT_RESPONSE_INVALID')
}
return rows
}
export const normalizePersonDocumentPayload = (payload) => {
assertPlainPayload(payload, new Set(['lineagePersonId', 'documentType', 'documentTitle', 'maskedIdentifier', 'description', 'sortOrder', 'status']), '重要证件请求')
const documentType = normalizeOptionalText(payload.documentType, 'documentType')
if (!personDocumentTypes.has(documentType)) throw new TypeError('请选择有效的证件类型')
const documentTitle = normalizeOptionalText(payload.documentTitle, 'documentTitle')
if (!documentTitle) throw new TypeError('请填写证件标题')
if (documentTitle.length > 100) throw new TypeError('证件标题不能超过100个字符')
const normalizedPayload = {
lineagePersonId: normalizeResourcePathId(payload.lineagePersonId, '世系人物标识'),
documentType,
documentTitle
}
for (const [field, limit] of [['maskedIdentifier', 100], ['description', 1000]]) {
if (!Object.prototype.hasOwnProperty.call(payload, field)) continue
if (typeof payload[field] !== 'string') throw new TypeError(`${field}必须是字符串`)
const normalizedText = payload[field].trim()
if (normalizedText.length > limit) throw new TypeError(`${field} 超出长度限制`)
normalizedPayload[field] = normalizedText
}
if (payload.sortOrder !== undefined && payload.sortOrder !== null && payload.sortOrder !== '') {
const sortOrder = typeof payload.sortOrder === 'number' ? payload.sortOrder : Number(payload.sortOrder)
if (!Number.isSafeInteger(sortOrder)) throw new TypeError('sortOrder必须是安全整数')
normalizedPayload.sortOrder = sortOrder
}
const status = normalizeOptionalNormalDisableStatus(payload.status, '证件状态')
if (status) normalizedPayload.status = status
return normalizedPayload
}
export const normalizePersonDocumentResourcePayload = (payload) => {
assertPlainPayload(payload, new Set(['ossId', 'usageType', 'sortOrder']), '证件文件请求')
if (!personDocumentResourceUsages.has(payload.usageType)) throw new TypeError('请选择有效的证件文件用途')
const ossId = normalizeResourcePathId(payload.ossId, 'OSS文件标识')
const normalizedPayload = { ossId, usageType: payload.usageType }
if (payload.sortOrder !== undefined && payload.sortOrder !== null && payload.sortOrder !== '') {
const sortOrder = typeof payload.sortOrder === 'number' ? payload.sortOrder : Number(payload.sortOrder)
if (!Number.isSafeInteger(sortOrder)) throw new TypeError('sortOrder必须是安全整数')
normalizedPayload.sortOrder = sortOrder
}
return normalizedPayload
}
+209
View File
@@ -0,0 +1,209 @@
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import {
normalizeBusinessFileAccess
} from './business-file-contract.js'
import { normalizeGenealogyPathId } from './genealogy-contract.js'
import {
normalizePersonDocument,
normalizePersonDocumentPayload,
normalizePersonDocumentResource,
normalizePersonDocumentResourcePayload,
normalizePersonDocuments
} from './person-document-contract.js'
import { assertPlainPayload, normalizeResourcePathId } from './request-normalizers.js'
import {
contentAccessHeader,
normalizeContentAccessGrant,
normalizeContentPasswordPayload
} from './protected-content-contract.js'
import { createRequestError, requestStrict } from './request-client.js'
const requireRemotePersonDocument = (operation) => {
if (hasRemoteConfig()) return
throw createRequestError(
`重要证件${operation}需要真实服务,当前本地预览不会伪造结果`,
operation === '读取' ? 'REMOTE_READ_REQUIRED' : 'REMOTE_WRITE_REQUIRED'
)
}
const writePersonDocument = (url, method, data, label, requestOptions) => {
if (!hasRemoteConfig()) {
throw createRequestError(
`${label}写入需要真实服务,当前本地预览不会伪造结果`,
'REMOTE_WRITE_REQUIRED'
)
}
return requestStrict({ url, method, data }, {
requestController: requestOptions.requestController ?? null
})
}
const deletePersonDocumentEndpoint = async (url, label, requestOptions) => {
if (!hasRemoteConfig()) {
throw createRequestError(
`${label}写入需要真实服务,当前本地预览不会伪造结果`,
'REMOTE_WRITE_REQUIRED'
)
}
await requestStrict({ url, method: 'DELETE' }, {
requireData: false,
requestController: requestOptions.requestController ?? null
})
return null
}
const normalizeDocumentIds = (genealogyId, documentId) => ({
genealogyId: normalizeGenealogyPathId(genealogyId),
documentId: normalizeResourcePathId(documentId, '重要证件标识')
})
export const personDocumentApi = {
async getPersonDocuments(genealogyId, query = {}, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
assertPlainPayload(query, new Set(['lineagePersonId']), '重要证件查询')
const queryParams = {}
if (query.lineagePersonId !== undefined && query.lineagePersonId !== null && query.lineagePersonId !== '') {
queryParams.lineagePersonId = normalizeResourcePathId(query.lineagePersonId, '世系人物标识')
}
requireRemotePersonDocument('读取')
const personDocuments = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/person-documents`,
method: 'GET',
data: queryParams
}, {
requestController: requestOptions.requestController ?? null
})
return normalizePersonDocuments(personDocuments, normalizedGenealogyId)
},
async getPersonDocument(genealogyId, documentId, accessToken = '', requestOptions = {}) {
const normalizedIds = normalizeDocumentIds(genealogyId, documentId)
const personDocument = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedIds.genealogyId}/person-documents/${normalizedIds.documentId}`,
method: 'GET',
header: contentAccessHeader(accessToken)
}, {
requestController: requestOptions.requestController ?? null
})
return normalizePersonDocument(personDocument, normalizedIds.genealogyId)
},
async createPersonDocument(genealogyId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const personDocument = await writePersonDocument(
`/genealogy/app/genealogies/${normalizedGenealogyId}/person-documents`,
'POST',
normalizePersonDocumentPayload(payload),
'重要证件',
requestOptions
)
return normalizePersonDocument(personDocument, normalizedGenealogyId)
},
async updatePersonDocument(genealogyId, documentId, payload, requestOptions = {}) {
const normalizedIds = normalizeDocumentIds(genealogyId, documentId)
const personDocument = await writePersonDocument(
`/genealogy/app/genealogies/${normalizedIds.genealogyId}/person-documents/${normalizedIds.documentId}`,
'PUT',
normalizePersonDocumentPayload(payload),
'重要证件',
requestOptions
)
return normalizePersonDocument(personDocument, normalizedIds.genealogyId)
},
async deletePersonDocument(genealogyId, documentId, requestOptions = {}) {
const normalizedIds = normalizeDocumentIds(genealogyId, documentId)
return deletePersonDocumentEndpoint(
`/genealogy/app/genealogies/${normalizedIds.genealogyId}/person-documents/${normalizedIds.documentId}`,
'重要证件',
requestOptions
)
},
async addPersonDocumentResource(genealogyId, documentId, payload, requestOptions = {}) {
const normalizedIds = normalizeDocumentIds(genealogyId, documentId)
const documentResource = await writePersonDocument(
`/genealogy/app/genealogies/${normalizedIds.genealogyId}/person-documents/${normalizedIds.documentId}/resources`,
'POST',
normalizePersonDocumentResourcePayload(payload),
'证件文件',
requestOptions
)
return normalizePersonDocumentResource(documentResource)
},
async updatePersonDocumentResource(genealogyId, documentId, resourceId, payload, requestOptions = {}) {
const normalizedIds = normalizeDocumentIds(genealogyId, documentId)
const normalizedResourceId = normalizeResourcePathId(resourceId, '证件文件标识')
const documentResource = await writePersonDocument(
`/genealogy/app/genealogies/${normalizedIds.genealogyId}/person-documents/${normalizedIds.documentId}/resources/${normalizedResourceId}`,
'PUT',
normalizePersonDocumentResourcePayload(payload),
'证件文件',
requestOptions
)
return normalizePersonDocumentResource(documentResource)
},
async deletePersonDocumentResource(genealogyId, documentId, resourceId, requestOptions = {}) {
const normalizedIds = normalizeDocumentIds(genealogyId, documentId)
const normalizedResourceId = normalizeResourcePathId(resourceId, '证件文件标识')
return deletePersonDocumentEndpoint(
`/genealogy/app/genealogies/${normalizedIds.genealogyId}/person-documents/${normalizedIds.documentId}/resources/${normalizedResourceId}`,
'证件文件',
requestOptions
)
},
async getPersonDocumentResourceAccess(genealogyId, documentId, resourceId, accessToken = '', requestOptions = {}) {
const normalizedIds = normalizeDocumentIds(genealogyId, documentId)
const normalizedResourceId = normalizeResourcePathId(resourceId, '证件文件标识')
const fileAccess = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedIds.genealogyId}/person-documents/${normalizedIds.documentId}/resources/${normalizedResourceId}/access`,
method: 'GET',
header: contentAccessHeader(accessToken)
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeBusinessFileAccess(
fileAccess,
'证件文件访问对象',
'PERSON_DOCUMENT_FILE_ACCESS_INVALID',
{ required: true }
)
},
async setPersonDocumentPassword(genealogyId, documentId, password, requestOptions = {}) {
const normalizedIds = normalizeDocumentIds(genealogyId, documentId)
await writePersonDocument(
`/genealogy/app/genealogies/${normalizedIds.genealogyId}/person-documents/${normalizedIds.documentId}/content-protection`,
'PUT',
normalizeContentPasswordPayload(password),
'证件内容密码',
requestOptions
)
return null
},
async unlockPersonDocument(genealogyId, documentId, password, requestOptions = {}) {
const normalizedIds = normalizeDocumentIds(genealogyId, documentId)
const accessGrant = await writePersonDocument(
`/genealogy/app/genealogies/${normalizedIds.genealogyId}/person-documents/${normalizedIds.documentId}/content-unlock`,
'POST',
normalizeContentPasswordPayload(password),
'证件内容解锁',
requestOptions
)
return normalizeContentAccessGrant(accessGrant)
},
async disablePersonDocumentPassword(genealogyId, documentId, requestOptions = {}) {
const normalizedIds = normalizeDocumentIds(genealogyId, documentId)
return deletePersonDocumentEndpoint(
`/genealogy/app/genealogies/${normalizedIds.genealogyId}/person-documents/${normalizedIds.documentId}/content-protection`,
'证件内容密码',
requestOptions
)
}
}
+129
View File
@@ -0,0 +1,129 @@
import { createRequestError } from './request-client.js'
import { assertSmsCode } from '@/utils/auth/verification.js'
import {
assertPlainPayload,
normalizeOptionalText,
normalizeOssIdString
} from './request-normalizers.js'
import { normalizeOptionalResponseText } from './response-normalizers.js'
import { normalizeBusinessFileAccess } from './business-file-contract.js'
export const PROFILE_SEX_OPTIONS = Object.freeze([
Object.freeze({ value: '0', label: '男' }),
Object.freeze({ value: '1', label: '女' }),
Object.freeze({ value: '2', label: '未知' })
])
const profileSexValues = new Set(PROFILE_SEX_OPTIONS.map(({ value }) => value))
const normalizeProfileResponseText = (value, field) => {
if (value === undefined || value === null) return ''
if (typeof value !== 'string') {
throw createRequestError(`个人资料字段 ${field} 无效`, 'PROFILE_RESPONSE_INVALID')
}
return value.trim()
}
export const normalizePhoneChangePayload = (payload) => {
assertPlainPayload(payload, new Set(['phone', 'smsCode']), '换绑手机号请求')
if (typeof payload.phone !== 'string' || !/^1\d{10}$/.test(payload.phone.trim())) {
throw new TypeError('新手机号格式无效')
}
return { phone: payload.phone.trim(), smsCode: assertSmsCode(payload.smsCode) }
}
const normalizeProfileUserId = (value) => {
if (value === undefined || value === null || value === '') return ''
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value)
throw createRequestError('个人资料字段 userId 无效', 'PROFILE_RESPONSE_INVALID')
}
export const normalizeAppProfile = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw createRequestError('个人资料响应格式无效', 'PROFILE_RESPONSE_INVALID')
}
return {
userId: normalizeProfileUserId(payload.userId),
tenantId: normalizeProfileResponseText(payload.tenantId, 'tenantId'),
userNo: normalizeProfileResponseText(payload.userNo, 'userNo'),
phone: normalizeProfileResponseText(payload.phone, 'phone'),
nickName: normalizeProfileResponseText(payload.nickName, 'nickName'),
realName: normalizeProfileResponseText(payload.realName, 'realName'),
avatarFile: normalizeBusinessFileAccess(
payload.avatarFile,
'个人头像',
'PROFILE_RESPONSE_INVALID'
),
sex: normalizeProfileResponseText(payload.sex, 'sex'),
birthday: normalizeProfileResponseText(payload.birthday, 'birthday'),
email: normalizeProfileResponseText(payload.email, 'email'),
registerSource: normalizeProfileResponseText(payload.registerSource, 'registerSource'),
status: normalizeProfileResponseText(payload.status, 'status')
}
}
export const normalizeProfileUpdatePayload = (payload) => {
const allowedFields = new Set(['nickName', 'realName', 'avatar', 'sex', 'birthday', 'email'])
assertPlainPayload(payload, allowedFields, '个人资料请求')
const normalizedPayload = {}
for (const [field, limit] of [['nickName', 30], ['realName', 30]]) {
if (!Object.prototype.hasOwnProperty.call(payload, field)) continue
const normalizedText = normalizeOptionalText(payload[field], field)
if (!normalizedText) continue
if (normalizedText.length > limit) throw new TypeError(`${field} 超出长度限制`)
normalizedPayload[field] = normalizedText
}
if (Object.prototype.hasOwnProperty.call(payload, 'sex')) {
const sex = normalizeOptionalText(payload.sex, 'sex')
if (sex) {
if (!profileSexValues.has(sex)) throw new TypeError('sex 必须为 0、1 或 2')
normalizedPayload.sex = sex
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'birthday')) {
const birthday = normalizeOptionalText(payload.birthday, 'birthday')
if (birthday) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(birthday)) {
throw new TypeError('birthday 必须是 yyyy-MM-dd 日期')
}
normalizedPayload.birthday = birthday
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'email')) {
const email = normalizeOptionalText(payload.email, 'email')
if (email) {
if (email.length > 100 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new TypeError('email 格式无效')
}
normalizedPayload.email = email
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'avatar')) {
const avatarOssId = payload.avatar
if (avatarOssId !== undefined && avatarOssId !== null && avatarOssId !== '') {
normalizedPayload.avatar = normalizeOssIdString(avatarOssId, 'avatar')
}
}
if (Object.keys(normalizedPayload).length === 0) {
throw new TypeError('个人资料至少需要一个可更新字段')
}
return normalizedPayload
}
export const normalizeRecommendationPreference = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('个性化推荐偏好响应无效', 'RECOMMENDATION_PREFERENCE_RESPONSE_INVALID')
}
if (typeof value.enabled !== 'boolean') {
throw createRequestError('个性化推荐偏好缺少 enabled', 'RECOMMENDATION_PREFERENCE_RESPONSE_INVALID')
}
if (!Number.isSafeInteger(value.version) || value.version < 0) {
throw createRequestError('个性化推荐偏好 version 无效', 'RECOMMENDATION_PREFERENCE_RESPONSE_INVALID')
}
return {
enabled: value.enabled,
version: value.version,
updatedAt: normalizeOptionalResponseText(value.updatedAt, 'updatedAt', '个性化推荐偏好响应', 'RECOMMENDATION_PREFERENCE_RESPONSE_INVALID')
}
}
+77
View File
@@ -0,0 +1,77 @@
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import {
normalizeAppProfile,
normalizeProfileUpdatePayload,
normalizeRecommendationPreference
} from './profile-contract.js'
import {
createRequestError,
requestStrict
} from './request-client.js'
const requireRemoteProfile = (operation) => {
if (hasRemoteConfig()) return
const isRead = operation === '读取'
throw createRequestError(
`个人资料${operation}需要真实服务,当前本地预览不伪造${isRead ? '资料' : '保存成功'}`,
isRead ? 'REMOTE_READ_REQUIRED' : 'REMOTE_WRITE_REQUIRED'
)
}
const requireRemotePreference = (operation) => {
if (hasRemoteConfig()) return
throw createRequestError(
`个性化推荐偏好${operation}需要真实服务,当前本地预览不会伪造结果`,
operation === '读取' ? 'REMOTE_READ_REQUIRED' : 'REMOTE_WRITE_REQUIRED'
)
}
export const profileApi = {
async getProfile(requestOptions = {}) {
requireRemoteProfile('读取')
const profile = await requestStrict({
url: '/genealogy/app/auth/profile',
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeAppProfile(profile)
},
async updateProfile(payload, requestOptions = {}) {
const profileChanges = normalizeProfileUpdatePayload(payload)
requireRemoteProfile('更新')
const profile = await requestStrict({
url: '/genealogy/app/auth/profile',
method: 'PUT',
data: profileChanges
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeAppProfile(profile)
},
async getRecommendationPreference(requestOptions = {}) {
requireRemotePreference('读取')
const preference = await requestStrict({
url: '/genealogy/app/recommendation-preference',
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeRecommendationPreference(preference)
},
async updateRecommendationPreference(enabled, requestOptions = {}) {
if (typeof enabled !== 'boolean') throw new TypeError('个性化推荐开关必须是布尔值')
requireRemotePreference('写入')
const preference = await requestStrict({
url: '/genealogy/app/recommendation-preference',
method: 'PUT',
data: { enabled }
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeRecommendationPreference(preference)
}
}
@@ -0,0 +1,24 @@
import { createRequestError } from './request-client.js'
import { normalizeOptionalResponseText } from './response-normalizers.js'
export const normalizeContentPasswordPayload = (password) => {
if (typeof password !== 'string') throw new TypeError('内容密码必须是字符串')
if (password.length < 8 || password.length > 128) throw new TypeError('内容密码长度必须为8至128个字符')
return { password }
}
export const normalizeContentAccessGrant = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('内容解锁响应无效', 'CONTENT_ACCESS_GRANT_INVALID')
}
const accessToken = normalizeOptionalResponseText(value.accessToken, 'accessToken', '内容解锁响应', 'CONTENT_ACCESS_GRANT_INVALID')
const expiresAt = normalizeOptionalResponseText(value.expiresAt, 'expiresAt', '内容解锁响应', 'CONTENT_ACCESS_GRANT_INVALID')
if (!accessToken || !expiresAt) throw createRequestError('内容解锁响应缺少授权信息', 'CONTENT_ACCESS_GRANT_INVALID')
return { accessToken, expiresAt }
}
export const contentAccessHeader = (accessToken) => {
if (accessToken === undefined || accessToken === null || accessToken === '') return {}
if (typeof accessToken !== 'string' || !accessToken.trim()) throw new TypeError('内容访问令牌无效')
return { 'X-Content-Access-Token': accessToken.trim() }
}
+36
View File
@@ -0,0 +1,36 @@
import { createRequestError } from './request-client.js'
import { normalizeResponseText } from './response-normalizers.js'
export const normalizeRegionParentCode = (value) => {
if (value === undefined || value === null || value === '') return '0'
if (typeof value !== 'string' || !value.trim()) throw new TypeError('地区父级标识无效')
return value.trim()
}
export const normalizeRegionCode = (value) => {
if (typeof value !== 'string' || !value.trim()) throw new TypeError('行政区划编码无效')
return value.trim()
}
export const normalizeRegionSelectorItems = (value) => {
if (!Array.isArray(value)) {
throw createRequestError('地区选择响应不是列表', 'REGION_RESPONSE_INVALID')
}
return value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('地区选择响应包含无效条目', 'REGION_RESPONSE_INVALID')
}
const regionCode = normalizeResponseText(item.regionCode ?? item.value, 'regionCode')
const label = normalizeResponseText(item.label ?? item.regionName, 'label')
if (!regionCode || !label) {
throw createRequestError('地区选择响应缺少地区名称或标识', 'REGION_RESPONSE_INVALID')
}
return {
regionCode,
label,
parentCode: normalizeResponseText(item.parentCode, 'parentCode'),
leaf: item.leaf === true,
regionLevel: Number.isInteger(item.regionLevel) ? item.regionLevel : null
}
})
}
+43
View File
@@ -0,0 +1,43 @@
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import {
normalizeRegionCode,
normalizeRegionParentCode,
normalizeRegionSelectorItems
} from './region-contract.js'
import {
createRequestError,
requestStrict
} from './request-client.js'
const requireRemoteRegion = (label) => {
if (hasRemoteConfig()) return
throw createRequestError(`${label}读取需要真实服务,当前本地预览不会伪造结果`, 'REMOTE_READ_REQUIRED')
}
export const regionApi = {
async getRegionChildren(parentCode, requestOptions = {}) {
requireRemoteRegion('地区选择')
const regionChildren = await requestStrict({
url: '/genealogy/app/region/children',
method: 'GET',
data: { parentCode: normalizeRegionParentCode(parentCode) }
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeRegionSelectorItems(regionChildren)
},
async getRegionPath(regionCode, requestOptions = {}) {
requireRemoteRegion('行政区划路径')
const regionPath = await requestStrict({
url: `/genealogy/app/region/path/${encodeURIComponent(normalizeRegionCode(regionCode))}`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
if (!Array.isArray(regionPath)) {
throw createRequestError('行政区划路径响应不是列表', 'REGION_PATH_RESPONSE_INVALID')
}
return regionPath
}
}
+347
View File
@@ -0,0 +1,347 @@
import { runtimeConfig } from '@/utils/runtime-config.js'
import { goRoot } from '@/utils/navigation/gateway.js'
import { session } from '@/utils/session.js'
import { createRequestCancelledError } from './request-controller.js'
const SUCCESS_CODES = [0, 200]
const REQUEST_TIMEOUT_MS = 15000
export const createRequestError = (message, code, details = {}) => {
const error = new Error(message)
error.code = code
Object.assign(error, details)
return error
}
const isAuthenticatedSessionRejected = (error) =>
error?.code === 'BUSINESS_ERROR' && Number(error.businessCode) === 401
let authenticationRecoveryInFlight = false
const recoverExpiredAuthenticatedSession = () => {
if (authenticationRecoveryInFlight) return
authenticationRecoveryInFlight = true
session.clear()
const finishRecovery = () => {
authenticationRecoveryInFlight = false
}
goRoot('A01').then(finishRecovery, finishRecovery)
}
export const unwrapResponse = (response) => {
if (!response || typeof response !== 'object' || Array.isArray(response)) return response
if (!Object.prototype.hasOwnProperty.call(response, 'code')) return response
if (!SUCCESS_CODES.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
}
export 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,
tenantId: runtimeConfig.tenantId,
...(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) {
// 业务 401 表示本地会话已经失效:只撤销本地令牌和家谱上下文,
// 不调用退出接口、不重试写操作,也不影响未携带会话的登录/注册请求。
if (authenticated && isAuthenticatedSessionRejected(error)) {
recoverExpiredAuthenticatedSession()
}
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)
})
// 已核对的接口使用严格边界:只接受 HTTP 200 与完整 JSON envelope
// 并统一限制弱网等待时间;尚未逐页治理的旧读取仍使用通用 request。
export const requestStrict = (options, {
authenticated = true,
requireData = true,
requestController = null
} = {}) => request({
...options,
timeout: REQUEST_TIMEOUT_MS
}, {
authenticated,
strictEnvelope: true,
requireEnvelopeData: requireData,
expectedStatus: 200,
requestController
})
export const requestAuth = (options, { requestController = null } = {}) => requestStrict(options, {
authenticated: false,
requestController
})
// RVoid 的 OpenAPI schema 没有把 data 声明为必填。空结果接口仍要求合法的
// HTTP 200 与整数 code,但统一向页面返回 null,避免把 envelope 泄漏成业务数据。
export const requestAuthVoid = async (options, { requestController = null } = {}) => {
await requestStrict(options, {
authenticated: false,
requireData: false,
requestController
})
return null
}
const parseStrictUploadResponse = (responseText, requireData) => {
let uploadEnvelope
try {
uploadEnvelope = JSON.parse(responseText)
} catch {
throw createRequestError('文件服务响应格式无效', 'RESPONSE_INVALID')
}
if (
!uploadEnvelope ||
typeof uploadEnvelope !== 'object' ||
Array.isArray(uploadEnvelope) ||
!Object.prototype.hasOwnProperty.call(uploadEnvelope, 'code') ||
!Number.isInteger(uploadEnvelope.code) ||
(requireData && !Object.prototype.hasOwnProperty.call(uploadEnvelope, 'data'))
) {
throw createRequestError('文件服务响应格式无效', 'RESPONSE_INVALID')
}
return unwrapResponse(uploadEnvelope)
}
export const requestNativeFileChunk = ({ uploadId, chunkIndex, chunkMd5, filePath }, { requestController = null } = {}) => new Promise((resolve, reject) => {
if (typeof globalThis.uni?.uploadFile !== 'function') {
reject(createRequestError('当前运行环境不支持文件上传', 'FILE_UPLOAD_UNAVAILABLE'))
return
}
if (requestController !== null && (
typeof requestController.bind !== 'function' ||
typeof requestController.release !== 'function'
)) {
reject(new TypeError('文件上传控制器格式无效'))
return
}
let settled = false
let task = null
let cancelled = false
const release = () => requestController?.release(abort)
const resolveOnce = (value) => {
if (settled) return
settled = true
release()
resolve(value)
}
const rejectOnce = (error) => {
if (settled) return
settled = true
release()
reject(error)
}
const abort = () => {
if (settled) return
cancelled = true
task?.abort?.()
rejectOnce(createRequestCancelledError())
}
const token = session.getToken()
try {
task = globalThis.uni.uploadFile({
url: `${runtimeConfig.baseUrl}/genealogy/app/files/resumable/chunk`,
filePath,
name: 'file',
formData: {
uploadId,
chunkIndex: String(chunkIndex),
chunkMd5
},
header: {
clientid: runtimeConfig.clientId,
tenantId: runtimeConfig.tenantId,
...(token ? { Authorization: `Bearer ${token}` } : {})
},
timeout: REQUEST_TIMEOUT_MS,
success: (upload) => {
if (upload.statusCode !== 200) {
rejectOnce(createRequestError(`文件分片上传失败(HTTP ${upload.statusCode}`, 'HTTP_ERROR', { httpStatus: upload.statusCode }))
return
}
try {
parseStrictUploadResponse(upload.data, false)
resolveOnce(null)
} catch (error) {
rejectOnce(error)
}
},
fail: (error) => {
if (settled) return
if (cancelled) {
rejectOnce(createRequestCancelledError())
return
}
const message = typeof error?.errMsg === 'string' && error.errMsg
? error.errMsg
: '文件分片上传失败'
const timeout = /timeout/i.test(message)
rejectOnce(createRequestError(
timeout ? '文件分片上传超时' : message,
timeout ? 'REQUEST_TIMEOUT' : 'UPLOAD_FAILED'
))
}
})
if (requestController) requestController.bind(abort)
} catch (error) {
rejectOnce(error)
}
})
export const requestBrowserFileChunk = ({ uploadId, chunkIndex, chunkMd5, file }, { requestController = null } = {}) => new Promise((resolve, reject) => {
if (typeof globalThis.fetch !== 'function' || typeof globalThis.FormData !== 'function') {
reject(createRequestError('当前运行环境不支持文件上传', 'FILE_UPLOAD_UNAVAILABLE'))
return
}
if (requestController !== null && (
typeof requestController.bind !== 'function' ||
typeof requestController.release !== 'function'
)) {
reject(new TypeError('文件上传控制器格式无效'))
return
}
let settled = false
let timeoutId = null
const abortController = new AbortController()
const release = () => requestController?.release(abort)
const clearRequestTimeout = () => {
if (timeoutId === null) return
clearTimeout(timeoutId)
timeoutId = null
}
const resolveOnce = (value) => {
if (settled) return
settled = true
clearRequestTimeout()
release()
resolve(value)
}
const rejectOnce = (error) => {
if (settled) return
settled = true
clearRequestTimeout()
release()
reject(error)
}
const abort = () => {
if (settled) return
abortController.abort()
rejectOnce(createRequestCancelledError())
}
const token = session.getToken()
const formData = new globalThis.FormData()
formData.append('uploadId', uploadId)
formData.append('chunkIndex', String(chunkIndex))
formData.append('chunkMd5', chunkMd5)
formData.append('file', file, file.name || 'image')
if (requestController) requestController.bind(abort)
timeoutId = setTimeout(() => {
abortController.abort()
rejectOnce(createRequestError('文件分片上传超时', 'REQUEST_TIMEOUT'))
}, REQUEST_TIMEOUT_MS)
globalThis.fetch(`${runtimeConfig.baseUrl}/genealogy/app/files/resumable/chunk`, {
method: 'POST',
headers: {
clientid: runtimeConfig.clientId,
tenantId: runtimeConfig.tenantId,
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: formData,
signal: abortController.signal
}).then(async (response) => {
if (response.status !== 200) {
throw createRequestError(`文件分片上传失败(HTTP ${response.status}`, 'HTTP_ERROR', { httpStatus: response.status })
}
parseStrictUploadResponse(await response.text(), false)
resolveOnce(null)
}).catch((error) => {
if (settled) return
rejectOnce(error?.name === 'AbortError' ? createRequestCancelledError() : error)
})
})
+31
View File
@@ -0,0 +1,31 @@
const createRequestCancelledError = () => {
const error = new Error('请求已取消')
error.code = 'REQUEST_CANCELLED'
return error
}
// 页面只持有控制器,不依赖各端 RequestTask 的实现差异。
// 新请求会接管控制器;页面卸载时 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 { createRequestCancelledError }
+12
View File
@@ -0,0 +1,12 @@
export const getRequestErrorMessage = (error, fallback = '操作未完成,请稍后再试。') => {
if (error?.code === 'NETWORK_ERROR' || error?.code === 'REQUEST_TIMEOUT') {
return '网络不太稳定,请检查网络后重试。'
}
if (error?.code === 'BUSINESS_ERROR' && Number(error.businessCode) === 401) {
return '登录状态已失效,请重新登录。'
}
if (error?.code === 'BUSINESS_ERROR' && Number(error.businessCode) === 403) {
return '暂时没有权限进行这项操作。'
}
return fallback
}
+56
View File
@@ -0,0 +1,56 @@
import { isNormalDisableStatus } from './normal-disable-status.js'
export const assertPlainPayload = (payload, allowedFields, label) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.getPrototypeOf(payload) !== Object.prototype) {
throw new TypeError(`${label}必须是普通对象`)
}
if (Object.keys(payload).some((field) => !allowedFields.has(field))) {
throw new TypeError(`${label}包含未声明字段`)
}
}
export const normalizeOptionalText = (value, label) => {
if (value === undefined || value === null) return undefined
if (typeof value !== 'string') {
throw new TypeError(`${label}必须是字符串`)
}
const normalized = value.trim()
return normalized || undefined
}
export const normalizeOptionalNormalDisableStatus = (value, label = 'status') => {
const status = normalizeOptionalText(value, label)
if (status === undefined) return undefined
if (!isNormalDisableStatus(status)) throw new TypeError(`${label}必须为 0 或 1`)
return status
}
export const normalizeOssIdString = (value, field) => {
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) {
throw new TypeError(`${field} 必须是正整数 OSS ID 字符串`)
}
return value
}
export const normalizeOptionalSafeInteger = (value, field) => {
if (value === undefined || value === null || value === '') return undefined
const numericValue = typeof value === 'number' ? value : Number(value)
if (!Number.isSafeInteger(numericValue)) throw new TypeError(`${field} 必须是安全整数`)
return numericValue
}
export const normalizeResourcePathId = (value, label) => {
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value)
throw new TypeError(`${label}必须是有效的正整数标识`)
}
export const normalizeOptionalOssIdList = (value) => {
if (value === undefined || value === null || value === '') return undefined
if (typeof value !== 'string') throw new TypeError('mediaOssIds 必须是字符串')
const normalized = value.trim()
if (!/^[1-9]\d*(,[1-9]\d*)*$/.test(normalized)) {
throw new TypeError('mediaOssIds 必须是以英文逗号分隔的正整数文件标识')
}
return normalized
}
+57
View File
@@ -0,0 +1,57 @@
import { createRequestError } from './request-client.js'
import { isNormalDisableStatus } from './normal-disable-status.js'
export const normalizeOptionalNumericId = (value, label, code) => {
if (value === undefined || value === null || value === '') return null
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value)
throw createRequestError(`${label}无效`, code)
}
export const normalizeResponseText = (
value,
label,
{ code = 'API_RESPONSE_INVALID', subject = '接口响应' } = {}
) => {
if (value === undefined || value === null) return ''
if (typeof value !== 'string') {
throw createRequestError(`${subject}字段 ${label} 无效`, code)
}
return value.trim()
}
export const normalizeOptionalResponseText = (value, field, label, code) => {
if (value === undefined || value === null) return ''
if (typeof value !== 'string') {
throw createRequestError(`${label}字段 ${field} 无效`, code)
}
return value.trim()
}
export const normalizeNormalDisableResponseStatus = (value, label, code) => {
const status = normalizeResponseText(value, label, { code })
if (!status) return ''
if (!isNormalDisableStatus(status)) {
throw createRequestError(`${label}无效`, code)
}
return status
}
export const normalizeOptionalNonnegativeInteger = (value, label, code) => {
if (value === undefined || value === null) return 0
if (!Number.isSafeInteger(value) || value < 0) {
throw createRequestError(`${label}无效`, code)
}
return value
}
export const normalizeOptionalCurrencyAmount = (value, label, code, { required = false } = {}) => {
if (value === undefined || value === null || value === '') {
if (required) throw createRequestError(`${label}缺失`, code)
return ''
}
if (typeof value !== 'string') throw createRequestError(`${label}无效`, code)
const match = value.trim().match(/^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/)
if (!match) throw createRequestError(`${label}无效`, code)
return `${match[1]}.${(match[2] || '').padEnd(2, '0')}`
}
+125
View File
@@ -0,0 +1,125 @@
import { createRequestError } from './request-client.js'
import {
normalizeOptionalNumericId,
normalizeResponseText
} from './response-normalizers.js'
import { normalizeBusinessFileAccess } from './business-file-contract.js'
const promotionPlacements = new Set(['home_banner', 'home_bottom', 'message_bottom', 'profile_bottom'])
export const normalizePromotionPlacement = (value) => {
if (!promotionPlacements.has(value)) throw new TypeError('推广位无效')
return value
}
export const normalizeAppPromotions = (value, expectedPlacement) => {
if (!Array.isArray(value)) throw createRequestError('应用推广响应不是列表', 'PROMOTION_RESPONSE_INVALID')
const promotions = value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('应用推广响应包含无效条目', 'PROMOTION_RESPONSE_INVALID')
}
const id = normalizeOptionalNumericId(item.promotionId, '推广标识', 'PROMOTION_RESPONSE_INVALID')
const title = normalizeResponseText(item.promotionTitle, 'promotionTitle')
if (!id || !title) {
throw createRequestError('应用推广响应缺少稳定字段', 'PROMOTION_RESPONSE_INVALID')
}
const platform = normalizeResponseText(item.platform, 'platform')
const placement = normalizeResponseText(item.placement, 'placement')
if ((platform && platform !== 'app' && platform !== 'all') || placement !== expectedPlacement) {
throw createRequestError('应用推广响应位置与请求不匹配', 'PROMOTION_RESPONSE_INVALID')
}
const targetUrl = normalizeResponseText(item.targetUrl, 'targetUrl')
if (
targetUrl &&
!(/^\/(?!\/)[^\s]*$/.test(targetUrl) || /^https:\/\/[^\s/?#]+(?:[/?#][^\s]*)?$/.test(targetUrl))
) {
throw createRequestError('应用推广跳转地址无效', 'PROMOTION_RESPONSE_INVALID')
}
return {
id,
title,
description: normalizeResponseText(item.promotionDesc, 'promotionDesc'),
coverFile: normalizeBusinessFileAccess(item.coverFile, '推广封面', 'PROMOTION_RESPONSE_INVALID'),
targetUrl,
placement
}
})
if (new Set(promotions.map((item) => item.id)).size !== promotions.length) {
throw createRequestError('应用推广响应包含重复标识', 'PROMOTION_RESPONSE_INVALID')
}
return promotions
}
const normalizeHelpArticleText = (value, field) => {
if (typeof value !== 'string' || !value.trim()) {
throw createRequestError(`帮助文章缺少 ${field}`, 'HELP_ARTICLE_RESPONSE_INVALID')
}
return value.trim()
}
const normalizeOptionalHelpArticleText = (value, field) => {
if (value === undefined || value === null) return ''
return normalizeHelpArticleText(value, field)
}
const helpArticleCategories = new Set(['common', 'member', 'lineage'])
export const normalizeHelpArticles = (value) => {
if (!Array.isArray(value)) throw createRequestError('帮助文章响应不是列表', 'HELP_ARTICLE_RESPONSE_INVALID')
const articles = value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('帮助文章响应包含无效条目', 'HELP_ARTICLE_RESPONSE_INVALID')
}
const id = normalizeOptionalNumericId(item.helpId, '帮助文章标识', 'HELP_ARTICLE_RESPONSE_INVALID')
const category = normalizeHelpArticleText(item.helpCategory, 'helpCategory')
if (!id || !helpArticleCategories.has(category)) {
throw createRequestError('帮助文章标识或分类无效', 'HELP_ARTICLE_RESPONSE_INVALID')
}
return {
key: `help-${id}`,
category,
categoryLabel: normalizeOptionalHelpArticleText(item.helpCategoryLabel, 'helpCategoryLabel') || category,
title: normalizeHelpArticleText(item.helpTitle, 'helpTitle'),
content: normalizeHelpArticleText(item.helpContent, 'helpContent')
}
})
if (new Set(articles.map((article) => article.key)).size !== articles.length) {
throw createRequestError('帮助文章响应包含重复标识', 'HELP_ARTICLE_RESPONSE_INVALID')
}
return articles
}
export const COMPLIANCE_DOCUMENT_KEY = Object.freeze({
USER_AGREEMENT: 'user_agreement',
PRIVACY_POLICY: 'privacy_policy'
})
const complianceDocumentKeys = new Set(Object.values(COMPLIANCE_DOCUMENT_KEY))
export const normalizeComplianceDocumentKey = (value) => {
if (typeof value !== 'string' || !complianceDocumentKeys.has(value)) {
throw new TypeError('合规文档类型无效')
}
return value
}
export const normalizeComplianceDocument = (value, expectedKey) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('协议正文响应无效', 'COMPLIANCE_DOCUMENT_RESPONSE_INVALID')
}
const key = normalizeResponseText(value.documentKey, 'documentKey')
const title = normalizeResponseText(value.documentTitle, 'documentTitle')
const versionNo = normalizeResponseText(value.versionNo, 'versionNo')
const content = normalizeResponseText(value.documentContent, 'documentContent')
if (key !== expectedKey || !title || !versionNo || !content) {
throw createRequestError('协议正文响应缺少稳定字段', 'COMPLIANCE_DOCUMENT_RESPONSE_INVALID')
}
return {
key,
title,
versionNo,
content,
effectiveAt: normalizeResponseText(value.effectiveAt, 'effectiveAt'),
publishedAt: normalizeResponseText(value.publishedAt, 'publishedAt')
}
}
+55
View File
@@ -0,0 +1,55 @@
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import {
normalizeAppPromotions,
normalizeComplianceDocument,
normalizeComplianceDocumentKey,
normalizeHelpArticles,
normalizePromotionPlacement
} from './site-content-contract.js'
import {
createRequestError,
requestStrict
} from './request-client.js'
const requireRemoteSiteContent = (label) => {
if (hasRemoteConfig()) return
throw createRequestError(`${label}读取需要真实服务,当前本地预览不会伪造结果`, 'REMOTE_READ_REQUIRED')
}
export const siteContentApi = {
async getHelpArticles(requestOptions = {}) {
requireRemoteSiteContent('帮助文章')
const helpArticles = await requestStrict({
url: '/genealogy/app/help-articles',
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeHelpArticles(helpArticles)
},
async getPromotions(requestOptions = {}) {
const placement = normalizePromotionPlacement(requestOptions.placement ?? 'home_banner')
requireRemoteSiteContent('应用推广')
const promotions = await requestStrict({
url: '/genealogy/app/promotions',
method: 'GET',
data: { platform: 'app', placement }
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeAppPromotions(promotions, placement)
},
async getComplianceDocument(documentKey, requestOptions = {}) {
requireRemoteSiteContent('协议正文')
const normalizedKey = normalizeComplianceDocumentKey(documentKey)
const complianceDocument = await requestStrict({
url: `/genealogy/app/compliance/documents/${normalizedKey}`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeComplianceDocument(complianceDocument, normalizedKey)
}
}
+101
View File
@@ -0,0 +1,101 @@
import { createRequestError } from './request-client.js'
import { normalizeOptionalNumericId } from './response-normalizers.js'
const normalizeVipText = (value, field, { required = false } = {}) => {
if (value === undefined || value === null) {
if (required) throw createRequestError(`VIP 响应缺少 ${field}`, 'VIP_RESPONSE_INVALID')
return ''
}
if (typeof value !== 'string') {
throw createRequestError(`VIP 响应 ${field} 无效`, 'VIP_RESPONSE_INVALID')
}
const text = value.trim()
if (required && !text) throw createRequestError(`VIP 响应缺少 ${field}`, 'VIP_RESPONSE_INVALID')
return text
}
const normalizeVipAmount = (value, field, { required = false } = {}) => {
if (value === undefined || value === null || value === '') {
if (required) throw createRequestError(`VIP 响应缺少 ${field}`, 'VIP_RESPONSE_INVALID')
return ''
}
const text = typeof value === 'number' && Number.isFinite(value) ? String(value) : value
if (typeof text !== 'string') throw createRequestError(`VIP 响应 ${field} 无效`, 'VIP_RESPONSE_INVALID')
const match = text.trim().match(/^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/)
if (!match) throw createRequestError(`VIP 响应 ${field} 无效`, 'VIP_RESPONSE_INVALID')
return `${match[1]}.${(match[2] || '').padEnd(2, '0')}`
}
const vipOrderStatusFallbackLabels = Object.freeze({
'0': '待支付',
'1': '已支付',
'2': '已关闭',
'3': '已退款'
})
const normalizeVipOrderStatus = (value) => {
const status = normalizeVipText(value, 'payStatus', { required: true })
return {
status,
statusLabel: vipOrderStatusFallbackLabels[status] || '状态待确认'
}
}
export const normalizeVipPackages = (value) => {
if (!Array.isArray(value)) throw createRequestError('VIP 套餐响应不是列表', 'VIP_RESPONSE_INVALID')
const packages = value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('VIP 套餐响应包含无效条目', 'VIP_RESPONSE_INVALID')
}
const id = normalizeOptionalNumericId(item.packageId, 'VIP 套餐标识', 'VIP_RESPONSE_INVALID')
if (!id) throw createRequestError('VIP 套餐响应缺少稳定标识', 'VIP_RESPONSE_INVALID')
return {
key: `vip-package-${id}`,
id,
name: normalizeVipText(item.packageName, 'packageName', { required: true }),
description: normalizeVipText(item.packageDesc, 'packageDesc'),
price: normalizeVipAmount(item.price, 'price', { required: true }),
originalPrice: normalizeVipAmount(item.originalPrice, 'originalPrice')
}
})
if (new Set(packages.map((item) => item.id)).size !== packages.length) {
throw createRequestError('VIP 套餐响应包含重复标识', 'VIP_RESPONSE_INVALID')
}
return packages
}
export const normalizeVipOrders = (value) => {
if (!Array.isArray(value)) throw createRequestError('VIP 订单响应不是列表', 'VIP_RESPONSE_INVALID')
const orders = value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw createRequestError('VIP 订单响应包含无效条目', 'VIP_RESPONSE_INVALID')
}
const id = normalizeOptionalNumericId(item.orderId, 'VIP 订单标识', 'VIP_RESPONSE_INVALID')
if (!id) throw createRequestError('VIP 订单响应缺少稳定标识', 'VIP_RESPONSE_INVALID')
const { status, statusLabel } = normalizeVipOrderStatus(item.payStatus)
return {
key: `vip-order-${id}`,
id,
packageName: normalizeVipText(item.packageName, 'packageName', { required: true }),
amount: normalizeVipAmount(item.payAmount ?? item.orderAmount, 'payAmount', { required: true }),
status,
statusLabel,
paidAt: normalizeVipText(item.payTime, 'payTime'),
expiresAt: normalizeVipText(item.expireTime, 'expireTime')
}
})
if (new Set(orders.map((item) => item.id)).size !== orders.length) {
throw createRequestError('VIP 订单响应包含重复标识', 'VIP_RESPONSE_INVALID')
}
return orders
}
export const normalizeVipCapability = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value) || typeof value.enabled !== 'boolean') {
throw createRequestError('VIP 购买能力响应无效', 'VIP_RESPONSE_INVALID')
}
return {
enabled: value.enabled,
disabledReason: normalizeVipText(value.disabledReason, 'disabledReason')
}
}
+50
View File
@@ -0,0 +1,50 @@
import { hasRemoteConfig } from '@/utils/runtime-config.js'
import {
normalizeVipCapability,
normalizeVipOrders,
normalizeVipPackages
} from './vip-contract.js'
import {
createRequestError,
requestStrict
} from './request-client.js'
const requireRemoteVip = (label) => {
if (hasRemoteConfig()) return
throw createRequestError(`${label}读取需要真实服务,当前本地预览不会伪造结果`, 'REMOTE_READ_REQUIRED')
}
export const vipApi = {
async getVipCapability(requestOptions = {}) {
requireRemoteVip('VIP 购买能力')
const capability = await requestStrict({
url: '/genealogy/app/vip/capability',
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeVipCapability(capability)
},
async getVipPackages(requestOptions = {}) {
requireRemoteVip('VIP 套餐')
const packages = await requestStrict({
url: '/genealogy/app/vip/packages',
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeVipPackages(packages)
},
async getVipOrders(requestOptions = {}) {
requireRemoteVip('VIP 订单')
const orders = await requestStrict({
url: '/genealogy/app/vip/orders',
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeVipOrders(orders)
}
}