Files
airz-uniapp/services/agreements.uts
T
hujun c95482a0f3 feat: 实现协议全文与同意记录功能
新增协议全文后台管理、版本化发布及本人同意记录功能,支持可撤销授权;完善资料与授权相关边界提示,调整评估提交时的资料与授权校验逻辑,同步更新相关页面文案与路由。
2026-10-14 14:53:13 +08:00

121 lines
8.8 KiB
Plaintext

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 })
}