Files
airz-uniapp/packages/finance/services/matches.uts
T
hujun 71b3c25695 feat: 优化账户切换时的页面状态一致性,增加登录回跳校验和课程阅读新逻辑
- 增加账户状态监听定时器,页面切换时自动校验账户归属,避免跨账户操作
- 新增登录回跳路径校验函数,限制仅允许指定页面回跳,防止非法跳转
- 课程页改为整篇连续阅读,底部仅一次标记已读,兼容历史记录
- 完善助手、对比页、资料填写页等多处的账户状态校验逻辑,防止错版数据提交
- 优化账户资料请求逻辑,增加版本校验,避免旧请求覆盖新数据
2026-10-07 10:01:05 +08:00

127 lines
15 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { revisionParams } from './records.uts'
import { requestJson } from '@/services/api.uts'
import { yuanToWan } from '@/services/money.uts'
export type ProductView = { id: string, version: number, name: string, bank: string, type: string, demo: boolean, amount: string, rate: string, term: string, objects: string, regions: string, valid: string, materials: string, source: string, sourceRef: string, checkedOn: string, conditions: string[] }
export type MatchReason = { id: string, text: string, evidence: string[], fields: string[], explanation: string }
export type MatchDetail = { assessmentId: string, revisionId: string, resultVersion: number, snapshotId: string, ruleVersion: string, orderingVersion: string, item: MatchView }
export type MatchView = { id: string, state: string, stateLabel: string, availability: string, availabilityLabel: string, product: ProductView, positive: MatchReason[], blocking: MatchReason[], cautions: MatchReason[], aiText: string, aiStatus: string, aiCanRetry: boolean, aiCanRequest: boolean, aiGeneratedAt: string }
export type MatchPage = { assessmentId: string, revisionId: string, resultVersion: number, snapshotId: string, ruleVersion: string, orderingVersion: string, status: string, message: string, eligible: string, ineligible: string, unknown: string, items: MatchView[], hasMore: boolean, cursor: string }
function text(data: UTSJSONObject, key: string): string { return data.getString(key) ?? '' }
export function validRecordId(value: string): boolean { return /^[1-9][0-9]{0,9}$/.test(value) }
export function eligibilityLabel(value: string): string { return value == 'eligible' ? '符合已知条件' : (value == 'ineligible' ? '条件不符' : '待核实') }
const fieldNames: UTSJSONObject = { requestedAmountYuan: '融资金额', requestedTermMonths: '融资期限', applicantType: '申请主体', purposeCode: '资金用途', cityCode: '所在城市', monthlyIncome: '月收入', annualRevenue: '年营收', operatingMonths: '经营月数', totalDebtBalance: '贷款余额', monthlyLoanPayment: '每月还款额', creditLoanCount: '贷款笔数', creditCardCount: '信用卡数量', creditCardTotalLimit: '信用卡总额度', creditCardUsedLimit: '信用卡已用额度', socialSecurity: '社保情况', housingFund: '公积金情况', house: '房产情况', car: '车辆情况' }
const labels: UTSJSONObject = { personal: '个人', enterprise: '企业', daily_consumption: '日常消费', education: '教育进修', home_improvement: '家居装修', personal_business: '个体经营周转', working_capital: '经营周转', inventory_purchase: '采购备货', equipment_purchase: '设备购置', business_expansion: '经营扩展', other: '其他用途', none: '无', under_six_months: '不足六个月', six_months_plus: '六个月及以上', yes: '有', no: '无', '440300': '深圳市', '440100': '广州市', '110100': '北京市', '310100': '上海市', '330100': '杭州市' }
const moneyFields = ['requestedAmountYuan','monthlyIncome','annualRevenue','totalDebtBalance','monthlyLoanPayment','creditCardTotalLimit','creditCardUsedLimit']
function valueText(value: any | null, field: string): string {
if (value == null) return '未提供'
const raw = value.toString()
if (moneyFields.includes(field)) return yuanToWan(raw) + ' 万元'
if (field == 'requestedTermMonths' || field == 'operatingMonths') return raw + ' 个月'
return labels.getString(raw) ?? raw
}
function ruleText(rule: UTSJSONObject): string {
const field = text(rule, 'field'); const op = text(rule, 'operator')
const prefix = fieldNames.getString(field) ?? '条件'
let expected = ''
if (op == 'in' || op == 'between') {
const values = rule['value'] as any[]
expected = values.map((value: any): string => valueText(value, field)).join(op == 'between' ? ' 至 ' : '、')
} else expected = valueText(rule['value'], field)
const operation = op == 'gte' ? '不少于 ' : (op == 'lte' ? '不超过 ' : (op == 'in' ? '属于 ' : ''))
return prefix + ':' + operation + expected + (text(rule, 'requiredSource') == 'verified' ? '(需核验依据)' : '')
}
function productView(data: UTSJSONObject, publicMode: boolean): ProductView {
const source = data['source'] as UTSJSONObject
const objects = data['applicantTypes'] as string[]; const regions = data['regions'] as string[]
const conditions: string[] = []
if (publicMode) {
const groups = data['rulesByApplicantType'] as UTSJSONObject
objects.forEach((type: string) => {
(groups[type] as UTSJSONObject[]).forEach((r: UTSJSONObject) => { conditions.push((labels.getString(type) ?? type) + ' · ' + ruleText(r)) })
})
} else (data['rules'] as UTSJSONObject[]).forEach((r: UTSJSONObject) => { conditions.push(ruleText(r)) })
return { id: text(data, 'productId'), version: data.getNumber('productVersion') ?? 0, name: text(data, 'productName'), bank: text(data, 'bankName'), type: text(data, 'productType'), demo: data.getBoolean('isDemo') ?? false,
amount: text(data, 'amountMinYuan').length == 0 || text(data, 'amountMaxYuan').length == 0 ? '未提供' : yuanToWan(text(data, 'amountMinYuan')) + '–' + yuanToWan(text(data, 'amountMaxYuan')) + ' 万元',
rate: text(data, 'annualRateMinPercent').length == 0 || text(data, 'annualRateMaxPercent').length == 0 ? '未提供' : text(data, 'annualRateMinPercent') + '%–' + text(data, 'annualRateMaxPercent') + '%',
term: data.getNumber('termMinMonths') == null || data.getNumber('termMaxMonths') == null ? '未提供' : data.getNumber('termMinMonths')!.toString() + '–' + data.getNumber('termMaxMonths')!.toString() + ' 个月',
objects: objects.map((v: string): string => labels.getString(v) ?? v).join(' / '), regions: regions.length == 0 ? '全国' : regions.map((v: string): string => labels.getString(v) ?? v).join('、'),
valid: text(data, 'validFrom') + ' 至 ' + text(data, 'validUntil'), materials: text(data, 'information').length > 0 ? text(data, 'information') : '未提供',
// 内部引用用于定位资料来源,不把测试环境标识当作面向用户的产品说明。
source: text(source, 'publisher') + ' · ' + text(source, 'title'), sourceRef: text(source, 'reference').startsWith('demo:airz-') ? '产品规则档案' : text(source, 'reference'), checkedOn: text(source, 'checkedOn'), conditions }
}
function matchView(data: UTSJSONObject): MatchView {
const snapshot = data['productSnapshot'] as UTSJSONObject
const productId = text(data, 'productId'); const version = data.getNumber('productVersion') ?? 0
if (!validRecordId(productId) || text(snapshot, 'productId') != productId || version < 1 || Math.floor(version) != version || snapshot.getNumber('productVersion') != version || !['eligible','ineligible','unknown'].includes(text(data, 'eligibility')) || !['available','unavailable','unknown'].includes(text(data, 'currentAvailability'))) throw new Error('产品冻结资料不完整,请重新加载。')
const evidence = data['evidence'] as UTSJSONObject[]
const ai = data['aiExplanation'] as UTSJSONObject
const explanations = (ai['reasonExplanations'] as UTSJSONObject[] | null) ?? []
const reasons = (key: string): MatchReason[] => (data[key] as UTSJSONObject[]).map((r: UTSJSONObject): MatchReason => {
const reasonFields: string[] = []
const lines = (r['evidenceRefs'] as string[]).map((ref: string): string => {
const e = evidence.find((item: UTSJSONObject): boolean => text(item, 'evidenceId') == ref)
if (e == null) return '证据暂不可读取'
const fields = e['fieldRefs'] as string[]; const field = fields.length > 0 ? fields[0] : ''
fields.forEach((name: string) => { if (!reasonFields.includes(name)) reasonFields.push(name) })
const source = text(e, 'source') == 'verified' ? '外部核验' : (text(e, 'source') == 'product_snapshot' ? '产品快照' : (text(e, 'source') == 'self_reported' ? '用户自填,未经核验' : '来源未知'))
return (fieldNames.getString(field) ?? field) + ':' + valueText(e['actual'], field) + ' · ' + source
})
const explanation = explanations.find((item: UTSJSONObject): boolean => text(item, 'reasonId') == text(r, 'reasonId'))
return { id: text(r, 'reasonId'), text: text(r, 'text'), evidence: lines, fields: reasonFields, explanation: text(ai, 'status') == 'completed' && explanation != null ? text(explanation, 'text') : '' }
})
const availability = text(data, 'currentAvailability')
return { id: text(data, 'productId'), state: text(data, 'eligibility'), stateLabel: eligibilityLabel(text(data, 'eligibility')), availability,
availabilityLabel: availability == 'available' ? '当前产品可查看' : (availability == 'unavailable' ? '当前产品已下架或不在有效期' : '当前产品状态暂无法确认'),
product: productView(data['productSnapshot'] as UTSJSONObject, false), positive: reasons('positiveReasons'), blocking: reasons('blockingReasons'), cautions: reasons('cautions'),
aiText: text(ai, 'status') == 'completed' ? text(ai, 'text') : (text(ai, 'status') == 'failed' ? 'AI解释暂不可用,已保留全部规则依据。' : '当前展示规则依据,尚未生成单产品AI解释。'),
aiStatus: text(ai, 'status'), aiCanRetry: ai.getBoolean('canRetry') ?? false, aiCanRequest: data.getBoolean('canRequestExplanation') ?? false, aiGeneratedAt: text(ai, 'generatedAt') }
}
export async function requestProductExplanation(id: string, productId: string, requestId: string, revisionId: string = ''): Promise<MatchView> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches/' + encodeURIComponent(productId) + '/explanation', 'POST', { ...revisionParams(revisionId), requestId })
return detailRecord(data, id, productId, revisionId).item
}
export async function fetchMatchPage(id: string, group: string, cursor: string, revisionId: string = ''): Promise<MatchPage> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches', 'GET', { ...revisionParams(revisionId), eligibility: group, limit: '3', cursor })
const matching = data['matching'] as UTSJSONObject; const status = text(matching, 'status')
const resolvedRevision = text(data, 'revisionId'); const version = data.getNumber('resultVersion') ?? 0
if (!validRecordId(resolvedRevision) || (revisionId.length > 0 && resolvedRevision != revisionId) || text(data, 'assessmentId') != id || version < 1 || Math.floor(version) != version) throw new Error('匹配结果与本次评估不一致,请重新加载。')
const items = (data['items'] as UTSJSONObject[]).map((v: UTSJSONObject): MatchView => matchView(v))
const hasMore = data.getBoolean('hasMore') ?? false; const nextCursor = text(data, 'nextCursor')
const ids: string[] = []
items.forEach((item: MatchView) => { if (item.state != group || ids.includes(item.id)) throw new Error('匹配分组或产品编号不一致,请重新加载。'); ids.push(item.id) })
if (!['completed','unavailable'].includes(status) || items.length > 3 || (hasMore && (items.length == 0 || nextCursor.length == 0)) || (!hasMore && nextCursor.length > 0) || (status == 'unavailable' && (items.length > 0 || hasMore))) throw new Error('匹配分页状态不完整,请重新加载。')
if (status == 'completed') {
if (text(matching, 'catalogSnapshotId').length == 0 || text(matching, 'ruleVersion').length == 0 || text(matching, 'orderingPolicyVersion').length == 0) throw new Error('匹配快照版本不完整,请重新加载。')
const countKeys = ['candidateCount','ineligibleCount','unknownCount']
countKeys.forEach((key: string) => { const value = matching.getNumber(key); if (value == null || value < 0 || Math.floor(value) != value) throw new Error('匹配统计不完整,请重新加载。') })
}
const reason = text(matching, 'reasonCode')
const count = (key: string): string => { const v = matching.getNumber(key); return v == null ? '—' : v.toString() }
const message = status == 'completed' ? '按本次评估时的产品条件判断,展示顺序不代表优劣。' : (reason == 'LEGACY_INPUT' ? '旧版报告未执行产品匹配,可重新评估获取新的结果。' : (reason == 'MATCH_RULES_UNPUBLISHED' ? '本次评估尚未启用产品匹配规则。' : '本次评估没有可用产品目录,未执行产品匹配。'))
return { assessmentId: id, revisionId: resolvedRevision, resultVersion: version, snapshotId: text(matching, 'catalogSnapshotId'), ruleVersion: text(matching, 'ruleVersion'), orderingVersion: text(matching, 'orderingPolicyVersion'), status, message, eligible: count('candidateCount'), ineligible: count('ineligibleCount'), unknown: count('unknownCount'), items, hasMore, cursor: nextCursor }
}
export async function fetchMatchDetail(id: string, productId: string, revisionId: string = ''): Promise<MatchView> {
return (await fetchMatchDetailRecord(id, productId, revisionId)).item
}
export async function fetchMatchDetailRecord(id: string, productId: string, revisionId: string = ''): Promise<MatchDetail> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches/' + encodeURIComponent(productId), 'GET', revisionParams(revisionId))
return detailRecord(data, id, productId, revisionId)
}
function detailRecord(data: UTSJSONObject, id: string, productId: string, revisionId: string): MatchDetail {
const matching = data['matching'] as UTSJSONObject
const item = matchView(data['item'] as UTSJSONObject)
const version = data.getNumber('resultVersion') ?? 0
const snapshotId = text(matching, 'catalogSnapshotId')
// URL只表达选择意图;读取后再次检查归属和冻结版本,不能用公共资料补齐失败列。
if (!validRecordId(text(data, 'revisionId')) || (revisionId.length > 0 && text(data, 'revisionId') != revisionId) || text(data, 'assessmentId') != id || item.id != productId || item.product.id != productId || version < 1 || Math.floor(version) != version || snapshotId.length == 0 || text(matching, 'ruleVersion').length == 0 || text(matching, 'orderingPolicyVersion').length == 0 || text(matching, 'status') != 'completed') throw new Error('产品不属于本次冻结匹配结果,请返回重新选择。')
return { assessmentId: id, revisionId: text(data, 'revisionId'), resultVersion: version, snapshotId, ruleVersion: text(matching, 'ruleVersion'), orderingVersion: text(matching, 'orderingPolicyVersion'), item }
}
export async function fetchPublicProduct(id: string): Promise<ProductView> {
const result = productView(await requestJson('/products/' + encodeURIComponent(id), 'GET', new UTSJSONObject()), true)
if (result.id != id || result.version < 1 || Math.floor(result.version) != result.version) throw new Error('产品资料与所选产品不一致,请重新加载。')
return result
}