Files
airz-uniapp/packages/finance/services/matches.uts
hujun 2c9d3feab5 feat: 完善登录流程、UI适配及协议文档更新
本次提交包含以下修改:
1. 新增微信登录相关服务及页面逻辑,实现首次微信绑定手机号流程
2. 优化产品匹配卡片 UI,移除不必要状态显示
3. 调整首页 scroll-view 属性及 banner 背景样式
4. 完善积分规则校验逻辑,增加时区及策略格式验证
5. 更新协议草案文档,补充资料清单及功能说明
6. 修复分析结果路由路径,适配新的匹配结果页面
7. 调整品牌 Logo 安卓端字体对齐样式
2026-10-22 11:24:15 +08:00

185 lines
21 KiB
Plaintext
Raw Permalink 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 { API_BASE_URL } from '@/services/config.uts'
import { yuanToWan } from '@/services/money.uts'
import { provinceOptions } from '@/services/cities.uts'
export type ProductMaterial = { name: string, note: string, required: boolean, objects: string }
export type ProductView = { logo: string, materialList: ProductMaterial[], consultPhone: string, repayment: string, channel: string, approval: string, 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 = { score: number | null, 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, fallback: string = ''): string {
const value = data.getString(key) ?? ''
return value.length > 0 ? value : fallback
}
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' ? '条件不符' : '待核实') }
export function hasProductValue(value: string): boolean { return value.trim().length > 0 && !['未提供', '待核实', '—'].includes(value.trim()) }
export function matchPercentage(state: string, score: number | null): string { return ['eligible','unknown','ineligible'].includes(state) && score != null ? score.toString() + '%' : '' }
const fieldNames: UTSJSONObject = { age: '年龄', occupation: '职业类型', employer: '单位名称', requestedAmountYuan: '融资金额', requestedTermMonths: '融资期限', applicantType: '申请主体', purposeCode: '资金用途', cityCode: '所在城市', provinceCode: '所在省份', monthlyIncome: '月收入', annualRevenue: '年营收', operatingMonths: '经营月数', totalDebtBalance: '贷款余额', monthlyLoanPayment: '每月还款额', creditLoanCount: '贷款笔数', creditCardCount: '信用卡数量', creditCardTotalLimit: '信用卡总额度', creditCardUsedLimit: '信用卡已用额度', socialSecurity: '社保情况', housingFund: '公积金情况', house: '房产情况', car: '车辆情况', establishedMonths: '企业成立月数', annualInvoiceAmount: '年开票金额', taxPaymentMonths: '连续纳税月数', taxpayerGrade: '纳税等级', enterpriseCreditStatus: '企业信用状态', annualRevenueGrowthPercent: '年营收增长率', debtRatioPercent: '负债率', cashFlowStability: '经营流水稳定性', industryCategory: '行业类别' }
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: '无', normal: '正常', abnormal: '异常', stable: '稳定', unstable: '不稳定', manufacturing: '制造业', trade: '贸易', services: '服务业', construction: '建筑业', agriculture: '农业', finance: '金融业', real_estate: '房地产业', '440300': '深圳市', '440100': '广州市', '110100': '北京市', '310100': '上海市', '330100': '杭州市' }
const moneyFields = ['requestedAmountYuan','monthlyIncome','annualRevenue','totalDebtBalance','monthlyLoanPayment','creditCardTotalLimit','creditCardUsedLimit','annualInvoiceAmount']
const advisoryReasonCodes = ['AI_CONDITION_NOT_ASSESSED', 'PROFILE_DETAIL_NOT_ASSESSED']
function displayReasonText(value: string): string {
return value.replace(/((?:根据自填资料判断|资料尚需核验|用户自填[,、]未经核验|自填信息未经核验|产品快照))/g, '').replace(/自填信息未经核验;本页面不承诺额度、放款或获批。/g, '结果仅供参考,不代表审批结果。').trim()
}
function valueText(value: any | null, field: string): string {
if (value == null) return '未提供'
const raw = value.toString()
if (field == 'provinceCode') return provinceOptions.find((item): boolean => item.code == raw)?.name ?? '待核实'
if (field == 'age') return raw + ' 岁'
if (moneyFields.includes(field)) return yuanToWan(raw) + ' 万元'
if (field == 'requestedTermMonths' || field == 'operatingMonths' || field == 'establishedMonths' || field == 'taxPaymentMonths') return raw + ' 个月'
if (field == 'debtRatioPercent' || field == 'annualRevenueGrowthPercent') 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 rateText(value: string): string { return value.indexOf('.') >= 0 ? value.replace(/0+$/, '').replace(/\.$/, '') : value }
function validityText(data: UTSJSONObject): string {
const from = text(data, 'validFrom'); const until = text(data, 'validUntil')
if (from.length == 0) return until.length == 0 ? '未设置有效期' : '有效至 ' + until
return until.length == 0 ? from + ' 起生效' : from + ' 至 ' + until
}
function rangeText(min: string, max: string, unit: string): string {
if (min.length == 0) return max.length == 0 ? '未提供' : '最高 ' + max + unit
if (max.length == 0) return '最低 ' + min + unit
return (min == max ? min : min + '–' + max) + unit
}
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)) })
const rawLogo = text(data, 'bankLogo')
const logo = /^https:\/\/[^\s<>"']+$/.test(rawLogo) ? rawLogo : (/^\/uploads\/[a-zA-Z0-9/_.-]+$/.test(rawLogo) ? API_BASE_URL.replace(/\/api\/v1\/?$/, '') + rawLogo : '')
const materialList = ((data['materials'] as UTSJSONObject[] | null) ?? []).map((item: UTSJSONObject): ProductMaterial => {
return { name: text(item, 'name'), note: text(item, 'note'), required: item.getBoolean('required') ?? false, objects: ((item['applicantTypes'] as string[] | null) ?? []).map((v: string): string => labels.getString(v) ?? v).join(' / ') }
})
const rawPhone = text(data, 'consultPhone')
const sourceText = [text(source, 'publisher'), text(source, 'title')].filter((value: string): boolean => value.length > 0).join(' · ')
return { logo, materialList, consultPhone: /^[+0-9][0-9 -]{4,29}$/.test(rawPhone) ? rawPhone : '', repayment: text(data, 'repaymentMethod', '未提供'), channel: text(data, 'channel', '未提供'), approval: text(data, 'approvalDuration', '未提供'), 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, 'expectation').length > 0 ? text(data, 'expectation') : rangeText(text(data, 'amountMinYuan').length == 0 ? '' : yuanToWan(text(data, 'amountMinYuan')), text(data, 'amountMaxYuan').length == 0 ? '' : yuanToWan(text(data, 'amountMaxYuan')), ' 万元'),
rate: rangeText(rateText(text(data, 'annualRateMinPercent')), rateText(text(data, 'annualRateMaxPercent')), '%'),
term: text(data, 'quota').length > 0 ? text(data, 'quota') : rangeText(data.getNumber('termMinMonths')?.toString() ?? '', data.getNumber('termMaxMonths')?.toString() ?? '', ' 个月'),
objects: objects.map((v: string): string => labels.getString(v) ?? v).join(' / '), regions: text(data, 'regionMode') == 'unverified' ? '待核实' : (regions.length == 0 ? '全国' : regions.map((v: string): string => labels.getString(v) ?? v).join('、')),
valid: validityText(data), materials: text(data, 'information').length > 0 ? text(data, 'information') : '未提供',
// 内部引用用于定位资料来源,不把测试环境标识当作面向用户的产品说明。
source: sourceText.length > 0 ? sourceText : '待核实', 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 hiddenAdvisory = (reason: UTSJSONObject): boolean => {
const code = text(reason, 'code')
if (!advisoryReasonCodes.includes(code)) return false
const refs = (reason['evidenceRefs'] as string[] | null) ?? []
return refs.length > 0 && refs.every((ref: string): boolean => {
const item = evidence.find((entry: UTSJSONObject): boolean => text(entry, 'evidenceId') == ref)
if (item == null || text(item, 'reasonCode') != code || text(item, 'outcome') != 'unknown') return false
const source = text(item, 'source')
return code == 'AI_CONDITION_NOT_ASSESSED' ? source == 'ai_self_reported' : ['self_reported', 'product_snapshot'].includes(source)
})
}
// 仅隐藏证据一致的未参与判断提示;异常记录与原始快照保持可追溯。
const reasons = (key: string): MatchReason[] => (data[key] as UTSJSONObject[]).filter((r: UTSJSONObject): boolean => key != 'cautions' || !hiddenAdvisory(r)).map((r: UTSJSONObject): MatchReason => {
const reasonFields: string[] = []
const reasonText = displayReasonText(text(r, 'text'))
const lines = ((r['evidenceRefs'] as string[] | null) ?? []).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) })
if (text(e, 'source') == 'ai_self_reported') {
if (e['actual'] != null && (field == 'age' || fieldNames.getString(field) != null)) return (field == 'age' ? '年龄' : fieldNames.getString(field)!) + ':' + valueText(e['actual'], field)
const names = fields.map((name: string): string => name == 'age' ? '年龄' : (fieldNames.getString(name) ?? '')).filter((name: string): boolean => name.length > 0)
return names.length > 0 ? '关联资料:' + names.join('、') : reasonText
}
const source = text(e, 'source')
const sourceNote = ['self_reported', 'product_snapshot'].includes(source) ? '' : ' · ' + (source == 'verified' ? '外部核验' : '来源未知')
return (fieldNames.getString(field) ?? field) + ':' + valueText(e['actual'], field) + sourceNote
})
const explanation = explanations.find((item: UTSJSONObject): boolean => text(item, 'reasonId') == text(r, 'reasonId'))
return { id: text(r, 'reasonId'), text: reasonText, evidence: lines, fields: reasonFields, explanation: text(ai, 'status') == 'completed' && explanation != null ? displayReasonText(text(explanation, 'text')) : '' }
})
const availability = text(data, 'currentAvailability')
const score = data.getNumber('score')
if (score != null && (Math.floor(score) != score || score < 0 || score > 100 || text(data, 'scorePolicyVersion') != 'product-soft-score-v1')) throw new Error('匹配度资料无效,请重新加载。')
const pending = evidence.filter((item: UTSJSONObject): boolean => text(item, 'kind') == 'hard' && text(item, 'outcome') == 'unknown')
const missingData = text(data, 'eligibility') == 'unknown' && pending.length > 0 && pending.every((item: UTSJSONObject): boolean => ['FACT_MISSING', 'AI_CONDITION_UNKNOWN'].includes(text(item, 'reasonCode')))
return { score, id: text(data, 'productId'), state: text(data, 'eligibility'), stateLabel: missingData ? '资料不足' : 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' ? displayReasonText(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> {
if (!['all','eligible','unknown','ineligible'].includes(group)) throw new Error('匹配分组无效,请重新选择。')
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 ((group != 'all' && 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' ? (group == 'all' ? '优先展示符合条件的产品,其次为待核实、条件不符的产品。' : (text(matching, 'orderingPolicyVersion') == 'product-score-descending-v1' ? '按本次条件匹配度排序,匹配度不代表获批概率。' : '按本次评估时的产品条件判断,展示顺序不代表优劣。')) : (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
}