feat: 实现协议全文与同意记录功能

新增协议全文后台管理、版本化发布及本人同意记录功能,支持可撤销授权;完善资料与授权相关边界提示,调整评估提交时的资料与授权校验逻辑,同步更新相关页面文案与路由。
This commit is contained in:
hujun
2026-10-14 14:53:13 +08:00
parent b418969e92
commit c95482a0f3
58 changed files with 3269 additions and 146 deletions
+20
View File
@@ -0,0 +1,20 @@
// 主包共享协议路由与会话守卫,账户分包不依赖融资分包。
export function agreementScope(code: string): string {
if (code == 'terms') return 'account-terms'
if (code == 'privacy') return 'account-privacy'
if (code == 'document-use') return 'documents'
if (code == 'data-authorization') return 'data'
return ''
}
export function agreementVersionValid(value: string): boolean { return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value) }
export function agreementRecordIdValid(value: string): boolean {
return /^[1-9][0-9]{0,18}$/.test(value) && !(value.length == 19 && value > '9223372036854775807')
}
export function agreementRoute(code: string, version: string = ''): string {
if (agreementScope(code).length == 0 || (version.length > 0 && !agreementVersionValid(version))) return ''
return '/pages/agreement/agreement?code=' + code + (version.length > 0 ? '&version=' + encodeURIComponent(version) : '')
}
export const optionalConsentScope = (scope: string): boolean => scope == 'documents' || scope == 'data'
export function sameConsentSession(token: string, userId: string, currentToken: string, currentUserId: string): boolean {
return token.length > 0 && userId.length > 0 && token == currentToken && userId == currentUserId
}
+120
View File
@@ -0,0 +1,120 @@
import { ApiRequestError, getSession, requestJson } from './api.uts'
import { agreementScope, agreementVersionValid, agreementRecordIdValid, agreementRoute, optionalConsentScope, sameConsentSession } from './agreements-route.uts'
export { agreementScope, agreementRoute, optionalConsentScope, sameConsentSession } from './agreements-route.uts'
export type Agreement = { revisionId: string, code: string, version: string, scope: string, title: string, content: string, contentHash: string, effectiveAt: string }
export type Consent = { consentId: string, scope: string, agreementCode: string, agreementVersion: string, contentHash: string, grantedAt: string, revokedAt: string, canRevoke: boolean }
export type ConsentPage = { items: Consent[], page: number, pageSize: number, total: number, hasMore: boolean }
const scopes = ['account-terms', 'account-privacy', 'documents', 'data']
const validHash = (value: string): boolean => /^[a-f0-9]{64}$/.test(value)
const validTime = (value: string): boolean => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(value) && !isNaN(Date.parse(value))
const validKey = (value: string): boolean => /^[A-Za-z0-9_-]{16,64}$/.test(value)
export function consentRequestId(): string { return 'consent_' + Date.now().toString() + '_' + Math.floor(Math.random() * 1000000000).toString() }
export function emptyAgreement(): Agreement { return { revisionId: '', code: '', version: '', scope: '', title: '', content: '', contentHash: '', effectiveAt: '' } }
export function agreementUnavailable(error: any | null): boolean { return error instanceof ApiRequestError && error.status == 404 && error.errorCode == 'AGREEMENT_NOT_AVAILABLE' }
export function agreementChanged(error: any | null): boolean { return error instanceof ApiRequestError && error.status == 409 && error.errorCode == 'AGREEMENT_CHANGED' }
export const consentTitle = (code: string): string => {
if (code == 'terms') return '用户协议'
if (code == 'privacy') return '隐私政策'
if (code == 'document-use') return '资料使用协议'
if (code == 'data-authorization') return '数据授权协议'
return '协议'
}
export const consentTime = (value: string): string => value.length == 0 ? '' : value.replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC').replace('Z', ' UTC')
function validateAgreement(item: Agreement): void {
if (!agreementRecordIdValid(item.revisionId) || agreementScope(item.code).length == 0 || item.scope != agreementScope(item.code)
|| !agreementVersionValid(item.version) || !validHash(item.contentHash) || item.title.trim().length == 0
|| item.content.trim().length == 0 || !validTime(item.effectiveAt)) throw new Error('协议响应不完整,请重新加载')
}
function agreementView(data: UTSJSONObject): Agreement {
const item: Agreement = { revisionId: data.getString('revisionId') ?? '', code: data.getString('code') ?? '', version: data.getString('version') ?? '',
scope: data.getString('scope') ?? '', title: data.getString('title') ?? '', content: data.getString('content') ?? '',
contentHash: data.getString('contentHash') ?? '', effectiveAt: data.getString('effectiveAt') ?? '' }
validateAgreement(item)
return item
}
function consentView(data: UTSJSONObject): Consent {
const revokedValue = data['revokedAt']
if (revokedValue != null && typeof revokedValue != 'string') throw new Error('同意记录撤销时间无效')
const revokedAt = revokedValue == null ? '' : revokedValue as string
const flag = data.getBoolean('canRevoke')
const item: Consent = { consentId: data.getString('consentId') ?? '', scope: data.getString('scope') ?? '', agreementCode: data.getString('agreementCode') ?? '',
agreementVersion: data.getString('agreementVersion') ?? '', contentHash: data.getString('contentHash') ?? '',
grantedAt: data.getString('grantedAt') ?? '', revokedAt, canRevoke: flag ?? false }
if (!agreementRecordIdValid(item.consentId) || agreementScope(item.agreementCode).length == 0 || item.scope != agreementScope(item.agreementCode)
|| !agreementVersionValid(item.agreementVersion) || !validHash(item.contentHash) || !validTime(item.grantedAt)
|| (revokedAt.length > 0 && !validTime(revokedAt)) || flag == null
|| (item.canRevoke && (!optionalConsentScope(item.scope) || revokedAt.length > 0))) throw new Error('同意记录响应不完整,请重新加载')
return item
}
function sessionOwner(): UTSJSONObject {
const session = getSession()
if (session == null) throw new Error('请先登录后管理同意记录')
return session
}
function checkOwner(owner: UTSJSONObject): void {
const current = getSession()
if (!sameConsentSession(owner.getString('token') ?? '', owner.getString('userId') ?? '', current?.getString('token') ?? '', current?.getString('userId') ?? ''))
throw new Error('登录状态已变化,请重新操作')
}
export async function fetchAgreement(code: string, version: string = ''): Promise<Agreement> {
if (agreementRoute(code, version).length == 0) throw new Error('协议参数无效,请返回重新选择')
const query: UTSJSONObject = {}
if (version.length > 0) query['version'] = version
const item = agreementView(await requestJson('/agreements/' + code, 'GET', query))
if (item.code != code || (version.length > 0 && item.version != version)) throw new Error('协议版本不一致,请重新加载')
return item
}
export async function grantConsent(agreement: Agreement, requestId: string): Promise<Consent> {
const owner = sessionOwner()
validateAgreement(agreement)
if (!validKey(requestId)) throw new Error('同意请求标识无效')
const item = consentView(await requestJson('/consents', 'POST', { scope: agreement.scope, agreementCode: agreement.code, agreementVersion: agreement.version, requestId }))
checkOwner(owner)
if (item.scope != agreement.scope || item.agreementCode != agreement.code || item.agreementVersion != agreement.version || item.contentHash != agreement.contentHash
|| (item.revokedAt.length > 0 && !optionalConsentScope(item.scope)))
throw new Error('同意回执未能核对,请用原请求重试')
return item
}
export async function fetchConsents(scope: string = '', page: number = 1, pageSize: number = 20): Promise<ConsentPage> {
const owner = sessionOwner()
if ((scope.length > 0 && !scopes.includes(scope)) || !Number.isInteger(page) || page < 1 || page > 1000 || !Number.isInteger(pageSize) || pageSize < 1 || pageSize > 50)
throw new Error('同意记录查询参数无效')
const query: UTSJSONObject = { page: page.toString(), pageSize: pageSize.toString() }
if (scope.length > 0) query['scope'] = scope
const data = await requestJson('/consents', 'GET', query)
checkOwner(owner)
const raw = data['items']; const total = data.getNumber('total'); const hasMore = data.getBoolean('hasMore')
if (raw == null || !Array.isArray(raw) || data.getNumber('page') != page || data.getNumber('pageSize') != pageSize
|| total == null || !Number.isInteger(total) || total < 0 || hasMore == null) throw new Error('同意记录分页响应无效')
const rows = raw as UTSJSONObject[]
const items = rows.map((row: UTSJSONObject): Consent => {
if (row == null || typeof row != 'object' || Array.isArray(row)) throw new Error('同意记录格式无效')
const item = consentView(row)
if (scope.length > 0 && item.scope != scope) throw new Error('同意记录范围不一致')
return item
})
return { items, page, pageSize, total, hasMore }
}
export async function revokeConsent(consent: Consent, requestId: string): Promise<Consent> {
const owner = sessionOwner()
if (!agreementRecordIdValid(consent.consentId) || !optionalConsentScope(consent.scope) || !consent.canRevoke || consent.revokedAt.length > 0 || !validKey(requestId))
throw new Error('仅可撤销当前有效的可选授权')
const item = consentView(await requestJson('/consents/' + consent.consentId + '/revoke', 'POST', { requestId }))
checkOwner(owner)
if (item.consentId != consent.consentId || item.scope != consent.scope || item.agreementCode != consent.agreementCode
|| item.agreementVersion != consent.agreementVersion || item.contentHash != consent.contentHash || item.grantedAt != consent.grantedAt || item.revokedAt.length == 0 || item.canRevoke)
throw new Error('撤销回执未能核对,请用原请求重试')
return item
}
export function showAgreementNotice(title: string): void {
let code = ''
if (title == '用户协议') code = 'terms'
else if (title == '隐私政策' || title == '隐私说明') code = 'privacy'
else if (title == '资料使用协议') code = 'document-use'
else if (title == '数据授权协议') code = 'data-authorization'
const url = agreementRoute(code)
if (url.length > 0) uni.navigateTo({ url })
}
+4 -4
View File
@@ -100,14 +100,14 @@ const requestJson = (path: string, method: RequestMethod, payload: UTSJSONObject
export { apiMessage, requestJson, wait }
export function uploadJson(path: string, filePath: string): Promise<UTSJSONObject> {
export function uploadJson(path: string, filePath: string, formData: UTSJSONObject = {}, failureMessage: string = '头像上传失败'): Promise<UTSJSONObject> {
return new Promise<UTSJSONObject>((resolve, reject) => {
const sentToken = currentToken()
if (sentToken.length == 0) { reject(new Error('请先登录')); return }
if (API_BASE_URL.length == 0 || (APP_ENVIRONMENT == 'production' && !API_BASE_URL.startsWith('https://'))) {
reject(new Error('服务地址尚未配置')); return
}
uni.uploadFile({ url: API_BASE_URL + path, filePath, name: 'file', timeout: REQUEST_TIMEOUT,
uni.uploadFile({ url: API_BASE_URL + path, filePath, name: 'file', formData, timeout: REQUEST_TIMEOUT,
header: { Authorization: 'Bearer ' + sentToken, 'X-Requested-With': 'XMLHttpRequest' },
success: (response) => {
if (currentToken() != sentToken) { reject(new Error('登录状态已变化,请重新操作')); return }
@@ -117,13 +117,13 @@ export function uploadJson(path: string, filePath: string): Promise<UTSJSONObjec
if (body == null) throw new Error('上传响应格式错误')
if (response.statusCode < 200 || response.statusCode >= 300 || body['code'] != 1) {
if (body['code'] == 401) clearSession()
throw new Error((body['msg'] as string | null) ?? '头像上传失败')
throw new Error((body['msg'] as string | null) ?? failureMessage)
}
const data = body['data'] as UTSJSONObject | null
if (data == null) throw new Error('上传响应不完整')
resolve(data)
} catch (error) { reject(error) }
}, fail: () => { reject(new Error('头像上传失败,请检查网络后重试')) }
}, fail: () => { reject(new Error(failureMessage + ',请检查网络后手动重试')) }
})
})
}
+8 -4
View File
@@ -3,6 +3,7 @@ import { readAssessmentFlow, writeAssessmentFlow, clearAssessmentFlow } from './
export { clearAssessmentFlow } from './assessment-flow.uts'
import { contributionBaseVisible, contributionBaseValid, contributionBaseYuan } from './contribution.uts'
import { wanAmountValid, wanToYuan, wanToCents } from './money.uts'
import { showAgreementNotice as openAgreementNotice } from './agreements.uts'
export type AssessmentForm = {
name: string, idCard: string, phone: string, city: string, maritalStatus: string,
@@ -12,7 +13,7 @@ export type AssessmentForm = {
creditCardCount: string, creditCardTotalLimit: string, creditCardUsedLimit: string,
otherAssets: string, enterpriseName: string, businessCode: string, annualRevenue: string, operatingYears: string, operatingMonths: string
}
export type SelectedDocument = { name: string, path: string }
export type SelectedDocument = { name: string, path: string, documentId: string, consentId: string, deletePending: boolean }
export type DataAuthorization = { key: string, name: string, selected: boolean }
export function emptyAssessmentForm(): AssessmentForm {
@@ -91,12 +92,15 @@ export function documentsFromInput(saved: UTSJSONObject): SelectedDocument[] {
if (items == null) return results
items.forEach((item) => {
const name = draftText(item, 'name'); const path = draftText(item, 'path')
if (name.length > 0 && path.length > 0) { const document: SelectedDocument = { name, path }; results.push(document) }
const documentId = draftText(item, 'documentId'); const consentId = draftText(item, 'consentId')
if (name.length > 0 && (path.length > 0 || documentId.length > 0)) {
const document: SelectedDocument = { name, path, documentId, consentId, deletePending: item.getBoolean('deletePending') ?? false }; results.push(document)
}
})
return results
}
export function setAssessmentInput(type: string, step: number, form: AssessmentForm, documents: SelectedDocument[], authorizations: DataAuthorization[], documentConsent: boolean, dataConsent: boolean, editAssessmentId: string = '', baseRevisionId: string = ''): void {
const files: UTSJSONObject[] = documents.map((item): UTSJSONObject => ({ name: item.name, path: item.path }))
const files: UTSJSONObject[] = documents.map((item): UTSJSONObject => ({ name: item.name, path: '', documentId: item.documentId, consentId: item.consentId, deletePending: item.deletePending }))
const grants: string[] = authorizations.filter((item) => item.selected).map((item) => item.key)
const payload: UTSJSONObject = {
type, step, editAssessmentId, baseRevisionId, moneyUnit: 'wan', name: form.name, idCard: form.idCard, phone: form.phone, city: form.city, maritalStatus: form.maritalStatus,
@@ -110,7 +114,7 @@ export function setAssessmentInput(type: string, step: number, form: AssessmentF
writeAssessmentFlow(flowToken(), payload)
}
export function showAgreementNotice(title: string): void {
uni.showModal({ title, content: '完整协议将在相关服务开放时提供。当前勾选仅保存您的选择,不会发起实名认证、查询外部数据或上传资料。', showCancel: false, confirmText: '我知道了' })
openAgreementNotice(title)
}
export type AssessmentProgress = { percent: number, basicDone: boolean, incomeDone: boolean, assetsDone: boolean, authorizationSelected: boolean }
+7 -7
View File
@@ -1,8 +1,8 @@
// 发布前选择目标环境并配置 HTTPS 地址;未配置时请求失败,不回退模拟服务。
// 小程序体验版连接远程测试服;切回本机联调时改为 development。
export const APP_ENVIRONMENT: string = 'test' as string
const DEVELOPMENT_API_URL = 'http://127.0.0.1/api/v1'
const TEST_API_URL = 'https://airz.hqling.cloud/api/v1'
const PRODUCTION_API_URL = ''
export const API_BASE_URL = APP_ENVIRONMENT == 'production' ? PRODUCTION_API_URL : (APP_ENVIRONMENT == 'test' ? TEST_API_URL : DEVELOPMENT_API_URL)
// 当前仅使用既有正式服务器;小程序从当前源码重新编译后由用户发行。
// 本机联调副本显式使用 development,test 留空,不自动切换其他环境。
export const APP_ENVIRONMENT: string = 'production' as string
const DEVELOPMENT_API_URL = 'http://127.0.0.1:18580/api/v1'
const TEST_API_URL = ''
const PRODUCTION_API_URL = 'https://airz.hqling.cloud/api/v1'
export const API_BASE_URL = APP_ENVIRONMENT == 'development' ? DEVELOPMENT_API_URL : (APP_ENVIRONMENT == 'test' ? TEST_API_URL : (APP_ENVIRONMENT == 'production' ? PRODUCTION_API_URL : ''))
export const API_DEMO_ENABLED = false
+83
View File
@@ -0,0 +1,83 @@
import { API_BASE_URL, getSession, clearSession, uploadJson, requestJson } from './api.uts'
import { agreementRecordIdValid, sameConsentSession } from './agreements-route.uts'
export const documentCategory = (name: string): string => {
const names = ['身份证正面', '身份证反面', '银行卡', '收入证明', '营业执照', '银行流水', '经营证明']
const categories = ['id-front', 'id-back', 'bank-card', 'income', 'business-license', 'bank-statement', 'business-proof']
const index = names.indexOf(name)
return index < 0 ? '' : categories[index]
}
export type OwnedDocument = { name: string, documentId: string, consentId: string, deletePending: boolean }
export async function fetchOwnedDocuments(): Promise<OwnedDocument[]> {
const sent = owner()
const data = await requestJson('/consents', 'GET', { scope: 'documents', page: 1, pageSize: 1 })
if (!sameOwner(sent)) throw new Error('登录状态已变化,请重新操作')
const rows = data.getArray<UTSJSONObject>('documents')
if (rows == null) throw new Error('已保存照片读取未能确认,请重新读取')
const names = ['身份证正面', '身份证反面', '银行卡', '收入证明', '营业执照', '银行流水', '经营证明']
const items: OwnedDocument[] = []; const ids: string[] = []
rows.forEach((row) => {
const id = row.getString('documentId') ?? ''; const consentId = row.getString('consentId') ?? ''
const category = row.getString('category') ?? ''; const pending = row.getBoolean('deletePending')
const name = names.find((item) => documentCategory(item) == category) ?? ''
if (!agreementRecordIdValid(id) || !agreementRecordIdValid(consentId) || name.length == 0 || pending == null
|| ids.includes(id) || row['path'] != null || row['url'] != null || row['storage_key'] != null)
throw new Error('已保存照片响应无效,请重新读取')
ids.push(id); items.push({ name, documentId: id, consentId, deletePending: pending })
})
return items
}
function owner(): UTSJSONObject {
const current = getSession()
if (current == null) throw new Error('请先登录')
return current
}
function sameOwner(sent: UTSJSONObject): boolean {
const current = getSession()
return sameConsentSession(sent.getString('token') ?? '', sent.getString('userId') ?? '', current?.getString('token') ?? '', current?.getString('userId') ?? '')
}
export async function uploadDocument(path: string, category: string, consentId: string): Promise<string> {
const sent = owner()
if (path.length == 0 || !['id-front', 'id-back', 'bank-card', 'income', 'business-license', 'bank-statement', 'business-proof'].includes(category) || !agreementRecordIdValid(consentId))
throw new Error('照片类别或同意记录无效')
const data = await uploadJson('/documents', path, { category, consentId }, '保存结果未确认,重试可能另存一份照片')
if (!sameOwner(sent)) throw new Error('登录状态已变化,请重新操作')
const id = data.getString('documentId') ?? ''; const size = data.getNumber('byteSize') ?? 0
if (!agreementRecordIdValid(id) || data.getString('category') != category || data.getString('mimeType') != 'image/png'
|| !Number.isInteger(size) || size <= 0 || size > 5242880 || data['url'] != null || data['path'] != null)
throw new Error('保存回执未能确认,请手动重试;可能另存一份照片')
return id
}
export function releaseDocumentTemp(path: string): void {
if (path.length == 0) return
// 不保存到相册或持久缓存。原生端尽力移除下载临时文件;H5对象URL由运行时管理。
// #ifndef WEB
uni.getFileSystemManager().unlink({ filePath: path, fail: () => {} })
// #endif
}
export function downloadDocument(id: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
let sent: UTSJSONObject
try { sent = owner(); if (!agreementRecordIdValid(id)) throw new Error('资料编号无效') }
catch (error) { reject(error); return }
uni.downloadFile({ url: API_BASE_URL + '/documents/' + id + '/content', timeout: 10000,
header: { Authorization: 'Bearer ' + (sent.getString('token') ?? ''), 'X-Requested-With': 'XMLHttpRequest' },
success: (result) => {
if (!sameOwner(sent)) { releaseDocumentTemp(result.tempFilePath); reject(new Error('登录状态已变化,请重新操作')); return }
if (result.statusCode != 200 || result.tempFilePath.length == 0) {
releaseDocumentTemp(result.tempFilePath)
if (result.statusCode == 401) clearSession()
reject(new Error('资料读取失败,请重新登录或稍后重试')); return
}
resolve(result.tempFilePath)
}, fail: () => { reject(new Error('资料读取失败,请检查网络后重试')) }
})
})
}
export async function deleteDocument(id: string): Promise<void> {
const sent = owner()
if (!agreementRecordIdValid(id)) throw new Error('资料编号无效')
const data = await requestJson('/documents/' + id + '/delete', 'POST', {})
if (!sameOwner(sent)) throw new Error('登录状态已变化,请重新操作')
if (data.getString('documentId') != id || data.getBoolean('deleted') != true) throw new Error('删除未能确认,请对同一资料重试')
}