feat: migrate app routes and business modules
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' }
|
||||
])
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')}`
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user