完成80%

This commit is contained in:
2026-07-27 06:50:07 +08:00
parent 8475bbd19a
commit 1eae3bbef4
63 changed files with 13664 additions and 280 deletions
+332 -20
View File
@@ -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()) {
+7 -7
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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} 与最近实例不一致`);
}
+63 -12
View File
@@ -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,