完成80%
This commit is contained in:
+332
-20
@@ -12,7 +12,7 @@ import {
|
||||
listNotificationFixtures
|
||||
} from '@/data/mock.js'
|
||||
import { hasRemoteConfig, resolveRuntimeMode, runtimeConfig } from '@/utils/config.js'
|
||||
import { AUTH_TAC_SCENE, assertSmsCode } from '@/utils/auth-verification.js'
|
||||
import { AUTH_VERIFICATION_OPERATION, assertSmsCode } from '@/utils/auth-verification.js'
|
||||
import { GENEALOGY_ACCESS_PRESET, fromApiGenealogyAccess } from '@/utils/genealogy-contracts.js'
|
||||
import { session } from '@/utils/session.js'
|
||||
|
||||
@@ -302,6 +302,64 @@ const requestNativeFileChunk = ({ uploadId, chunkIndex, chunkMd5, filePath }, {
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
const abortController = new AbortController()
|
||||
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
|
||||
abortController.abort()
|
||||
rejectOnce(createRequestCancelledError())
|
||||
}
|
||||
const query = `uploadId=${encodeURIComponent(uploadId)}&chunkIndex=${encodeURIComponent(chunkIndex)}&chunkMd5=${encodeURIComponent(chunkMd5)}`
|
||||
const token = session.getToken()
|
||||
const formData = new globalThis.FormData()
|
||||
formData.append('file', file, file.name || 'image')
|
||||
if (requestController) requestController.bind(abort)
|
||||
globalThis.fetch(`${runtimeConfig.baseUrl}/genealogy/app/files/resumable/chunk?${query}`, {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
const requestNativeSingleFileUpload = ({ filePath }, { requestController = null } = {}) => new Promise((resolve, reject) => {
|
||||
if (requestController !== null && (
|
||||
typeof requestController.bind !== 'function' ||
|
||||
@@ -371,11 +429,11 @@ const requireRemoteAuth = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const assertAuthScene = (sceneCode) => {
|
||||
if (!Object.values(AUTH_TAC_SCENE).includes(sceneCode)) {
|
||||
throw new TypeError('短信场景不属于当前认证合同')
|
||||
const assertAuthVerificationOperation = (operationCode) => {
|
||||
if (!Object.values(AUTH_VERIFICATION_OPERATION).includes(operationCode)) {
|
||||
throw new TypeError('认证动作不属于当前认证合同')
|
||||
}
|
||||
return sceneCode
|
||||
return operationCode
|
||||
}
|
||||
|
||||
const assertValidToken = (validToken) => {
|
||||
@@ -385,6 +443,9 @@ const assertValidToken = (validToken) => {
|
||||
return validToken.trim()
|
||||
}
|
||||
|
||||
const normalizeOptionalValidToken = (validToken) =>
|
||||
validToken === undefined ? undefined : assertValidToken(validToken)
|
||||
|
||||
const assertPasswordHash = (passwordHash) => {
|
||||
if (typeof passwordHash !== 'string' || !/^[a-f0-9]{32}$/.test(passwordHash)) {
|
||||
throw new TypeError('密码摘要必须是 32 位小写 MD5')
|
||||
@@ -579,11 +640,13 @@ const normalizeResumableInitResult = (payload) => {
|
||||
if (typeof payload.instant !== 'boolean') {
|
||||
throw createRequestError('文件初始化响应缺少 instant', 'FILE_RESPONSE_INVALID')
|
||||
}
|
||||
const uploadId = normalizeUploadId(payload.uploadId, '响应 uploadId')
|
||||
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,
|
||||
@@ -844,6 +907,40 @@ const normalizeOptionalNonnegativeInteger = (value, label, code) => {
|
||||
return value
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeOptionalNumericId = (value, label, code) => {
|
||||
if (value === undefined || value === null || value === '') return null
|
||||
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
|
||||
@@ -896,6 +993,31 @@ const normalizeRegionParentCode = (value) => {
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
const normalizeRegionCode = (value) => {
|
||||
if (typeof value !== 'string' || !value.trim()) throw new TypeError('行政区划编码无效')
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
const normalizeRegionSearchPayload = (payload) => {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new TypeError('行政区划搜索参数无效')
|
||||
}
|
||||
const keyword = typeof payload.keyword === 'string' ? payload.keyword.trim() : ''
|
||||
if (!keyword) throw new TypeError('行政区划搜索关键词无效')
|
||||
const data = { keyword }
|
||||
if (payload.level !== undefined && payload.level !== null && payload.level !== '') {
|
||||
if (!Number.isInteger(payload.level) || payload.level < 1 || payload.level > 5) {
|
||||
throw new TypeError('行政区划级别无效')
|
||||
}
|
||||
data.level = payload.level
|
||||
}
|
||||
if (payload.limit !== undefined && payload.limit !== null && payload.limit !== '') {
|
||||
if (!Number.isInteger(payload.limit)) throw new TypeError('行政区划搜索数量上限无效')
|
||||
data.limit = payload.limit
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
const normalizeRegionSelectorItems = (value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
throw createRequestError('地区选择响应不是列表', 'REGION_RESPONSE_INVALID')
|
||||
@@ -1269,7 +1391,7 @@ const normalizeFamilyFeedRows = (value, expectedGenealogyId) => {
|
||||
mediaOssIds: normalizeOptionalGenealogyText(item.mediaOssIds, 'mediaOssIds'),
|
||||
likeCount: normalizeOptionalNonnegativeInteger(item.likeCount, '动态点赞数', 'FEED_RESPONSE_INVALID'),
|
||||
commentCount: normalizeOptionalNonnegativeInteger(item.commentCount, '动态评论数', 'FEED_RESPONSE_INVALID'),
|
||||
likedByMe: item.likedByMe === true,
|
||||
likedByMe: typeof item.likedByMe === 'boolean' ? item.likedByMe : null,
|
||||
pinned: item.pinned === true
|
||||
}
|
||||
})
|
||||
@@ -1666,6 +1788,33 @@ const normalizeJoinAuditPayload = (payload) => {
|
||||
return data
|
||||
}
|
||||
|
||||
const normalizeCeremonyInviteePayload = (payload) => {
|
||||
assertPlainPayload(payload, new Set(['inviteeUserIds']), '活动受邀人请求')
|
||||
if (!Array.isArray(payload.inviteeUserIds)) {
|
||||
throw new TypeError('inviteeUserIds必须是数组')
|
||||
}
|
||||
const inviteeUserIds = payload.inviteeUserIds.map((value) => {
|
||||
const id = normalizeResourcePathId(value, '受邀业务用户标识')
|
||||
const numericId = Number(id)
|
||||
if (!Number.isSafeInteger(numericId)) {
|
||||
throw createRequestError('受邀业务用户标识超出 APP 安全整数范围,需后端统一字符串 ID 合同', 'CEREMONY_INVITEE_ID_UNSAFE')
|
||||
}
|
||||
return numericId
|
||||
})
|
||||
if (new Set(inviteeUserIds).size !== inviteeUserIds.length) {
|
||||
throw new TypeError('inviteeUserIds不能包含重复标识')
|
||||
}
|
||||
return { inviteeUserIds }
|
||||
}
|
||||
|
||||
const normalizeCeremonyInvitationResponsePayload = (payload) => {
|
||||
assertPlainPayload(payload, new Set(['inviteStatus']), '活动邀请响应请求')
|
||||
if (!['ACCEPTED', 'DECLINED'].includes(payload.inviteStatus)) {
|
||||
throw new TypeError('inviteStatus仅允许ACCEPTED或DECLINED')
|
||||
}
|
||||
return { inviteStatus: payload.inviteStatus }
|
||||
}
|
||||
|
||||
const normalizeGenerationPoemWritePayload = (payload) => {
|
||||
assertPlainPayload(payload, new Set(['generationNo', 'generationText', 'description', 'sortOrder', 'status']), '字辈请求')
|
||||
const generationNo = Number(payload.generationNo)
|
||||
@@ -1798,9 +1947,15 @@ const normalizeLineageWritePayload = (payload) => {
|
||||
throw new TypeError('人物写入请求必须是普通对象')
|
||||
}
|
||||
const allowedFields = new Set([
|
||||
'appUserId',
|
||||
'personNo',
|
||||
'name',
|
||||
'aliasName',
|
||||
'sex',
|
||||
'generationName',
|
||||
'fatherId',
|
||||
'motherId',
|
||||
'avatarOssId',
|
||||
'birthDate',
|
||||
'birthLunar',
|
||||
'birthPlace',
|
||||
@@ -1811,7 +1966,9 @@ const normalizeLineageWritePayload = (payload) => {
|
||||
'biography',
|
||||
'remark',
|
||||
'relationName',
|
||||
'generation'
|
||||
'generation',
|
||||
'personStatus',
|
||||
'sortOrder'
|
||||
])
|
||||
for (const field of Object.keys(payload)) {
|
||||
if (!allowedFields.has(field)) throw new TypeError(`人物写入包含未声明字段:${field}`)
|
||||
@@ -1819,6 +1976,15 @@ const normalizeLineageWritePayload = (payload) => {
|
||||
const name = normalizeLineagePersonText(payload.name, '姓名', { required: true })
|
||||
if (name.length > 20) throw new TypeError('人物姓名长度超出当前页面合同')
|
||||
const data = { name }
|
||||
for (const [field, label] of [
|
||||
['appUserId', '绑定用户标识'],
|
||||
['fatherId', '父亲标识'],
|
||||
['motherId', '母亲标识'],
|
||||
['avatarOssId', '头像文件标识']
|
||||
]) {
|
||||
if (payload[field] === undefined || payload[field] === null || payload[field] === '') continue
|
||||
data[field] = normalizeLineagePersonIdentity(payload[field], label)
|
||||
}
|
||||
if (payload.generation !== undefined) {
|
||||
if (!Number.isSafeInteger(payload.generation) || payload.generation < 1) {
|
||||
throw new TypeError('人物世代必须是正安全整数')
|
||||
@@ -1826,6 +1992,8 @@ const normalizeLineageWritePayload = (payload) => {
|
||||
data.generation = payload.generation
|
||||
}
|
||||
for (const [field, label, maxLength] of [
|
||||
['personNo', '人物编号', null],
|
||||
['sex', '性别', null],
|
||||
['generationName', '字辈', 12],
|
||||
['aliasName', '别名或曾用名', null],
|
||||
['birthLunar', '出生农历', null],
|
||||
@@ -1835,7 +2003,8 @@ const normalizeLineageWritePayload = (payload) => {
|
||||
['burialPlace', '安葬地', null],
|
||||
['biography', '人物简介', 500],
|
||||
['remark', '备注', null],
|
||||
['relationName', '关系显示名称', null]
|
||||
['relationName', '关系显示名称', null],
|
||||
['personStatus', '人物状态', null]
|
||||
]) {
|
||||
if (payload[field] === undefined) continue
|
||||
const value = normalizeLineagePersonText(payload[field], label)
|
||||
@@ -1853,6 +2022,11 @@ const normalizeLineageWritePayload = (payload) => {
|
||||
if (data.birthDate && data.deathDate && data.deathDate < data.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('人物排序值必须是安全整数')
|
||||
data.sortOrder = sortOrder
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -1877,33 +2051,55 @@ const requireLineageWriteRequestController = (requestOptions) => {
|
||||
}
|
||||
|
||||
export const appApi = {
|
||||
async getCaptchaRequirement({ sceneCode, subject }, requestOptions = {}) {
|
||||
async getCaptchaRequirement({ operationCode, subject }, requestOptions = {}) {
|
||||
requireRemoteAuth()
|
||||
return requestAuth({
|
||||
url: '/captcha/require',
|
||||
url: `/genealogy/app/auth/verification/${encodeURIComponent(assertAuthVerificationOperation(operationCode))}/require`,
|
||||
method: 'GET',
|
||||
data: authPayload({ sceneCode: assertAuthScene(sceneCode), subject })
|
||||
data: { tenantId: runtimeConfig.tenantId, subject }
|
||||
}, requestOptions)
|
||||
},
|
||||
async sendSmsCode({ sceneCode, phone, validToken }, 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 sendLegacySmsCode({ phone, validToken }, requestOptions = {}) {
|
||||
requireRemoteAuth()
|
||||
const normalizedValidToken = normalizeOptionalValidToken(validToken)
|
||||
return requestAuthVoid({
|
||||
url: '/genealogy/app/auth/sms/code',
|
||||
method: 'POST',
|
||||
data: authPayload({
|
||||
data: {
|
||||
tenantId: runtimeConfig.tenantId,
|
||||
grantType: 'sms',
|
||||
sceneCode: assertAuthScene(sceneCode),
|
||||
phone,
|
||||
validToken: assertValidToken(validToken)
|
||||
})
|
||||
...(normalizedValidToken ? { validToken: normalizedValidToken } : {})
|
||||
}
|
||||
}, requestOptions)
|
||||
},
|
||||
async loginWithPassword({ phone, passwordHash }, requestOptions = {}) {
|
||||
async loginWithPassword({ phone, passwordHash, validToken }, requestOptions = {}) {
|
||||
requireRemoteAuth()
|
||||
const normalizedValidToken = normalizeOptionalValidToken(validToken)
|
||||
const result = await requestAuth({
|
||||
url: '/genealogy/app/auth/login',
|
||||
method: 'POST',
|
||||
data: authPayload({ grantType: 'password', phone, password: assertPasswordHash(passwordHash) })
|
||||
data: {
|
||||
tenantId: runtimeConfig.tenantId,
|
||||
grantType: 'password',
|
||||
phone,
|
||||
password: assertPasswordHash(passwordHash),
|
||||
...(normalizedValidToken ? { validToken: normalizedValidToken } : {})
|
||||
}
|
||||
}, requestOptions)
|
||||
return saveLogin(result)
|
||||
},
|
||||
@@ -2069,6 +2265,25 @@ export const appApi = {
|
||||
filePath: payload.filePath.trim()
|
||||
}, requestOptions)
|
||||
},
|
||||
async uploadBrowserResumableChunk(payload, file, requestOptions = {}) {
|
||||
const allowedFields = new Set(['uploadId', 'chunkIndex', 'chunkMd5'])
|
||||
assertPlainPayload(payload, allowedFields, '浏览器文件分片请求')
|
||||
if (!Number.isInteger(payload.chunkIndex) || payload.chunkIndex < 0 || payload.chunkIndex > 2147483647) {
|
||||
throw new TypeError('chunkIndex必须是非负 int32')
|
||||
}
|
||||
if (!file || typeof file !== 'object') {
|
||||
throw new TypeError('浏览器文件分片必须提供文件对象')
|
||||
}
|
||||
if (!hasRemoteConfig()) {
|
||||
throw createRequestError('文件上传需要真实服务,当前本地预览不伪造回执', 'REMOTE_WRITE_REQUIRED')
|
||||
}
|
||||
return requestBrowserFileChunk({
|
||||
uploadId: normalizeUploadId(payload.uploadId, 'uploadId'),
|
||||
chunkIndex: payload.chunkIndex,
|
||||
chunkMd5: normalizeUploadMd5(payload.chunkMd5, 'chunkMd5'),
|
||||
file
|
||||
}, requestOptions)
|
||||
},
|
||||
async completeResumableUpload(payload, requestOptions = {}) {
|
||||
const data = normalizeResumableCompletePayload(payload)
|
||||
if (!hasRemoteConfig()) {
|
||||
@@ -2111,7 +2326,7 @@ export const appApi = {
|
||||
throw error
|
||||
}
|
||||
const result = await requestStrict({
|
||||
url: '/genealogy/app/region/children',
|
||||
url: '/genealogy/region/children',
|
||||
method: 'GET',
|
||||
data: { parentCode: normalizeRegionParentCode(parentCode) }
|
||||
}, {
|
||||
@@ -2119,6 +2334,46 @@ export const appApi = {
|
||||
})
|
||||
return normalizeRegionSelectorItems(result)
|
||||
},
|
||||
async getRegionPath(regionCode, requestOptions = {}) {
|
||||
requireRemoteResource('行政区划路径', '读取')
|
||||
const result = await requestStrict({
|
||||
url: `/genealogy/region/path/${encodeURIComponent(normalizeRegionCode(regionCode))}`,
|
||||
method: 'GET'
|
||||
}, {
|
||||
requestController: requestOptions.requestController ?? null
|
||||
})
|
||||
if (!Array.isArray(result)) {
|
||||
throw createRequestError('行政区划路径响应不是列表', 'REGION_PATH_RESPONSE_INVALID')
|
||||
}
|
||||
return result
|
||||
},
|
||||
async searchRegions(payload, requestOptions = {}) {
|
||||
requireRemoteResource('行政区划搜索', '读取')
|
||||
const result = await requestStrict({
|
||||
url: '/genealogy/region/search',
|
||||
method: 'GET',
|
||||
data: normalizeRegionSearchPayload(payload)
|
||||
}, {
|
||||
requestController: requestOptions.requestController ?? null
|
||||
})
|
||||
if (!Array.isArray(result)) {
|
||||
throw createRequestError('行政区划搜索响应不是列表', 'REGION_SEARCH_RESPONSE_INVALID')
|
||||
}
|
||||
return result
|
||||
},
|
||||
async getRegion(regionCode, requestOptions = {}) {
|
||||
requireRemoteResource('行政区划详情', '读取')
|
||||
const result = await requestStrict({
|
||||
url: `/genealogy/region/${encodeURIComponent(normalizeRegionCode(regionCode))}`,
|
||||
method: 'GET'
|
||||
}, {
|
||||
requestController: requestOptions.requestController ?? null
|
||||
})
|
||||
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
||||
throw createRequestError('行政区划详情响应不是对象', 'REGION_DETAIL_RESPONSE_INVALID')
|
||||
}
|
||||
return result
|
||||
},
|
||||
async getMyGenealogies(requestOptions = {}) {
|
||||
if (!hasRemoteConfig()) {
|
||||
const error = new Error('我的家谱需要真实读取服务,当前本地预览不会伪造家谱列表')
|
||||
@@ -2133,6 +2388,16 @@ export const appApi = {
|
||||
})
|
||||
return normalizeMyGenealogies(result)
|
||||
},
|
||||
async getGenealogyQuota(requestOptions = {}) {
|
||||
requireRemoteResource('家谱配额', '读取')
|
||||
const result = await requestStrict({
|
||||
url: '/genealogy/app/genealogies/quota',
|
||||
method: 'GET'
|
||||
}, {
|
||||
requestController: requestOptions.requestController ?? null
|
||||
})
|
||||
return normalizeGenealogyQuota(result)
|
||||
},
|
||||
async createGenealogy(payload, requestOptions = {}) {
|
||||
const data = normalizeGenealogyCreatePayload(payload)
|
||||
if (!hasRemoteConfig()) {
|
||||
@@ -2820,6 +3085,40 @@ export const appApi = {
|
||||
if (!hasRemoteConfig()) return listCeremonyFixtures(normalizedGenealogyId)
|
||||
return readRemoteList(`/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies`, '礼仪活动', requestOptions)
|
||||
},
|
||||
async replaceCeremonyInvitees(genealogyId, ceremonyId, payload, requestOptions = {}) {
|
||||
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
||||
const normalizedCeremonyId = normalizeFeedCommentId(ceremonyId, '礼仪活动标识')
|
||||
return writeRemoteObject(
|
||||
`/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}/invitees`,
|
||||
'PUT',
|
||||
normalizeCeremonyInviteePayload(payload),
|
||||
'活动受邀人',
|
||||
requestOptions
|
||||
)
|
||||
},
|
||||
async getCeremonyInvitations(genealogyId, ceremonyId, requestOptions = {}) {
|
||||
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
||||
const normalizedCeremonyId = normalizeFeedCommentId(ceremonyId, '礼仪活动标识')
|
||||
return readRemoteList(
|
||||
`/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}/invitations`,
|
||||
'活动邀请名单',
|
||||
requestOptions
|
||||
)
|
||||
},
|
||||
async respondToCeremonyInvitation(genealogyId, ceremonyId, payload, requestOptions = {}) {
|
||||
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
||||
const normalizedCeremonyId = normalizeFeedCommentId(ceremonyId, '礼仪活动标识')
|
||||
return writeRemoteObject(
|
||||
`/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}/invitations/me`,
|
||||
'PUT',
|
||||
normalizeCeremonyInvitationResponsePayload(payload),
|
||||
'活动邀请响应',
|
||||
requestOptions
|
||||
)
|
||||
},
|
||||
async getMyCeremonyInvitations(requestOptions = {}) {
|
||||
return readRemoteList('/genealogy/app/genealogies/ceremony-invitations/mine', '我的活动邀请', requestOptions)
|
||||
},
|
||||
async getCeremonyDetail(genealogyId, ceremonyId, requestOptions = {}) {
|
||||
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
|
||||
const normalizedCeremonyId = normalizeFeedCommentId(ceremonyId, '礼仪活动标识')
|
||||
@@ -3014,6 +3313,19 @@ export const appApi = {
|
||||
if (!hasRemoteConfig()) return listNotificationFixtures()
|
||||
return readRemoteList('/genealogy/app/notifications', '消息通知', requestOptions)
|
||||
},
|
||||
async getUnreadNotificationCount(requestOptions = {}) {
|
||||
requireRemoteResource('未读通知数量', '读取')
|
||||
const value = await requestStrict({
|
||||
url: '/genealogy/app/notifications/unread-count',
|
||||
method: 'GET'
|
||||
}, {
|
||||
requestController: requestOptions.requestController ?? null
|
||||
})
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw createRequestError('未读通知数量响应无效', 'NOTIFICATION_COUNT_RESPONSE_INVALID')
|
||||
}
|
||||
return value
|
||||
},
|
||||
async markNotificationRead(notificationId, requestOptions = {}) {
|
||||
const normalizedNotificationId = normalizeResourcePathId(notificationId, '通知标识')
|
||||
if (hasRemoteConfig()) {
|
||||
|
||||
@@ -9,16 +9,16 @@ const requireFunction = (value, name) => {
|
||||
};
|
||||
|
||||
export const createAuthSmsCooldown = ({
|
||||
sceneCode,
|
||||
operationCode,
|
||||
onChange,
|
||||
now = Date.now,
|
||||
setIntervalFn = setInterval,
|
||||
clearIntervalFn = clearInterval,
|
||||
}) => {
|
||||
if (typeof sceneCode !== "string" || !sceneCode.trim()) {
|
||||
throw new TypeError("sceneCode must be a non-empty string");
|
||||
if (typeof operationCode !== "string" || !operationCode.trim()) {
|
||||
throw new TypeError("operationCode must be a non-empty string");
|
||||
}
|
||||
const normalizedSceneCode = sceneCode.trim();
|
||||
const normalizedOperationCode = operationCode.trim();
|
||||
const publish = requireFunction(onChange, "onChange");
|
||||
const readNow = requireFunction(now, "now");
|
||||
const scheduleInterval = requireFunction(setIntervalFn, "setIntervalFn");
|
||||
@@ -32,13 +32,13 @@ export const createAuthSmsCooldown = ({
|
||||
};
|
||||
|
||||
const sync = () => {
|
||||
const expiresAt = sceneExpiresAt.get(normalizedSceneCode) || 0;
|
||||
const expiresAt = sceneExpiresAt.get(normalizedOperationCode) || 0;
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
Math.ceil((expiresAt - Number(readNow())) / 1000),
|
||||
);
|
||||
if (remaining === 0) {
|
||||
sceneExpiresAt.delete(normalizedSceneCode);
|
||||
sceneExpiresAt.delete(normalizedOperationCode);
|
||||
stopTimer();
|
||||
} else if (timer === null) {
|
||||
timer = scheduleInterval(sync, 1000);
|
||||
@@ -52,7 +52,7 @@ export const createAuthSmsCooldown = ({
|
||||
throw new TypeError("cooldown seconds must be an integer from 1 to 300");
|
||||
}
|
||||
sceneExpiresAt.set(
|
||||
normalizedSceneCode,
|
||||
normalizedOperationCode,
|
||||
Number(readNow()) + seconds * 1000,
|
||||
);
|
||||
return sync();
|
||||
|
||||
+24
-15
@@ -1,7 +1,8 @@
|
||||
export const AUTH_TAC_SCENE = Object.freeze({
|
||||
SMS_LOGIN: "APP_SMS_LOGIN",
|
||||
REGISTER: "APP_REGISTER",
|
||||
FORGOT_PASSWORD: "APP_FORGOT_PASSWORD",
|
||||
export const AUTH_VERIFICATION_OPERATION = Object.freeze({
|
||||
PASSWORD_LOGIN: "password-login",
|
||||
SMS_LOGIN: "sms-login",
|
||||
REGISTER: "register",
|
||||
FORGOT_PASSWORD: "forgot-password",
|
||||
});
|
||||
|
||||
const SUPPORTED_TAC_TYPES = new Set([
|
||||
@@ -35,19 +36,24 @@ export const assertSmsCode = (value) => {
|
||||
return value;
|
||||
};
|
||||
|
||||
export const normalizeCaptchaRequirement = (value, expectedSceneCode) => {
|
||||
const assertAuthVerificationOperation = (operationCode) => {
|
||||
if (!Object.values(AUTH_VERIFICATION_OPERATION).includes(operationCode)) {
|
||||
throw contractError("认证动作不属于当前认证合同", "AUTH_OPERATION_INVALID");
|
||||
}
|
||||
return operationCode;
|
||||
};
|
||||
|
||||
export const normalizeCaptchaRequirement = (value) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw contractError("行为验证策略响应无效", "AUTH_TAC_REQUIREMENT_INVALID");
|
||||
}
|
||||
const sceneCode = requireText(value.sceneCode, "行为验证场景");
|
||||
if (sceneCode !== expectedSceneCode) {
|
||||
throw contractError("行为验证场景与当前操作不匹配", "AUTH_TAC_SCENE_MISMATCH");
|
||||
if (value.required === false) {
|
||||
return { required: false, sceneCode };
|
||||
}
|
||||
// 短信接口把 validToken 定义为必填,因此 required=false 不能在客户端被解释成
|
||||
// “跳过验证”。后端必须为短信场景启用 TAC,或另行签发可消费的免验证票据。
|
||||
if (value.required !== true) {
|
||||
throw contractError(
|
||||
"服务端未要求行为验证,无法取得发送短信所需票据",
|
||||
"行为验证策略缺少 required 布尔值",
|
||||
"AUTH_TAC_POLICY_INCOMPLETE",
|
||||
);
|
||||
}
|
||||
@@ -71,11 +77,14 @@ export const createTacRenderContext = ({
|
||||
baseUrl,
|
||||
clientId,
|
||||
tenantId,
|
||||
sceneCode,
|
||||
operationCode,
|
||||
subject,
|
||||
requirement,
|
||||
}) => {
|
||||
const normalizedRequirement = normalizeCaptchaRequirement(requirement, sceneCode);
|
||||
const normalizedRequirement = normalizeCaptchaRequirement(requirement);
|
||||
if (!normalizedRequirement.required) {
|
||||
throw contractError("当前认证动作无需行为验证", "AUTH_TAC_NOT_REQUIRED");
|
||||
}
|
||||
const normalizedBaseUrl = requireText(baseUrl, "后端地址").replace(/\/+$/, "");
|
||||
if (!/^https:\/\/[^/]+/i.test(normalizedBaseUrl)) {
|
||||
throw contractError("行为验证只允许使用 HTTPS 后端地址", "AUTH_TAC_HTTPS_REQUIRED");
|
||||
@@ -87,11 +96,11 @@ export const createTacRenderContext = ({
|
||||
return {
|
||||
requestId: requireText(requestId, "验证请求标识"),
|
||||
baseUrl: normalizedBaseUrl,
|
||||
challengeUrl: `${normalizedBaseUrl}/captcha/challenge`,
|
||||
verifyUrl: `${normalizedBaseUrl}/captcha/verify`,
|
||||
challengeUrl: `${normalizedBaseUrl}/genealogy/app/auth/verification/${assertAuthVerificationOperation(operationCode)}/challenge`,
|
||||
verifyUrl: `${normalizedBaseUrl}/genealogy/app/auth/verification/${assertAuthVerificationOperation(operationCode)}/verify`,
|
||||
clientId: requireText(clientId, "客户端标识"),
|
||||
tenantId: requireText(tenantId, "租户标识"),
|
||||
sceneCode: normalizedRequirement.sceneCode,
|
||||
operationCode,
|
||||
subject: normalizedSubject,
|
||||
providerCode: normalizedRequirement.providerCode,
|
||||
captchaType: normalizedRequirement.captchaType,
|
||||
|
||||
@@ -359,7 +359,7 @@ export const ROUTES = Object.freeze({
|
||||
path: "/pages/profile/m07-feedback",
|
||||
kind: "flow",
|
||||
parent: "M06",
|
||||
allowedSources: ["M06"],
|
||||
allowedSources: ["M01", "M06"],
|
||||
}),
|
||||
M08: defineRoute({
|
||||
path: "/pages/profile/m08-promotion",
|
||||
|
||||
+38
-2
@@ -633,6 +633,34 @@ const assertResultSourceContext = (targetRoute, targetParams, sourcePage) => {
|
||||
}
|
||||
};
|
||||
|
||||
const isImplicitRootOptionalContext = (
|
||||
routeKey,
|
||||
route,
|
||||
existingParams,
|
||||
targetParams,
|
||||
name,
|
||||
sourcePage,
|
||||
) => {
|
||||
if (
|
||||
!ROOT_ROUTE_KEYS.includes(routeKey) ||
|
||||
!route.optionalParams.includes(name) ||
|
||||
hasOwn(existingParams, name) ||
|
||||
!hasOwn(targetParams, name)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sourceRouteKey = getPageRouteKey(sourcePage);
|
||||
const sourceRoute = getRoute(sourceRouteKey);
|
||||
if (!sourceRoute) return false;
|
||||
const sourceParams = validateRouteParams(
|
||||
sourceRouteKey,
|
||||
getPageParams(sourcePage, sourceRoute),
|
||||
true,
|
||||
).params;
|
||||
return hasOwn(sourceParams, name) && sourceParams[name] === targetParams[name];
|
||||
};
|
||||
|
||||
const returnToValidated = (routeKey, targetParams, result) => {
|
||||
const validatedTarget = validateRouteParams(
|
||||
routeKey,
|
||||
@@ -656,8 +684,16 @@ const returnToValidated = (routeKey, targetParams, result) => {
|
||||
targetIdentity = validatedExisting.params;
|
||||
for (const [name, value] of Object.entries(normalizedTargetParams)) {
|
||||
if (
|
||||
!hasOwn(validatedExisting.params, name) ||
|
||||
validatedExisting.params[name] !== value
|
||||
(!hasOwn(validatedExisting.params, name) ||
|
||||
validatedExisting.params[name] !== value) &&
|
||||
!isImplicitRootOptionalContext(
|
||||
routeKey,
|
||||
route,
|
||||
validatedExisting.params,
|
||||
normalizedTargetParams,
|
||||
name,
|
||||
sourcePage,
|
||||
)
|
||||
) {
|
||||
throw new Error(`${routeKey} 显式目标参数 ${name} 与最近实例不一致`);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ const createUploadError = (message, code) => {
|
||||
|
||||
export const isImagePickCancelled = (error) => error?.code === "IMAGE_PICK_CANCELLED";
|
||||
|
||||
const createUploadId = () => {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
|
||||
return `app-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
};
|
||||
|
||||
const pickNativeImage = () => new Promise((resolve, reject) => {
|
||||
if (!globalThis.plus?.gallery?.pick) {
|
||||
reject(createUploadError("当前运行环境不支持从相册选择图片", "IMAGE_PICK_UNAVAILABLE"));
|
||||
@@ -21,6 +26,53 @@ const pickNativeImage = () => new Promise((resolve, reject) => {
|
||||
);
|
||||
});
|
||||
|
||||
const pickBrowserImage = () => new Promise((resolve, reject) => {
|
||||
if (typeof globalThis.document?.createElement !== "function" || typeof globalThis.FileReader !== "function") {
|
||||
reject(createUploadError("当前运行环境不支持选择图片", "IMAGE_PICK_UNAVAILABLE"));
|
||||
return;
|
||||
}
|
||||
const input = globalThis.document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.style.display = "none";
|
||||
const cleanup = () => input.remove();
|
||||
input.addEventListener("cancel", () => {
|
||||
cleanup();
|
||||
reject(createUploadError("已取消选择图片", "IMAGE_PICK_CANCELLED"));
|
||||
}, { once: true });
|
||||
input.addEventListener("change", () => {
|
||||
const browserFile = input.files?.[0];
|
||||
if (!browserFile) {
|
||||
cleanup();
|
||||
reject(createUploadError("已取消选择图片", "IMAGE_PICK_CANCELLED"));
|
||||
return;
|
||||
}
|
||||
const reader = new globalThis.FileReader();
|
||||
reader.onload = () => {
|
||||
const data = reader.result;
|
||||
cleanup();
|
||||
if (!(data instanceof ArrayBuffer)) {
|
||||
reject(createUploadError("读取图片数据失败", "IMAGE_READ_INVALID"));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
browserFile,
|
||||
fileName: browserFile.name || "image",
|
||||
size: browserFile.size,
|
||||
contentType: browserFile.type || "image/*",
|
||||
data,
|
||||
});
|
||||
};
|
||||
reader.onerror = () => {
|
||||
cleanup();
|
||||
reject(createUploadError("读取图片数据失败", "IMAGE_READ_FAILED"));
|
||||
};
|
||||
reader.readAsArrayBuffer(browserFile);
|
||||
}, { once: true });
|
||||
globalThis.document.body?.append(input);
|
||||
input.click();
|
||||
});
|
||||
|
||||
const readNativeImage = (filePath) => new Promise((resolve, reject) => {
|
||||
if (!globalThis.plus?.io?.resolveLocalFileSystemURL || !globalThis.plus?.io?.FileReader) {
|
||||
reject(createUploadError("当前运行环境不支持读取图片", "IMAGE_READ_UNAVAILABLE"));
|
||||
@@ -54,11 +106,6 @@ const readNativeImage = (filePath) => new Promise((resolve, reject) => {
|
||||
);
|
||||
});
|
||||
|
||||
const createUploadId = () => {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
|
||||
return `app-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
};
|
||||
|
||||
export const toConsumerOssId = (ossId) => {
|
||||
if (typeof ossId !== "string" || !/^[1-9]\d*$/.test(ossId)) {
|
||||
throw createUploadError("文件服务返回的文件 ID 无效", "OSS_ID_INVALID");
|
||||
@@ -89,15 +136,15 @@ const toUploadReceipt = ({ ossId, url = "", thumbnailUrl = "", fileName = "" })
|
||||
});
|
||||
|
||||
export const pickAndUploadImage = async ({ requestController = null } = {}) => {
|
||||
const filePath = await pickNativeImage();
|
||||
const file = await readNativeImage(filePath);
|
||||
const file = globalThis.plus?.gallery?.pick
|
||||
? await readNativeImage(await pickNativeImage())
|
||||
: await pickBrowserImage();
|
||||
if (!Number.isSafeInteger(file.size) || file.size <= 0) {
|
||||
throw createUploadError("所选图片大小无效", "IMAGE_SIZE_INVALID");
|
||||
}
|
||||
const fileMd5 = calcMD5Bytes(file.data);
|
||||
const uploadId = createUploadId();
|
||||
const initPayload = {
|
||||
uploadId,
|
||||
uploadId: createUploadId(),
|
||||
fileName: file.fileName,
|
||||
fileMd5,
|
||||
totalSize: file.size,
|
||||
@@ -107,12 +154,16 @@ export const pickAndUploadImage = async ({ requestController = null } = {}) => {
|
||||
};
|
||||
const initialized = await appApi.initializeResumableUpload(initPayload, { requestController });
|
||||
if (initialized.instant) return toUploadReceipt(initialized);
|
||||
await appApi.uploadResumableChunk({
|
||||
const chunkPayload = {
|
||||
uploadId: initialized.uploadId,
|
||||
chunkIndex: 0,
|
||||
chunkMd5: fileMd5,
|
||||
filePath: file.filePath,
|
||||
}, { requestController });
|
||||
};
|
||||
if (file.browserFile) {
|
||||
await appApi.uploadBrowserResumableChunk(chunkPayload, file.browserFile, { requestController });
|
||||
} else {
|
||||
await appApi.uploadResumableChunk({ ...chunkPayload, filePath: file.filePath }, { requestController });
|
||||
}
|
||||
const completed = await appApi.completeResumableUpload({
|
||||
uploadId: initialized.uploadId,
|
||||
fileName: file.fileName,
|
||||
|
||||
Reference in New Issue
Block a user