feat: 完善登录流程、UI适配及协议文档更新

本次提交包含以下修改:
1. 新增微信登录相关服务及页面逻辑,实现首次微信绑定手机号流程
2. 优化产品匹配卡片 UI,移除不必要状态显示
3. 调整首页 scroll-view 属性及 banner 背景样式
4. 完善积分规则校验逻辑,增加时区及策略格式验证
5. 更新协议草案文档,补充资料清单及功能说明
6. 修复分析结果路由路径,适配新的匹配结果页面
7. 调整品牌 Logo 安卓端字体对齐样式
This commit is contained in:
hujun
2026-10-22 11:24:15 +08:00
parent e5406c23a6
commit 2c9d3feab5
36 changed files with 1246 additions and 212 deletions
+5
View File
@@ -15,4 +15,9 @@
.brand-dot { width: 11rpx; height: 11rpx; border-radius: 50%; background-color: #ff5b35; } .brand-dot { width: 11rpx; height: 11rpx; border-radius: 50%; background-color: #ff5b35; }
.brand-stem { width: 10rpx; height: 35rpx; margin-top: 4rpx; background-color: #102557; } .brand-stem { width: 10rpx; height: 35rpx; margin-top: 4rpx; background-color: #102557; }
.brand-name { color: #102557; font-size: 52rpx; line-height: 68rpx; font-weight: 900; } .brand-name { color: #102557; font-size: 52rpx; line-height: 68rpx; font-weight: 900; }
/* #ifdef APP-ANDROID */
/* 对齐安卓字体的可见字形与色块,避免各部分底边错位。 */
.letter-a { transform: translateY(4rpx); }
.letter-i { transform: translateY(-4rpx); }
/* #endif */
</style> </style>
@@ -0,0 +1,44 @@
# 照片、积分与 Token 后端发布计划
> 执行方式:本会话逐项执行,使用独立发布目录固定文件和证据;不提交或推送工作树。
目标:按用户最新授权将积分一期、累计 Token 和额度管理部署到现有正式后端,并启用照片服务配置;保持照片所属用户和有效同意校验。
范围依据:`docs/积分规则与开发契约.md`、后端 `database/ai-token-usage.md`、`docs/照片存储配置落地-20261013.md`。目标为 `airz.hqling.cloud`、`/www/wwwroot/xctai.cn`、`127.0.0.1:3306/airz`;用户自行发行小程序。
## 约束与恢复边界
- 保护两仓既有改动。只发布固定运行文件、必要迁移和资源版本,不整仓覆盖,不修改模型选择、产品配置、历史报告或既有积分。
- 积分每日10分、上海每日一次,不补签、不连续加成、不过期、不联动等级;其他奖励和兑换关闭。
- Token跨模型按用户累计,失败有有效用量也记,不补历史;现有用户额度默认NULL不限,不擅自设置数额。
- 照片开关使用 `enabled = "true"`;仅供本人保存、查看和删除。若线上无正式资料协议,则保留协议/同意门禁,不能伪造协议审核或用户确认。
- 外部入口维护、排空在途任务并正常停止worker后才执行DDL和替换。MySQL DDL非原子;异常时不盲目重跑或恢复整库,先保留现场并修复。
- 上线后可能产生新积分、用量和照片;之后保留新数据并优先向前修复,不自动回滚结构或清除累计记录。
## 1. 发布材料与只读预检
- [x] 在 `D:/AAdesk/202609/airz-backups/photo-points-token-production-20261019-r1` 保存两仓摘要、明确授权及当前文件快照,设置仅本人/SYSTEM可读。
- [x] 固定15份Token后端/后台运行文件,包含新 `AiTokenUsage.php`;加入两份原迁移入口及schema作为配套材料,保留独立积分和照片运行依赖的摘要。
- [x] 执行相关纯测试、PHP/JS语法、归档路径与字节核验;不启动本机默认App或真实模型。
- [x] 通过已登录宝塔专用终端核对线上版本、源文件、环境摘要、三服务、活动调用和队列、存储权限、协议状态及迁移预检,发现漂移则先定位。
## 2. 维护、备份与配套迁移
- [x] 备份Nginx/环境/站点版本/worker配置与将替换的源码;临时维护门禁生效后排空并停止worker/PHP。
- [x] 保存正式全库灾难备份和各表结构/业务行指纹;仅在服务器私有目录保存凭据和数据库正文。
- [x] 执行 `migrate_points.php --env=<服务器私有INI> --target=127.0.0.1:3306/airz --apply --writes-stopped --backup-dir=<服务器私有目录>`,保留其双表完整备份和结果。
- [x] 执行 `migrate_ai_token_usage.php --connection=<服务器私有JSON> --apply`,随后两入口再次检查,确认无待迁移项且原业务列数据保持。
## 3. 发布、配置与维护内验证
- [x] 原子替换固定源码,保持原属主/权限;清理仅相关后台模板缓存并保留原件,更新资源版本。
- [x] 配置完整积分一期参数并启用;保留原环境其他字节,模型维持Pro。照片目录保持原私有目录,启用开关但保留有效同意限制。
- [x] 启动PHP,独立应用回读积分/照片/Token运行状态;模型构造阻断,读取原报告/匹配/产品、本人积分及额度,不创建正式评估或伪造用量。
- [x] 确认仅预期DDL、资源版本及配置变化,全部原业务行投影保持;HTTPS验证错误码、鉴权、敏感路径及新后台静态资源。
## 4. 恢复服务与交接
- [x] 验证备份可定位、源码可定向恢复,明确结构迁移后的恢复限制;通过后启动唯一worker并恢复原Nginx入口。
- [x] 核验worker命令、PID、ready心跳、服务状态、日志与公网HTTPS;记录照片实际可用程度,不能把开关启用等同于已发布正式协议。
- [x] 下载脱敏回执并核摘要,清理临时传输/连接配置,保留受限备份;关闭本轮专用终端,不退出用户原有面板。
- [x] 更新发布回执和新会话交接;小程序新增积分文案和校验需用户重新编译发行,真机与真实模型调用边界如实说明。
@@ -0,0 +1,55 @@
# 微信首次绑定手机号实施计划
> 本轮用户已明确授权开发,按任务实施并审查;不再询问执行方式。后端任务由subagent-driven-development子代理执行,前端和集成由主代理负责;禁止提交、推送、发布和生产写入。
**Goal:** 微信小程序首次登录必须通过手输手机号和短信验证码绑定到统一账户,后续微信直接登录。
**Architecture:** 服务端验证微信code,以临时票据隔离未完成绑定;专用短信挑战、身份映射双唯一约束、协议确认及现有Token共同完成认证。前端复用登录页,迟到结果不写会话;未来App复用公共账户协议。
**Tech Stack:** PHP7.4、ThinkPHP/FastAdmin、MySQL5.7、Redis、uni-app x/UTS、HBuilderX5.26。
**Spec:** ../specs/2026-10-21-wechat-sms-login-design.md
## Global Constraints
- 保留全部既有未提交改动;不改生产.env,不连接生产存储,不发送真实短信/微信请求/收费模型调用。
- 在两个当前工作区承接最新状态:功能分支和仓库外逐文件快照,不从干净HEAD遗漏既有开发成果。
- 新微信/短信渠道默认关闭;Android/iOS原生微信授权不在本轮启用。
- 文本UTF-8无BOM,关键约束中文注释;不自行提交/推送/PR/部署。
## Task 1: 后端绑定闭环与隔离测试
Files: 新增微信提供方边界、服务端小程序实现、票据服务、绑定业务、v1微信控制器、关闭配置、独立身份表迁移及tests/wechat_login.php;修改application/route.php和SmsVerification用途白名单。禁止修改已有脏schema.sql及其他无关改动。
Consumes/produces: 实现Spec的三个精确JSON端点及认证结果;前端只消费该契约。
- [x] 创建缺功能失败断言并运行,记录RED。
- [x] 实现默认关闭、code2Session验证、票据及新表、绑定专用短信挑战、账户关联/新建、冲突拒绝与同意事务。
- [x] 在全新隔离存储运行真实持久化/HTTP/并发检查,覆盖Spec列出的失败分支;不使用生产.env。394检查/85本机HTTP通过,主代理独立复跑通过。
- [x] 回归短信引擎/账户和密码会话,PHP语法检查;自查并提交审查资料,不创建Git提交。71/271/284回归及13PHP语法通过。
## Task 2: 前端绑定交互和会话隔离
Files: 新增packages/account/services/wechat-login.uts和scripts/test-wechat-login.cjs;修改packages/account/login/login.uvue;必要时仅复用auth.uts的现有会话接受逻辑。
Consumes: Task1三个端点;Produces: 微信授权/首次绑号/取消/过期/协议变化及成功回跳。
- [x] 编写执行真实UTS服务/页面逻辑的失败测试,覆盖绑定前不保存Token及迟到响应不覆盖会话。
- [x] 实现小程序授权适配和JSON服务,返回结果先验证页面代次和会话再保存。
- [x] 在现有登录页增加绑定状态、专用短信发送及取消入口;保留协议、手机号、密码和回跳行为,移除未实现的一键登录入口。
- [x] 运行新行为测试和现有协议/登录回归(19+30通过)。
## Task 3: 集成、构建和交付
- [x] 独立审查后端和前端最终差异,修复有证据的问题。前端审查通过;整体审查I1回滚异常漏撤销Token/M1路由消费s漏查均补RED/GREEN并定向复核通过。
- [x] HBuilderX小程序编译;Browser插件未提供时使用现有Playwright做受控渲染和关键交互,证据保存在仓库外。17:28再次29页编译通过;390×844/1280×900验证完成,未触达真实API。
- [x] 校验既有未提交文件摘要保持(本轮明确更新的交接文档除外),清理本轮合成存储、Token及所属进程。原有无关改动字节保持;隔离表/Redis空、库已删除、所属服务退出、端口释放、测试明文凭据移除;浏览器/Vite关闭。
- [x] 更新本地开发说明、交接和计划状态,明确正式迁移/渠道凭据/真实短信/微信真机/App仍待,不发布。
## 交付记录
本地交付说明:docs/微信与手机号登录开发-20261021.md;私密证据根目录:D:/AAdesk/202609/airz-backups/wechat-sms-login-20261021-r1。
用户后续决定:保留Android/iOS运营商本机号码一键登录作为App待接入功能,与短信登录共用现有userId;最新要求暂缓渠道准备、先还原登录页一键登录按钮,未接通时点击展示原“暂未开放”提示,完成服务开通、服务端核验及真机验证后启用实际认证。实际认证仍为后续App范围,不把它计入上方已完成的小程序/短信开发,也不代替现有微信首次绑定短信验证。
清理脚本发送Redis SHUTDOWN后收到正常连接重置,已定向确认所属两PID消失、端口释放及隔离库目录删除后完成凭据移除,回执backend/cleanup.json。此为测试工具收尾异常,非业务失败。两仓库分支保持codex/wechat-sms-login-20261021,未提交/推送/PR/上线。
@@ -0,0 +1,41 @@
# 微信首次绑定手机号与跨端统一账户
用户已确认:微信首次登录必须绑定手机号;各端保留统一的手输手机号和短信验证码流程;2026-10-21明确授权开始本地开发。后续用户明确要求保留Android/iOS运营商本机号码一键登录,作为App待接入功能,与短信登录共用现有账户;本轮小程序首次微信绑定仍使用手输手机号及短信验证。
## 范围与约束
- 首期接通微信小程序授权和公共后端;Android/iOS继续复用手机号登录,微信原生授权另行配置接入,不冒充已完成真机验收。
- Android/iOS保留运营商本机号码一键登录需求;按用户最新要求,当前先还原登录页图标和文字入口,点击展示原“暂未开放”提示,接通认证服务、服务端取号核验及真机验收后启用实际认证。手输手机号和短信验证码作为其他号码及不可用设备的登录方式。运营商认证手机号与现有账户按同一userId关联,不另建账户体系。本次恢复入口不代表服务已经接通,也未选择或开通付费供应商。
- 沿用PHP7.4/FastAdmin/ThinkPHP、MySQL5.7、Redis及uni-app x/UTS,不新增运行依赖。
- 两个当前工作区均含用户既有改动。保留所有改动,新建codex功能分支并在仓库外保存逐文件基线;不提交、推送、发布或访问生产数据库/Redis/worker,不发送真实短信或调用真实模型。
- 后端默认配置关闭;凭据来自环境,不在客户端、日志或验证回执中保存。真实渠道联调另行进行。
## 账户和登录规则
所有业务继续以现有userId归属。手机号验证码登录和密码登录保留。微信身份只作为登录凭据:小程序应用与openid共同定位身份,不假设App与小程序openid相同,不依赖必然存在的unionid。
微信身份由服务端向微信code2Session验证,拒绝客户端提供openid/appid。首次授权只返回短时绑定票据,不创建会员或业务Token。手输手机号并验证本次绑定用途的验证码后:手机号存在则关联原账户,不存在才创建;同一微信身份或同一应用下的账户绑定冲突均拒绝覆盖。账户禁用时拒绝登录,换绑手机号保持userId,业务资料/积分/额度不重置。
新增独立微信身份映射表,身份和同一应用下账户双唯一;不复用现有无唯一约束的user.openid列。迁移单独提供,仅在本轮隔离库执行,正式迁移未授权。
## 接口契约
全部为POST,前缀/api/v1,拒绝未知字段与查询参数。
1. `/auth/wechat-login`:`{platform:'mp-weixin',code,agreements:[{agreementCode,agreementVersion,contentHash,requestId}, ...]}`。agreements必须恰好包含terms/privacy当前版本及摘要,requestId各自独立且稳定。绑定有效则返回认证结果;否则返回`{status:'bind_required',bindingTicket,expiresIn:600}`。
2. `/auth/wechat/sms-code`:`{bindingTicket,phone}`。返回既有`retryAfter/expiresIn`,专用wechat-bind用途、票据上下文;正常登录验证码不能用于绑定,其他票据验证码不能串用。
3. `/auth/wechat/bind-phone`:`{bindingTicket,phone,code}`。验证票据、当前协议及验证码,在事务内关联或创建会员、保存映射和两份协议同意记录;完成后返回认证结果。
认证结果:`{status:'authenticated',token,userId,phone,expiresAt,profile,consentsConfirmed:true}`。微信会话密钥不下发。协议确认失败不得返回Token;事务或Token故障撤销新会话。原有手机号/密码登录接口兼容。
票据随机生成、只在内存页面和有过期时间的Redis保存,TTL600秒。按票据加短时互斥锁,验证码原子消费;数据库唯一约束兜底并发。成功作废票据;网络结果未知不自动重发或重新注册,用户重新微信登录可恢复已完成绑定。失败时不给客户端数据库、微信原始响应或身份信息。
## 页面行为
复用现有登录页,不新建分包页面。微信入口仅在本轮支持的小程序端展示;一键登录按用户最新要求恢复原占位入口,未接通时点击仅提示“暂未开放”,实际运营商认证留待Android/iOS接入。进入微信绑号或已认证状态时不展示其他登录方式,防止干扰当前流程。微信首次授权后显示绑定说明、手机号/验证码及取消入口;取消回到手机号登录。协议尚未发布时保留已有账户密码登录,不能走微信/验证码新入口绕过门禁。
客户端只在页面代次与原会话仍有效时接受认证结果并保存会话,离页或切换账户后的迟到响应不覆盖新会话。票据过期提示重新微信授权;验证码失败可手动重试;结果未知不自动重放。登录后保留现有白名单返回路径。
## 验证与交付
先写失败测试,再实现。后端用全新本机隔离ROOT/MySQL/Redis及显式bootstrap,微信和短信仅在测试中注入供应商替身,模型端点锁至127.0.0.1:1。验证新/老账户关联、跨用途/跨票据、过期/冲突/并发、禁用、协议变更、事务失败与Token回收;回归现有短信和密码登录。前端执行UTS行为测试、协议回归、小程序编译和受控H5渲染检查;受控测试不冒充微信真机或真实发送。完成后清理仅归属本轮的测试数据与进程,记录结果和未验收范围。
+14 -7
View File
@@ -1,6 +1,8 @@
# 协议草案与资料规则 # 协议草案与资料规则
编写日期:2026-10-13(Asia/Shanghai)。状态:本地草案,待运营信息和业务规则定稿,未写入后台、未正式发布。用户已说明暂时没有对外名称、运营主体和联系方式,本轮保留明确待填位置;按“先不验收”要求,只整理正文和规则,不进行功能验收或开启生产上传。 初稿日期:2026-10-13;本地修订日期:2026-10-19(Asia/Shanghai)。状态:本地草案,待运营信息和业务规则定稿,未写入后台、未正式发布。用户已说明暂时没有对外名称、运营主体和联系方式,继续保留明确待填位置;集中待补项目及当前功能口径见[定稿资料清单](定稿资料清单.md)。
2026-10-19修订:按当前代码及既有回执修正AI外发范围、匹配分排序、照片关闭与本人删除边界、平台服务器保存意向记录的说明,补入积分和AI用量的处理目的及待确认期限。积分一期、缓存命中/总Token及累计额度扩展均为本地待发布能力,正文使用对应的关闭状态或启用条件,不写成已在正式环境生效。本轮仅修订文档并做离线格式及一致性核对,不重新进行功能验收、不连接生产。
2026-10-13修订:用户已明确希望照片长期保存,并确认备份副本滚动保留7天。在账户正常使用且仍需平台保管照片期间,不设置上传起30天等固定到期时间;本人删除、用途结束、撤销授权后的依法处理及注销申请仍按明确规则执行。现有服务器私有目录、每日04:20照片备份及独立密钥副本已落实,见[落地记录](../照片存储配置落地-20261013.md);照片仍关闭。物理删除完成后元数据30天仍只是未确认建议,与在线照片和备份期限分别说明。 2026-10-13修订:用户已明确希望照片长期保存,并确认备份副本滚动保留7天。在账户正常使用且仍需平台保管照片期间,不设置上传起30天等固定到期时间;本人删除、用途结束、撤销授权后的依法处理及注销申请仍按明确规则执行。现有服务器私有目录、每日04:20照片备份及独立密钥副本已落实,见[落地记录](../照片存储配置落地-20261013.md);照片仍关闭。物理删除完成后元数据30天仍只是未确认建议,与在线照片和备份期限分别说明。
@@ -13,6 +15,7 @@
| [资料使用协议](资料使用协议.txt) | document-use | documents | 选填照片、敏感内容、本人管理、撤销与删除 | | [资料使用协议](资料使用协议.txt) | document-use | documents | 选填照片、敏感内容、本人管理、撤销与删除 |
| [数据授权协议](数据授权协议.txt) | data-authorization | data | 外部查询尚未开放;同意记录只是意向,不产生第三方授权 | | [数据授权协议](数据授权协议.txt) | data-authorization | data | 外部查询尚未开放;同意记录只是意向,不产生第三方授权 |
| [照片保存与删除规则](照片保存与删除规则.md) | 内部规则 | 不发布为协议 | 已实现行为、建议期限、备份和清理的最小安排 | | [照片保存与删除规则](照片保存与删除规则.md) | 内部规则 | 不发布为协议 | 已实现行为、建议期限、备份和清理的最小安排 |
| [定稿资料清单](定稿资料清单.md) | 内部清单 | 不发布为协议 | 待补资料、保存期限、供应商信息及当前功能口径 |
## 需要定稿的信息 ## 需要定稿的信息
@@ -23,9 +26,9 @@
| 运营主体地址 | 【待填写:运营主体联系地址】 | 待提供 | | 运营主体地址 | 【待填写:运营主体联系地址】 | 待提供 |
| 隐私和删除申请渠道 | 【待填写:隐私与删除申请联系方式】 | 须能实际受理;可用专用邮箱或电话,不编造 | | 隐私和删除申请渠道 | 【待填写:隐私与删除申请联系方式】 | 须能实际受理;可用专用邮箱或电话,不编造 |
| 版本、更新和生效日期 | 【待确认:正式版本号】、【待确认:更新日期】、【待确认:生效日期】 | 草案日期不等于生效日期;后台 UTC 生效时间另外填写 | | 版本、更新和生效日期 | 【待确认:正式版本号】、【待确认:更新日期】、【待确认:生效日期】 | 草案日期不等于生效日期;后台 UTC 生效时间另外填写 |
| 账户、评估、对话、反馈、同意及日志的期限 | 隐私政策逐类列出待确认项 | 不从照片期限推导,不宣称当前数据库有自动清理 | | 账户、评估、对话、反馈、积分、计量、同意及日志的期限 | 隐私政策逐类列出待确认项 | 不从照片期限推导,不宣称当前数据库有自动清理 |
| 照片在线保存规则 | 账户正常使用且仍需本人保管时长期保存,不设固定到期时间 | 用户已确认长期保存;保留本人删除、用途结束、撤销和注销申请的处理要求 | | 照片在线保存规则 | 账户正常使用且仍需本人保管时长期保存,不设固定到期时间 | 用户已确认长期保存;保留本人删除、用途结束、撤销和注销申请的处理要求 |
| 照片备份期限 | 每日备份、滚动保留7天 | 用户已确认并落实;每次成功备份清理超过7天的有效旧包,故障可能延迟清理,需及时处理 | | 照片备份期限 | 每日备份、滚动保留7天 | 用户已确认,配置已准备;每次成功备份清理超过7天的有效旧包。定时触发和真实照片恢复尚未验收,故障可能延迟清理 |
| 删除后照片元数据期限 | 建议完成文件删除后30天,再删除或去标识化 | 仅建议,现有代码保留元数据,不自动清理 | | 删除后照片元数据期限 | 建议完成文件删除后30天,再删除或去标识化 | 仅建议,现有代码保留元数据,不自动清理 |
| 服务商及存储信息 | 腾讯云 TokenHub 调用 DeepSeek;实际服务主体、存储地域、供应商留存等待核对 | 代码可确认调用路径,不能确认供应商合同或“绝不训练”承诺 | | 服务商及存储信息 | 腾讯云 TokenHub 调用 DeepSeek;实际服务主体、存储地域、供应商留存等待核对 | 代码可确认调用路径,不能确认供应商合同或“绝不训练”承诺 |
@@ -33,7 +36,7 @@
照片继续选填,只供本人保存、查看和删除。身份证、银行卡、收入证明、营业执照、银行流水和经营证明照片可能含敏感个人信息;目前照片不进入评估快照、模型请求、OCR或外部查询。普通头像使用公开图片路径,不能用于保存私有证件。 照片继续选填,只供本人保存、查看和删除。身份证、银行卡、收入证明、营业执照、银行流水和经营证明照片可能含敏感个人信息;目前照片不进入评估快照、模型请求、OCR或外部查询。普通头像使用公开图片路径,不能用于保存私有证件。
资料授权撤销会阻止使用该同意记录继续上传,不会自动删除已经保存的照片;本人仍可查看和逐份删除。删除先使资料不可读,再移除在线文件,失败保留同一资料 ID 供重试。授权和版本证据、资料元数据以及备份副本不因在线文件删除而自动消失,不能写成“一键清除所有数据”。 资料授权撤销会阻止使用该同意记录继续上传,不会自动删除已经保存的照片;照片服务可用时,撤销本身不影响本人查看和逐份删除。服务关闭也会拒绝本人管理接口,不能承诺关闭后仍能自助删除,应落实申请处理渠道。删除先使资料不可读,再移除在线文件,失败保留同一资料 ID 供重试。授权和版本证据、资料元数据以及备份副本不因在线文件删除而自动消失,不能写成“一键清除所有数据”。
上述是代码现状,不是撤回后任意继续保存的依据。仅依赖同意保存的照片,在同意撤回时需依法处理删除;现有接口没有联动自动删除,正式启用前须落实运营处理,不能把用户未再次点击删除当作无限留存许可。 上述是代码现状,不是撤回后任意继续保存的依据。仅依赖同意保存的照片,在同意撤回时需依法处理删除;现有接口没有联动自动删除,正式启用前须落实运营处理,不能把用户未再次点击删除当作无限留存许可。
@@ -41,18 +44,22 @@
自填评估含身份和财务信息。定稿时需核对其必要性、敏感个人信息的告知与单独同意,以及 AI 请求的处理依据;不能仅发布四份正文就把所有运行流程标为已具备这些条件。本轮未修改评估字段或账户流程。 自填评估含身份和财务信息。定稿时需核对其必要性、敏感个人信息的告知与单独同意,以及 AI 请求的处理依据;不能仅发布四份正文就把所有运行流程标为已具备这些条件。本轮未修改评估字段或账户流程。
评估外发白名单并非只有数值和枚举:包含推导年龄、省份或已知城市,以及符合规则的职业和单位文字。单产品解释及问答也会携带必要事实或上下文;对话号码脱敏不能保证覆盖全部敏感内容。已知技术路径不等于已确认服务方法定主体、处理关系、存储地域或训练/留存用途。
积分一期在本地按每日10分、上海每日一次、不补签、无连续加成、不过期、不改会员等级完成;正式签到仍关闭,尚未迁移启用。AI缓存命中/总Token及跨模型累计额度也仍待发布,不补历史用量;缓存命中计数不等于平台另存缓存正文。完整边界及证据入口见[定稿资料清单](定稿资料清单.md)。
## 定稿后的后台填写 ## 定稿后的后台填写
依次选择对应 code,填写正式标题、版本、UTC 生效时间和处理完所有待填项的完整正文。先保存草稿;实际审核完成后再选择正式发布并勾选审核确认。涉及正文、标题或生效时间的后续修改使用新版本,已发布版本保留。未来生效版本应新增来源并保留当前有效来源,避免提前替换导致当前全文不可用。 依次选择对应 code,填写正式标题、版本、UTC 生效时间和处理完所有待填项的完整正文。先保存草稿;实际审核完成后再选择正式发布并勾选审核确认。涉及正文、标题或生效时间的后续修改使用新版本,已发布版本保留。未来生效版本应新增来源并保留当前有效来源,避免提前替换导致当前全文不可用。
外部查询尚未接入时,数据授权正文只描述关闭状态和意向记录;后续开通渠道需重新告知并取得对应范围的有效授权,不能沿用本草案产生无限授权。私有目录/权限和备份已准备;照片服务在主体、有效申请渠道、协议及其余必要规则定稿前继续关闭。 外部查询尚未接入时,数据授权正文只描述关闭状态和意向记录;后续开通渠道需重新告知并取得对应范围的有效授权,不能沿用本草案产生无限授权。私有目录/权限和备份已准备;照片服务在主体、有效申请渠道、协议及其余必要规则定稿前继续关闭。
启用照片时需同步核对正文里的关闭状态描述,按当时实际功能修订;正文有变更须使用新版本,不能开启服务后仍展示过时状态。数据查询继续保持未开放,不能随照片启用一同改成已接入。 启用照片、积分或累计用量管理时需同步核对正文里的关闭状态和条件描述,按当时实际功能修订;正文有变更须使用新版本,不能开启服务后仍展示过时状态。数据查询继续保持未开放,不能随其他功能启用一同改成已接入。
## 核对依据 ## 核对依据
主要事实来自现有代码和既有部署记录:[协议实现](../协议全文与同意记录开发-20261012.md)、[照片实现](../私有资料上传开发-20261012.md)、[生产发布](../后端协议与照片发布-20261012.md)、[AI 调用来源](D:/AAdesk/202609/airz/application/common/library/DeepSeekClient.php)、[自填报告外发白名单](D:/AAdesk/202609/airz/application/common/library/AssessmentNeedsReport.php)、[对话上下文与脱敏](D:/AAdesk/202609/airz/application/common/library/AssistantChats.php)。初稿及长期保存修订只在本地完成;服务器只读核对见[存储与备份方案](../私有照片存储与备份方案-20261013.md),随后按用户授权实施的配置和备份见[落地记录](../照片存储配置落地-20261013.md)。本次草案同步只做本地格式校验,不访问生产或调用模型。 主要事实来自现有代码和既有部署记录:[协议实现](../协议全文与同意记录开发-20261012.md)、[照片实现](../私有资料上传开发-20261012.md)、[生产发布](../后端协议与照片发布-20261012.md)、[AI 调用来源](D:/AAdesk/202609/airz/application/common/library/DeepSeekClient.php)、[自填报告外发白名单](D:/AAdesk/202609/airz/application/common/library/AssessmentNeedsReport.php)、[对话上下文与脱敏](D:/AAdesk/202609/airz/application/common/library/AssistantChats.php)。初稿及长期保存修订只在本地完成;服务器只读核对见[存储与备份方案](../私有照片存储与备份方案-20261013.md),随后按用户授权实施的配置和备份见[落地记录](../照片存储配置落地-20261013.md)。本次草案同步只做本地格式校验,不访问生产或调用模型。
联网参考:[个人信息保护法](https://www.cac.gov.cn/2021-08/20/c_1631050028355286.htm)第十九条(最短必要保存时间)、第二十八条/第二十九条(敏感个人信息及单独同意)、第四十七条(删除条件),以及[生成式人工智能服务管理暂行办法](https://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)第十一条(输入和使用记录保护及个人信息处理)。搜索服务未成功,政府门户入口也未成功;改从网信办官方网页取得上述正文作为草案参考。该核对不替代实际主体、业务活动和服务商合同的定稿核对,也不声称已完成截至2026-10-13的全部法规修订检索。 2026-10-13初稿联网参考:[个人信息保护法](https://www.cac.gov.cn/2021-08/20/c_1631050028355286.htm)第十九条(最短必要保存时间)、第二十八条/第二十九条(敏感个人信息及单独同意)、第四十七条(删除条件),以及[生成式人工智能服务管理暂行办法](https://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)第十一条(输入和使用记录保护及个人信息处理)。当时搜索服务及政府门户入口未成功,后从网信办官方网页取得正文。本次2026-10-19修订未联网复核法规,不将历史参考当作最新法规全面核验,也不替代实际主体、业务活动及服务商合同的定稿核对。
本目录是可审阅的业务草案,尚未正式发布。服务器配置准备已完成,但照片/短信继续关闭;小程序由用户发行,验收继续暂缓。 本目录是可审阅的业务草案,尚未审核定稿或正式发布。照片存储配置已准备,但照片/短信继续关闭;积分和Token扩展也未发布。本轮文档核对不改变此前功能验收或小程序发行记录。
+61
View File
@@ -0,0 +1,61 @@
# 协议定稿资料清单
2026-10-19(Asia/Shanghai)。内部填写资料,不作为对外协议发布。四份正文已按当前代码和既有回执修订,仍是未审核、未生效的本地草案。用户此前已说明暂时没有平台对外名称、运营主体和联系方式,本轮保留待填,不编造,也不要求现在重复提供。
## 需要集中补齐的资料
| 资料 | 需要填写或核对的内容 | 当前状态 |
| --- | --- | --- |
| 对外名称 | 与小程序和实际服务一致的平台名称 | 待填写 |
| 运营主体 | 主体全称、联系地址,明确实际个人信息处理者 | 待填写,不以域名或开发者姓名代替 |
| 有效申请渠道 | 可受理隐私、查阅复制、更正、删除、撤回和注销的邮箱或电话,以及接收、核实、答复和处理失败的责任安排 | 待填写并验证可用;目前没有完整自助注销删除入口 |
| 适用人群 | 服务适用年龄、未成年人信息的处理及监护人申请安排 | 待确认,不把普通注册等同已核验年龄 |
| AI服务主体及关系 | TokenHub及DeepSeek实际签约/处理主体全称,各方属于受托处理或其他何种关系、适用服务条款及隐私说明链接 | 已知技术路径,法定主体及处理关系待核对 |
| 平台服务环境 | 实际存储地域、基础设施服务商、处理范围及适用跨境情况 | 待确认,不因使用某云服务推定所有数据位置 |
| AI侧处理 | 输入、输出、调用记录及缓存的存储地域、保存期限、训练等使用目的、删除限制及适用跨境情况 | 待以实际合同和有效规则核对,不承诺“绝不训练/不留存/不出境” |
| 正式版本与时间 | 四份正文的正式版本、更新日期、生效日期,以及后台UTC生效时间 | 资料和实际安排齐备后确定;本地修订日不等于生效日 |
| 告知与同意安排 | 自填身份/财务资料、AI外发及可能涉及敏感信息、独立处理者的具体告知与所需同意方式 | 结合真实处理核对,四份正文发布不替代必要流程 |
## 保存期限分别确认
除已确认的照片在线保存和备份规则外,其余类别仍待决定。填写时需同时明确保存目的、期限或可执行的终止条件、注销/撤回后的处置、必要留存依据及负责执行的人。以下空项不是无限期保存许可,也不表示已有自动清理程序。
| 信息类别 | 当前可用口径 | 待补内容 |
| --- | --- | --- |
| 账户与登录资料 | 用于识别账户、验证登录及安全 | 具体保存期限、注销后的删除或必要留存安排 |
| 自填评估、历史报告、AI对话 | 保存提交资料、冻结结果和问答上下文 | 各类期限及本人申请删除方式;不能只清本机 |
| 反馈、回复、通知和课程阅读记录 | 用于处理问题、展示通知及学习进度 | 各类期限、处理结束或注销后的处置 |
| 积分、流水、签到及请求记录 | 展示、核对及防止重复领取;新的签到尚未开放 | 期限、账户注销后处理;本机待确认请求与服务端流水分别处理 |
| AI调用用量、累计额度及计量记录 | 核对调用、管理额度;扩展尚未发布 | 期限、注销后处置及必要审计范围,不照搬积分或照片期限 |
| 协议正文、版本、确认和撤销证据 | 解释当时所见范围,提供必要证据 | 独立的必要保存期限和终止条件 |
| 访问、安全和其他运行记录 | 服务保护、错误排查 | 分类保存期限、访问范围及清理安排 |
| 照片在线文件 | 已确认:账户正常使用且仍需本人保管时长期保存,不设上传起固定到期时间 | 落实本人删除、用途结束、撤回后的依法处理及注销申请,不新增固定到期清理 |
| 照片备份 | 已确认:每日备份、滚动7天;配置已准备 | 定时触发及真实照片恢复尚未验收;受控恢复须排除已删除或应删除照片 |
| 在线照片删除后的元数据 | 30天只是原建议,未确认,现无自动清理 | 确认期限、必要字段及删除/去标识化方式;与在线长期保存分开 |
## 当前功能与正文口径
“已发布”来自既有回执,本轮未连接生产复核;“本地待发布”表示代码及此前验证已完成,不代表正式环境已启用。
| 功能 | 当前状态与应使用的说明 | 核对依据 |
| --- | --- | --- |
| 自填评估与AI处理 | 已发布。必要外发白名单含数值、枚举、派生年龄、省份或已知城市、受限职业和单位文字;完整姓名、身份证号、联系电话不是该结构化路径字段,但其他文字栏不保证没有用户自行输入的敏感信息 | [资质参考建议发布](../后端资质参考建议发布-20261015.md)、后端 `AssessmentNeedsReport::modelFacts()`、`ProductAiConditions::facts()` |
| 产品解释和对话 | 已发布。可使用所选产品/报告的必要事实证据和上下文、主动提问及部分历史;常见号码格式脱敏无法覆盖全部敏感内容 | 后端 `ProductExplanations::messages()`、`AssistantChats::prompt()`及`redact()` |
| 模型服务 | 技术路径为腾讯云TokenHub→DeepSeek;已切换Pro。正文不固化具体型号或内部配置字段,不据路径推断合同、留存、训练及地域 | [模型切换回执](../模型切换回执-20261019.md) |
| 匹配度与顺序 | 已发布的规则分值影响参考排序。25款74条加减分配置供新冻结目录使用;历史报告和已冻结任务不重算。百分比不是获批概率、信用分或授信额度,未知保持未知 | [正式配置回执](../产品材料/产品加减分正式写入回执-20261019.md)、后端 `ProductMatching` |
| 积分一期 | 本地待发布,正式签到仍关闭,流水表迁移未做。规则为每日10分、上海每日一次、不补签、无连续加成、不过期、不改会员等级;资料/邀请奖励、兑换、提现及AI额度抵扣不开放 | [积分规则与开发契约](../积分规则与开发契约.md)、[后端设计](D:/AAdesk/202609/airz/database/points-design.md) |
| 积分结果不明恢复 | 本地实现。仅按本人账户保留请求标识、签到日期和规则版本;保留不等于补发,普通退出或登录失效不自动清除。本人主动确认后,收到成功回执或服务端明确拒绝且未入账的结果时清除待确认请求 | 前端 `services/points.uts`、`packages/account/points/points.uvue`;正式到账及真机未验收 |
| Token用量与累计额度 | 本地待发布,尚未迁移正式库。每人跨模型累计,失败有有效用量也计入;日志增加命中和总Token,未知不编造,不补历史;留空不限、0禁止新调用、正数累计上限,并发在途实际用量可超限实记 | [用量说明](D:/AAdesk/202609/airz/database/ai-token-usage.md);正文不展示表名或内部算法 |
| 照片 | 仍关闭。启用后只供本人保存、查看、删除,不进评估、模型、OCR或银行/征信查询;撤销阻新上传,不自动删旧照。服务关闭时本人管理接口也不可用,必须落实有效申请渠道 | [照片规则](照片保存与删除规则.md)、[配置落地](../照片存储配置落地-20261013.md) |
| 外部查询 | 未接入。确认《数据授权协议》只在平台服务器记意向,不等于银行/征信凭据,不触发查询或收费 | [数据授权草案](数据授权协议.txt)、[协议实现](../协议全文与同意记录开发-20261012.md) |
| 登录、退出与注销 | 短信关闭,微信/一键登录仍为暂未开放提示;不据入口虚构已收集微信标识。退出、清缓存或删小程序不等于注销或服务器删除 | 当前前端登录入口、[隐私草案](隐私政策.txt) |
缓存命中Token是用量计数,不等于平台为计量另存“缓存正文”,也不能据此保证供应商无缓存。积分、累计Token和照片长期保存分别有独立目的与期限,不能互相替代。
## 资料就绪后的收尾
1. 将上表资料补入对应正文,核对实际申请渠道和执行安排;对服务方信息及处理依据完成实质核对。
2. 按拟生效时的真实功能状态修订正文,确定正式版本和日期,移除草案提示与待填项;再次核对现有后台纯文本格式及摘要。
3. 由实际运营主体完成内容审核,再按用户后续明确授权决定是否写入后台或发布。照片、积分等功能的迁移和启用另按各自部署要求处理,不因协议准备完成自动开启。
本轮仅完成产品事实及草案一致性核对,不是法律审定或最新法规全面检索。当前仍有明确待补资料,不能登记为已定稿、已审核或已发布。
+5 -5
View File
@@ -1,6 +1,6 @@
数据授权协议 数据授权协议
草案状态:未定稿、未生效。编写日期:2026-10-13。 草案状态:未定稿、未生效。初稿日期:2026-10-13;本地修订日期:2026-10-19。
正式版本:【待确认:正式版本号】 正式版本:【待确认:正式版本号】
更新日期:【待确认:更新日期】 更新日期:【待确认:更新日期】
生效日期:【待确认:生效日期】 生效日期:【待确认:生效日期】
@@ -13,7 +13,7 @@
二、同意记录的含义 二、同意记录的含义
您主动确认本协议时,平台仅保存一项可选的本地意向记录,包括所见协议版本、范围、正文摘要、确认及后续撤销时间和请求标识,用于说明您当时阅读和确认了什么。 您主动确认本协议时,平台仅在平台服务器保存一项可选的平台内意向记录,包括所见协议版本、范围、正文摘要、确认及后续撤销时间和请求标识,用于说明您当时阅读和确认了什么;这不是只保存在您手机中的记录。
该记录不等于已取得第三方授权凭据,不产生任何实际查询,不代表已核实身份或资料,不授权平台代表您提交贷款申请,也不授权平台进行不限定渠道、数据、次数或期限的查询。 该记录不等于已取得第三方授权凭据,不产生任何实际查询,不代表已核实身份或资料,不授权平台代表您提交贷款申请,也不授权平台进行不限定渠道、数据、次数或期限的查询。
@@ -23,7 +23,7 @@
未来开放实际查询时,应在相应页面逐项说明:实际提供和接收数据的主体、查询产品和渠道、要查询的具体信息、查询目的、本人或企业授权关系、处理方式、费用和扣费条件、查询次数及授权有效范围、结果保存期限,以及撤回和删除方式。 未来开放实际查询时,应在相应页面逐项说明:实际提供和接收数据的主体、查询产品和渠道、要查询的具体信息、查询目的、本人或企业授权关系、处理方式、费用和扣费条件、查询次数及授权有效范围、结果保存期限,以及撤回和删除方式。
涉及敏感个人信息、向其他独立处理者提供信息或其他依法需要单独同意的处理,应另外履行相应告知与同意要求。渠道自身需要登录、身份核实、签署授权文件或其他凭据时,平台不得以一项本地勾选代替。 涉及敏感个人信息、向其他独立处理者提供信息或其他依法需要单独同意的处理,应另外履行相应告知与同意要求。渠道自身需要登录、身份核实、签署授权文件或其他凭据时,平台不得以一项平台内勾选代替。
您应能在实际查询前作出明确选择。只有对应渠道和授权机制就绪、具体范围明确并取得有效授权后,才可发起该次查询;目前尚未接入,不会因保存本意向记录启动查询或计费。 您应能在实际查询前作出明确选择。只有对应渠道和授权机制就绪、具体范围明确并取得有效授权后,才可发起该次查询;目前尚未接入,不会因保存本意向记录启动查询或计费。
@@ -31,12 +31,12 @@
您可以不确认此项可选意向,继续使用不依赖外部查询的自填服务。未查询的数据保持未知,不能被解释为零、良好、已验证或符合申请条件。 您可以不确认此项可选意向,继续使用不依赖外部查询的自填服务。未查询的数据保持未知,不能被解释为零、良好、已验证或符合申请条件。
您可在“我的同意记录”撤销该意向。撤销后的旧记录保留必要的版本和时间证据,不得用于新的查询。再次取得本地意向也不等于自动满足实际渠道的授权要求。 您可在“我的同意记录”撤销该意向。撤销后的旧记录保留必要的版本和时间证据,不得用于新的查询。再次取得平台内意向也不等于自动满足实际渠道的授权要求。
若未来已有实际查询,撤销对后续处理的影响、已经发生的费用、结果删除和依法必要留存,应在对应渠道规则中预先说明;本草案不承诺尚未接入渠道能够退费、即时撤回已完成请求或删除渠道全部副本。 若未来已有实际查询,撤销对后续处理的影响、已经发生的费用、结果删除和依法必要留存,应在对应渠道规则中预先说明;本草案不承诺尚未接入渠道能够退费、即时撤回已完成请求或删除渠道全部副本。
五、记录保存与权利 五、记录保存与权利
本地协议和意向、撤销证据的保存期限:【待确认:协议与同意证据保存期限】。具体个人信息权利和申请方式见《隐私政策》;本意向记录不扩大其他信息的保存范围或期限。 平台保存的协议和意向、撤销证据的保存期限:【待确认:协议与同意证据保存期限】。具体个人信息权利和申请方式见《隐私政策》;本意向记录不扩大其他信息的保存范围或期限。
未来渠道、目的或信息范围变化时,需要重新告知并取得对应范围的有效授权,不以本版意向自动涵盖。对记录或处理有疑问,可通过上述联系方式申请说明、更正、撤销或依法删除。 未来渠道、目的或信息范围变化时,需要重新告知并取得对应范围的有效授权,不以本版意向自动涵盖。对记录或处理有疑问,可通过上述联系方式申请说明、更正、撤销或依法删除。
+43 -39
View File
@@ -1,41 +1,45 @@
{ {
"ok": true, "ok": true,
"documents": 4, "checkedOn": "2026-10-19",
"drafts": [ "documents": 4,
{ "drafts": [
"code": "terms", {
"title": "用户协议", "code": "terms",
"scope": "account-terms", "title": "用户协议",
"bodyBytes": 5656, "scope": "account-terms",
"contentHash": "d10054e46f220b7dc0f65a5b32b1606662a64ba32f49b63cd43dbc56f996ba41" "bodyBytes": 6869,
}, "contentHash": "fc72078a874e2f8296d1b1ff221f1f897afe03c8692fe2e0e30baba512ed0eb0"
{ },
"code": "privacy", {
"title": "隐私政策", "code": "privacy",
"scope": "account-privacy", "title": "隐私政策",
"bodyBytes": 10683, "scope": "account-privacy",
"contentHash": "c97bea31c07ba46ed06eeb9573e1ed205ba3501eebfd618c831a0fe96ff617c6" "bodyBytes": 13000,
}, "contentHash": "f961bbea159783ba3a95899bfa1018c8b334fdcfafab992dcbe9f567a2aa4301"
{ },
"code": "document-use", {
"title": "资料使用协议", "code": "document-use",
"scope": "documents", "title": "资料使用协议",
"bodyBytes": 6097, "scope": "documents",
"contentHash": "4c003378814087b097a4a4e5927afd7a5b38d829e2fb4de279c26d6cca14fb60" "bodyBytes": 6362,
}, "contentHash": "e800068754198d2ac4855311b964c35f7134e5c381577c5b9b6b6d6ee71cf2e7"
{ },
"code": "data-authorization", {
"title": "数据授权协议", "code": "data-authorization",
"scope": "data", "title": "数据授权协议",
"bodyBytes": 3600, "scope": "data",
"contentHash": "5c273ef7bd0bf5edd7f769d069662318843fffa68f9b1f6c360c1c2eb2932f55" "bodyBytes": 3715,
} "contentHash": "cfb0bc14201743be553c4ee84c465f40480393091c039c8f80d5de3c9b6b5ccf"
], }
"validationTimeIsFixture": true, ],
"validationVersionIsFixture": true, "validator": "AgreementRegistry::definition",
"frameworkLoaded": false, "validationTimeIsFixture": true,
"productionConfigRead": false, "validationVersionIsFixture": true,
"databaseCalls": 0, "fixtureValuesWrittenToDrafts": false,
"networkCalls": 0, "legalReviewCompleted": false,
"published": false "frameworkLoaded": false,
"productionConfigRead": false,
"databaseCalls": 0,
"networkCalls": 0,
"published": false
} }
@@ -2,6 +2,8 @@
2026-10-13。本文件区分已确认、已实施及待定稿规则,供本批协议定稿使用。私有目录、每日备份和独立密钥副本已按授权准备,见[落地记录](../照片存储配置落地-20261013.md);照片生产配置仍关闭,正式协议未发布。 2026-10-13。本文件区分已确认、已实施及待定稿规则,供本批协议定稿使用。私有目录、每日备份和独立密钥副本已按授权准备,见[落地记录](../照片存储配置落地-20261013.md);照片生产配置仍关闭,正式协议未发布。
2026-10-19文档核对:原保存方向保持,本轮未访问生产或实施配置;主体、申请渠道及其余期限集中在[定稿资料清单](定稿资料清单.md)。定时备份触发和真实照片恢复仍未验收,不将原配置准备记为本轮运行验证。
同日修订:用户已明确要求照片长期保存,备份副本滚动保留7天。在线照片不采用原建议的上传起30天到期;删除后元数据30天仍只是待确认建议。 同日修订:用户已明确要求照片长期保存,备份副本滚动保留7天。在线照片不采用原建议的上传起30天到期;删除后元数据30天仍只是待确认建议。
## 首版用途与收集范围 ## 首版用途与收集范围
+11 -5
View File
@@ -1,6 +1,6 @@
用户协议 用户协议
草案状态:未定稿、未生效。编写日期:2026-10-13。 草案状态:未定稿、未生效。初稿日期:2026-10-13;本地修订日期:2026-10-19。
正式版本:【待确认:正式版本号】 正式版本:【待确认:正式版本号】
更新日期:【待确认:更新日期】 更新日期:【待确认:更新日期】
生效日期:【待确认:生效日期】 生效日期:【待确认:生效日期】
@@ -11,7 +11,7 @@
本平台提供融资相关信息展示、自填资料整理、报告生成、按已配置规则进行的产品条件匹配、产品对比、AI问答、学习内容及反馈等功能。实际可用功能以页面说明为准;尚未开放的功能不因本协议中的说明而自动开通。 本平台提供融资相关信息展示、自填资料整理、报告生成、按已配置规则进行的产品条件匹配、产品对比、AI问答、学习内容及反馈等功能。实际可用功能以页面说明为准;尚未开放的功能不因本协议中的说明而自动开通。
当前资料照片仅供本人保存、查看和删除,暂不参与评估;外部征信或其他第三方数据查询尚未开放。平台当前不代您向银行提交贷款申请,不代银行作出审批、授信或放款决定。 资料照片功能目前尚未开放,开放后用途限于本人保存、查看和删除,不参与评估;外部征信或其他第三方数据查询尚未开放。平台当前不代您向银行提交贷款申请,不代银行作出审批、授信或放款决定。
二、账户与信息 二、账户与信息
@@ -25,7 +25,9 @@
报告依据您提交的自填信息、当次保存的规则及可用产品资料生成。资料完整度、准备状态和平台自定的融资综合等级用于解释当前信息状况,不是征信等级、银行授信评级、审批概率或可贷额度。 报告依据您提交的自填信息、当次保存的规则及可用产品资料生成。资料完整度、准备状态和平台自定的融资综合等级用于解释当前信息状况,不是征信等级、银行授信评级、审批概率或可贷额度。
“符合已知条件”表示满足系统已经掌握的条件,不等于银行接受申请或批准贷款;“待核实”表示依据不足,不能视为符合。展示顺序不构成收益、成本、适合程度或获批可能性的排名。 “符合已知条件”表示满足系统已经掌握的条件,不等于银行接受申请或批准贷款;“待核实”表示依据不足,不能视为符合。未核实的细项即使不阻断参考展示,也仍属未知,不会因此被认定为符合。
匹配度、分值和展示顺序依据已配置规则及已知资料形成,分值影响参考产品的排序,不代表对全部产品的综合评价。百分比不是获批概率、信用评分、银行授信评级或可贷额度。同分可能源于相同起始分、已知资料未触发加减分或触发规则后结果相同,不表示银行对这些产品的审批结果相同。
产品信息、额度范围、利率和期限可能变化,实际条件、费用和合同应向相关机构通过正规渠道核实。历史报告保留当次信息与结果,不因当前产品变化或资料修改自动重新计算。需要新的结果时,请按页面流程修改资料或重新评估。 产品信息、额度范围、利率和期限可能变化,实际条件、费用和合同应向相关机构通过正规渠道核实。历史报告保留当次信息与结果,不因当前产品变化或资料修改自动重新计算。需要新的结果时,请按页面流程修改资料或重新评估。
@@ -33,11 +35,15 @@
AI用于整理和解释已知信息,可能出现遗漏、理解偏差或错误。请结合原始信息核对,涉及贷款合同、费用或其他重要决定时,以合法有效的机构文件和实际核实结果为依据。 AI用于整理和解释已知信息,可能出现遗漏、理解偏差或错误。请结合原始信息核对,涉及贷款合同、费用或其他重要决定时,以合法有效的机构文件和实际核实结果为依据。
平台通过腾讯云TokenHub调用DeepSeek模型服务。为提供报告或问答,平台会按《隐私政策》说明处理必要的结构化自填事实、所选报告和产品上下文,以及您的提问和相关对话。照片目前不作为模型输入。 平台通过腾讯云TokenHub调用DeepSeek模型服务。为提供报告、产品解释或问答,平台会按《隐私政策》说明处理必要的自填事实,包括按规则选取的数值、枚举、年龄、地区及受限的职业和工作单位文字,以及所选报告、产品上下文、您的提问和相关对话。照片不作为模型输入。
请勿在问答中输入密码、验证码、完整银行卡号或与问题无关的敏感资料。AI回复不代表平台或任何金融机构已经为您办理业务,不构成授信、获批、放款或结果保证。 请勿在问答中输入密码、验证码、完整银行卡号或与问题无关的敏感资料。AI回复不代表平台或任何金融机构已经为您办理业务,不构成授信、获批、放款或结果保证。
五、合理使用与服务变化 五、积分、用量与合理使用
积分和签到奖励目前尚未开放。开放时将另行展示可参与的活动、领取条件、积分用途和有效期,具体以当时实际开放的规则为准。积分不等于现金或金融机构授信额度,目前未开放兑换、提现或抵扣AI调用额度,也未开放资料奖励或邀请奖励。
启用累计用量管理后,平台会按账户记录AI调用用量,并根据适用的累计额度限制新的AI请求。调用失败但服务方返回有效用量的,也会计入累计;未取得有效用量的,不编造用量。该计量不代表本协议已经开通付费服务或自动扣款。
请勿利用平台实施违法活动、干扰服务、尝试读取他人资料或绕过访问限制。平台可就账户异常、明确违规或安全事件采取必要的限制措施,并提供问题反馈渠道;依法应当承担的义务和责任不因此免除。 请勿利用平台实施违法活动、干扰服务、尝试读取他人资料或绕过访问限制。平台可就账户异常、明确违规或安全事件采取必要的限制措施,并提供问题反馈渠道;依法应当承担的义务和责任不因此免除。
+32 -15
View File
@@ -11,8 +11,8 @@
"code": "terms", "code": "terms",
"scope": "account-terms", "scope": "account-terms",
"title": "用户协议", "title": "用户协议",
"bytes": 5656, "bytes": 6869,
"sha256": "d10054e46f220b7dc0f65a5b32b1606662a64ba32f49b63cd43dbc56f996ba41", "sha256": "fc72078a874e2f8296d1b1ff221f1f897afe03c8692fe2e0e30baba512ed0eb0",
"unresolvedFields": [ "unresolvedFields": [
"平台对外名称", "平台对外名称",
"更新日期", "更新日期",
@@ -28,12 +28,14 @@
"code": "privacy", "code": "privacy",
"scope": "account-privacy", "scope": "account-privacy",
"title": "隐私政策", "title": "隐私政策",
"bytes": 10683, "bytes": 13000,
"sha256": "c97bea31c07ba46ed06eeb9573e1ed205ba3501eebfd618c831a0fe96ff617c6", "sha256": "f961bbea159783ba3a95899bfa1018c8b334fdcfafab992dcbe9f567a2aa4301",
"unresolvedFields": [ "unresolvedFields": [
"AI服务提供方全称及受托或其他处理关系", "AI服务提供方全称及受托或其他处理关系",
"AI服务方保存期限及处理用途", "AI服务方保存期限及处理用途",
"AI服务方数据存储地域及跨境处理情况",
"AI服务方有效隐私说明链接", "AI服务方有效隐私说明链接",
"AI用量和额度记录保存期限及账户注销后处理",
"协议与同意证据保存期限", "协议与同意证据保存期限",
"反馈阅读记录保存期限", "反馈阅读记录保存期限",
"平台对外名称", "平台对外名称",
@@ -43,6 +45,7 @@
"正式版本号", "正式版本号",
"照片元数据保存期限", "照片元数据保存期限",
"生效日期", "生效日期",
"积分和签到记录保存期限及账户注销后处理",
"评估报告和对话保存期限", "评估报告和对话保存期限",
"账户资料保存期限和注销后处理", "账户资料保存期限和注销后处理",
"运营主体全称", "运营主体全称",
@@ -56,8 +59,8 @@
"code": "document-use", "code": "document-use",
"scope": "documents", "scope": "documents",
"title": "资料使用协议", "title": "资料使用协议",
"bytes": 6097, "bytes": 6362,
"sha256": "4c003378814087b097a4a4e5927afd7a5b38d829e2fb4de279c26d6cca14fb60", "sha256": "e800068754198d2ac4855311b964c35f7134e5c381577c5b9b6b6d6ee71cf2e7",
"unresolvedFields": [ "unresolvedFields": [
"协议与同意证据保存期限", "协议与同意证据保存期限",
"平台对外名称", "平台对外名称",
@@ -75,8 +78,8 @@
"code": "data-authorization", "code": "data-authorization",
"scope": "data", "scope": "data",
"title": "数据授权协议", "title": "数据授权协议",
"bytes": 3600, "bytes": 3715,
"sha256": "5c273ef7bd0bf5edd7f769d069662318843fffa68f9b1f6c360c1c2eb2932f55", "sha256": "cfb0bc14201743be553c4ee84c465f40480393091c039c8f80d5de3c9b6b5ccf",
"unresolvedFields": [ "unresolvedFields": [
"协议与同意证据保存期限", "协议与同意证据保存期限",
"平台对外名称", "平台对外名称",
@@ -95,21 +98,22 @@
"currentProductionEnabledChanged": false, "currentProductionEnabledChanged": false,
"functionalAcceptancePerformed": false, "functionalAcceptancePerformed": false,
"backendContractValidation": "格式核对.json", "backendContractValidation": "格式核对.json",
"frontendRuntimeFilesUnchanged": 14,
"pageCount": 29,
"taskCount": 63,
"supportingFiles": [ "supportingFiles": [
{ {
"file": "README.md", "file": "README.md",
"sha256": "6aa20826b78d2b372b8d5bf5c58524f3c5f893a61340192f824daf03d07bc7ec" "sha256": "ce96a80c77f487b356b2e6ed6c03f041dfa1537fad220e5ee0b29d27b7f2303a"
}, },
{ {
"file": "照片保存与删除规则.md", "file": "照片保存与删除规则.md",
"sha256": "efd567f527bdcb3494d1b887b0102b688a19bf56a8aa8f524f56943924cc1433" "sha256": "55fe1f0cd462399f187a3607dd093be708c817d7fff0788aeae801aa5fbf23a4"
},
{
"file": "定稿资料清单.md",
"sha256": "185b3ee88d80722822e5a39d94c9826c9806a8b1a7acf33e43a599024343ca60"
}, },
{ {
"file": "格式核对.json", "file": "格式核对.json",
"sha256": "ea5055037c00fc8a0af1441c303cde4c8f38e8cfb1c32573f00547f93710bfef" "sha256": "2c11e69bebad9e13605d1986f3b051a6c91169f4c8c5dfce62eedc30bf2f3376"
} }
], ],
"onlinePhotoRetention": { "onlinePhotoRetention": {
@@ -142,5 +146,18 @@
"actualPhotoRestoreVerified": false "actualPhotoRestoreVerified": false
}, },
"productionStoragePrepared": true, "productionStoragePrepared": true,
"productionPhotosEnabled": false "productionPhotosEnabled": false,
"revisedOn": "2026-10-19",
"latestRevisionScope": "local documents only",
"contentFactReviewPerformed": true,
"legalReviewCompleted": false,
"runtimeFilesModified": 0,
"productionAccessPerformed": false,
"featureStateBasis": "Existing deployment receipts and local source; no production access in this revision",
"legacyInitialCheck": {
"checkedOn": "2026-10-13",
"frontendRuntimeFilesUnchanged": 14,
"pageCount": 29,
"taskCount": 63
}
} }
+4 -4
View File
@@ -1,6 +1,6 @@
资料使用协议 资料使用协议
草案状态:未定稿、未生效。编写日期:2026-10-13。 草案状态:未定稿、未生效。初稿日期:2026-10-13;本地修订日期:2026-10-19。
正式版本:【待确认:正式版本号】 正式版本:【待确认:正式版本号】
更新日期:【待确认:更新日期】 更新日期:【待确认:更新日期】
生效日期:【待确认:生效日期】 生效日期:【待确认:生效日期】
@@ -21,7 +21,7 @@
三、用途和处理方式 三、用途和处理方式
当前照片用途限于按类别保存您主动选择的文件,供您本人在资料卡片查看、管理和删除。平台不从照片提取数据,不进行OCR、真实性核验,不将照片加入评估快照或模型输入,也不向银行、征信机构或其他查询渠道发送照片。 照片功能开放后,用途限于按类别保存您主动选择的文件,供您本人在资料卡片查看、管理和删除。平台不从照片提取数据,不进行OCR、真实性核验,不将照片加入评估快照或模型输入,也不向银行、征信机构或其他查询渠道发送照片。
上传成功仅代表文件已保存,不表示资料真实、已审核、已完成征信查询或已取得贷款申请资格。照片不改变自填报告和既有匹配结果,也不形成授信、获批或放款承诺。 上传成功仅代表文件已保存,不表示资料真实、已审核、已完成征信查询或已取得贷款申请资格。照片不改变自填报告和既有匹配结果,也不形成授信、获批或放款承诺。
@@ -29,7 +29,7 @@
四、本人查看与删除 四、本人查看与删除
上传后的照片直接显示在原上传卡片,通过卡片菜单查看或删除;同类多份照片可逐份管理。读取和删除需要有效登录且资料属于当前账户,不提供可供他人直接打开的公开地址。 照片服务开放且可用时,上传后的照片直接显示在原上传卡片,通过卡片菜单查看或删除;同类多份照片可逐份管理。读取和删除需要有效登录且资料属于当前账户,不提供可供他人直接打开的公开地址。服务关闭或暂时不可用时,查看和自助删除也可能不可用,请通过申请渠道要求处理;关闭服务本身不会删除已保存的照片。
上传回执未知时,请先重新查看已保存资料,再决定是否手动重试;重试可能多保存一份照片。平台不把网络超时显示成保存成功,也不保证普通上传具有永久去重能力。 上传回执未知时,请先重新查看已保存资料,再决定是否手动重试;重试可能多保存一份照片。平台不把网络超时显示成保存成功,也不保证普通上传具有永久去重能力。
@@ -46,7 +46,7 @@
六、撤销与后续用途 六、撤销与后续用途
您可以在“我的同意记录”撤销资料使用授权。撤销后,不能使用该同意记录继续上传;在删除处理完成前,此前保存的照片仍可由本人查看和删除。现有撤销入口不会联动自动删除历史照片,您可在原卡片删除,或通过申请渠道要求处理。 您可以在“我的同意记录”撤销资料使用授权。撤销后,不能使用该同意记录继续上传;在照片服务开放且可用时,撤销本身不妨碍本人查看和删除此前保存的照片。现有撤销入口不会联动自动删除历史照片,您可在原卡片删除,或通过申请渠道要求处理。
仅基于您的同意保存的照片,在该同意撤回时,运营人员应依法处理删除,不以您尚未再次点击删除作为继续保存的无限授权。依法仍需保留的信息应限制在必要范围,并说明相应依据和处理方式。 仅基于您的同意保存的照片,在该同意撤回时,运营人员应依法处理删除,不以您尚未再次点击删除作为继续保存的无限授权。依法仍需保留的信息应限制在必要范围,并说明相应依据和处理方式。
+13 -6
View File
@@ -1,6 +1,6 @@
隐私政策 隐私政策
草案状态:未定稿、未生效。编写日期:2026-10-13。 草案状态:未定稿、未生效。初稿日期:2026-10-13;本地修订日期:2026-10-19。
正式版本:【待确认:正式版本号】 正式版本:【待确认:正式版本号】
更新日期:【待确认:更新日期】 更新日期:【待确认:更新日期】
生效日期:【待确认:生效日期】 生效日期:【待确认:生效日期】
@@ -17,7 +17,7 @@
2. 可选账户资料:您设置的昵称和头像,用于本人资料展示。头像通过普通图片地址提供,不能上传身份证、银行卡或其他私有证明材料作为头像。 2. 可选账户资料:您设置的昵称和头像,用于本人资料展示。头像通过普通图片地址提供,不能上传身份证、银行卡或其他私有证明材料作为头像。
3. 自填评估资料:按个人或企业页面的实际字段,可能包括姓名、身份证号码、联系电话、城市、职业、婚姻状况、任职或企业信息、统一社会信用代码、收入或营收、经营时长、社保和公积金情况及基数、房车和其他资产状况、负债、贷款月还款及信用卡相关金额和数量。这些信息用于保存您提交的评估资料、生成报告及执行已配置的产品条件规则。 3. 自填评估资料:按个人或企业页面的实际字段,可能包括姓名、身份证号码、联系电话、省份或城市、职业、婚姻状况、任职或企业信息、统一社会信用代码、收入或营收、经营时长、社保和公积金情况及基数、房车和其他资产状况、负债、贷款月还款及信用卡相关金额和数量。这些信息用于保存您提交的评估资料、生成报告及执行已配置的产品条件规则;适用时从身份证号码推导年龄,用于年龄条件判断。
身份证号码及金融相关信息可能属于敏感个人信息;不当处理可能影响身份或财产安全。平台应以具体目的和必要范围处理,在需要单独同意的活动发生前另行告知并取得有效同意,不能以一般政策确认取代该步骤。 身份证号码及金融相关信息可能属于敏感个人信息;不当处理可能影响身份或财产安全。平台应以具体目的和必要范围处理,在需要单独同意的活动发生前另行告知并取得有效同意,不能以一般政策确认取代该步骤。
@@ -27,17 +27,22 @@
6. 反馈和阅读记录:您提交的反馈内容、处理回复、站内通知,以及主动标记的课程阅读状态,用于回复问题、展示通知和记录学习进度。 6. 反馈和阅读记录:您提交的反馈内容、处理回复、站内通知,以及主动标记的课程阅读状态,用于回复问题、展示通知和记录学习进度。
7. 同意和运行记录:所见协议版本、正文摘要、确认范围、确认和撤销时间、请求标识,以及服务所需的访问时间、网络地址、错误状态和调用用量记录,用于证明操作范围、保护账户和排查问题。我们不要求您在反馈或日志中填写密码、验证码或模型密钥。 7. 积分和签到记录:提供相应服务时,处理与账户关联的积分余额、变动流水、签到日期、规则版本和请求记录,用于展示、核对及防止重复领取。当前新的签到和奖励未开放。客户端可能在本机保留待确认操作的请求标识、签到日期和规则版本,按账户区分,用于在网络结果不明后由您确认原操作;这些待确认内容不包含登录凭据或积分余额,也不会因刷新或重新登录自动补签。清除本机记录不等于删除服务器积分流水。
8. 同意和运行记录:所见协议版本、正文摘要、确认范围、确认和撤销时间、请求标识,以及服务所需的访问时间、网络地址、错误状态和AI调用用量记录,用于证明操作范围、保护账户、排查问题和核对服务使用。启用相应用量扩展后,还会记录与账户关联的输入、输出、缓存命中及总用量计数,跨模型累计并管理适用额度;失败调用返回有效用量时也计入。缓存命中计数说明调用用量,不表示平台为该计量另存一份缓存正文,也不表示服务方没有缓存或留存。我们不要求您在反馈或日志中填写密码、验证码或模型密钥。
三、AI处理与服务提供方 三、AI处理与服务提供方
平台通过腾讯云TokenHub调用DeepSeek模型生成文字。报告请求使用必要的结构化数值、枚举和规则依据;当前报告外发路径不包含照片、完整身份字段或任意自由文本说明。上下文问答会发送所选报告和产品的必要信息、您的问题及相关历史对话。对话中部分常见身份号码会作格式脱敏,但不能保证识别所有敏感表达,请勿主动输入无关隐私资料。 平台通过腾讯云TokenHub调用DeepSeek模型生成文字。评估和产品条件处理会按字段白名单发送必要的数值、枚举、从身份证号码推导的年龄、省份或已知城市,以及符合当前规则的职业类型和工作单位文字;还会发送相关产品条件、解释模板和规则依据。职业和单位是您填写的文字,不应将这条路径理解为完全没有自由文本。完整姓名、身份证号码和联系电话不作为这条结构化评估外发路径的字段;这些资料仍可能保存在平台的自填记录中,请勿在职业、单位等其他文字栏填入无关敏感信息。
单产品解释会使用所选产品的规则结论、理由及相应事实证据;适用时证据可包括职业、工作单位等已知事实。上下文问答会发送您主动输入的问题、所选报告和产品的必要上下文,以及部分相关历史对话。对话中部分常见身份证号、手机号和账户号码格式会作脱敏,但不能保证识别所有敏感内容;主动输入的文字也可能包含无法识别的信息,请勿输入无关隐私资料。
模型调用不等于征信查询、银行审批或照片核验。现有照片不发送给模型,不做OCR或自动核验。 模型调用不等于征信查询、银行审批或照片核验。现有照片不发送给模型,不做OCR或自动核验。
实际AI服务提供方和处理关系:【待确认:AI服务提供方全称及受托或其他处理关系】。 实际AI服务提供方和处理关系:【待确认:AI服务提供方全称及受托或其他处理关系】。
AI服务方隐私说明与数据使用规则:【待确认:AI服务方有效隐私说明链接】。 AI服务方隐私说明与数据使用规则:【待确认:AI服务方有效隐私说明链接】。
服务方对输入、输出和调用记录的保存及处理限制:【待确认:AI服务方保存期限及处理用途】。 服务方对输入、输出和调用记录的保存及处理限制:【待确认:AI服务方保存期限及处理用途】。
服务方的数据存储地域及适用的跨境情况:【待确认:AI服务方数据存储地域及跨境处理情况】。
我们不会用本政策给予服务方将您的资料用于无关目的的无限授权;是否存在训练用途或其他处理,应按实际合同和服务规则核对并明确告知,不能作未经核实的承诺。涉及向其他独立处理者提供、敏感个人信息或其他依法需要单独同意的情形,须在相应处理发生前履行适用的告知和同意要求。 我们不会用本政策给予服务方将您的资料用于无关目的的无限授权;是否存在训练用途或其他处理,应按实际合同和服务规则核对并明确告知,不能作未经核实的承诺。涉及向其他独立处理者提供、敏感个人信息或其他依法需要单独同意的情形,须在相应处理发生前履行适用的告知和同意要求。
@@ -64,13 +69,15 @@ AI服务方隐私说明与数据使用规则:【待确认:AI服务方有效
账户与登录资料:【待确认:账户资料保存期限和注销后处理】。 账户与登录资料:【待确认:账户资料保存期限和注销后处理】。
评估资料、报告及对话:【待确认:评估报告和对话保存期限】。 评估资料、报告及对话:【待确认:评估报告和对话保存期限】。
反馈与阅读记录:【待确认:反馈阅读记录保存期限】。 反馈与阅读记录:【待确认:反馈阅读记录保存期限】。
积分余额、流水、签到及请求记录:【待确认:积分和签到记录保存期限及账户注销后处理】。
AI调用用量、累计额度及相关计量记录:【待确认:AI用量和额度记录保存期限及账户注销后处理】。
私有照片在线文件:为供您持续保管和查看,在账户正常使用且您仍需平台提供照片保管服务期间长期保存,不设置自上传起计算的固定到期时间。您主动删除、照片保管用途结束、撤回作为保存依据的同意或申请注销时,按相应删除和申请规则处理;依法确需保留的部分另行说明依据和范围。 私有照片在线文件:为供您持续保管和查看,在账户正常使用且您仍需平台提供照片保管服务期间长期保存,不设置自上传起计算的固定到期时间。您主动删除、照片保管用途结束、撤回作为保存依据的同意或申请注销时,按相应删除和申请规则处理;依法确需保留的部分另行说明依据和范围。
照片备份副本:每日备份并滚动保留7天,每次成功备份后检查并清理创建时间超过7天的旧副本。在线删除不会立即清除历史备份;备份仅用于受控恢复,恢复时排除已删除或应按撤销、用途结束、注销申请处理删除的照片。停机或任务失败可能延迟清理,恢复运行后应及时处理;备份过期不会删除仍在使用的在线照片。 照片备份副本:每日备份并滚动保留7天,每次成功备份后检查并清理创建时间超过7天的旧副本。在线删除不会立即清除历史备份;备份仅用于受控恢复,恢复时排除已删除或应按撤销、用途结束、注销申请处理删除的照片。停机或任务失败可能延迟清理,恢复运行后应及时处理;备份过期不会删除仍在使用的在线照片。
照片删除后的元数据:【待确认:照片元数据保存期限】。 照片删除后的元数据:【待确认:照片元数据保存期限】。
协议版本、同意与撤销证据:【待确认:协议与同意证据保存期限】。 协议版本、同意与撤销证据:【待确认:协议与同意证据保存期限】。
访问、安全和调用记录:【待确认:运行记录保存期限】。 访问、安全和调用记录:【待确认:运行记录保存期限】。
您可通过原资料卡片逐份删除照片。系统先使该照片不可读取,再移除在线文件;在线文件确认移除后才显示删除成功。失败时可对同一资料重试,或通过联系方式申请协助。在线文件删除不代表历史同意记录、资料元数据和备份副本同时清除;这些副本按已明确的期限和删除规则处理,不得用于恢复继续提供已撤销的服务。 照片服务开放且可用时,您可通过原资料卡片逐份删除照片。系统先使该照片不可读取,再移除在线文件;在线文件确认移除后才显示删除成功。失败时可对同一资料重试,或通过联系方式申请协助。服务关闭或暂时不可用时,照片查看和自助删除也可能不可用,应通过申请渠道处理,不能将关闭服务代替删除。在线文件删除不代表历史同意记录、资料元数据和备份副本同时清除;这些副本按已明确的期限和删除规则处理,不得用于恢复继续提供已撤销的服务。
退出登录、清除本机缓存或删除客户端不会自动注销服务器账户,也不会自动删除评估历史和照片。需要处理服务器信息时,请使用相应删除入口或向我们提出申请。 退出登录、清除本机缓存或删除客户端不会自动注销服务器账户,也不会自动删除评估历史和照片。需要处理服务器信息时,请使用相应删除入口或向我们提出申请。
@@ -80,7 +87,7 @@ AI服务方隐私说明与数据使用规则:【待确认:AI服务方有效
“我的同意记录”可以撤销资料和数据的可选授权。用户协议和隐私政策的账户使用确认在该页不提供撤销按钮,这不限制您通过申请渠道行使法定权利,也不等于所有信息处理的同意永远不可撤回。 “我的同意记录”可以撤销资料和数据的可选授权。用户协议和隐私政策的账户使用确认在该页不提供撤销按钮,这不限制您通过申请渠道行使法定权利,也不等于所有信息处理的同意永远不可撤回。
撤回可选资料授权会停止基于该授权的新上传;在删除处理完成前,本人仍可查看和删除此前保存的照片。现有撤回和删除是不同操作,不会自动同时完成。仅基于您的同意保存的照片,在该同意撤回时由运营人员依法处理删除,不以您尚未再次点击删除作为无限留存依据。撤回不影响撤回前已经依法开展的处理,其他有独立合法依据而必须保存的记录仅在必要范围内保留,并说明相应依据和范围。 撤回可选资料授权会停止基于该授权的新上传;在照片服务开放且可用时,撤回本身不妨碍本人查看和删除此前保存的照片。现有撤回和删除是不同操作,不会自动同时完成。仅基于您的同意保存的照片,在该同意撤回时由运营人员依法处理删除,不以您尚未再次点击删除作为无限留存依据。撤回不影响撤回前已经依法开展的处理,其他有独立合法依据而必须保存的记录仅在必要范围内保留,并说明相应依据和范围。
我们会核实申请与账户的关系,核实只使用必要信息,不要求为普通申请额外上传整套证件。无法立即删除、需要补充核实或依法不能处理某项请求时,将说明原因、处理范围和可继续采取的方式。 我们会核实申请与账户的关系,核实只使用必要信息,不要求为普通申请额外上传整套证件。无法立即删除、需要补充核实或依法不能处理某项请求时,将说明原因、处理范围和可继续采取的方式。
@@ -0,0 +1,49 @@
# 微信与手机号登录本地开发(2026-10-21)
## 本轮行为
- 微信首次授权只取得10分钟绑定票据,此时不创建会员、不保存登录Token。
- 用户手工输入中国大陆手机号,通过本次绑定专用短信验证码后登录。手机号已有账户时关联原userId;没有才注册。
- 同一小程序内,每个微信身份和每个账户最多一条映射;绑定冲突明确拒绝,不自动覆盖或合并。
- 已绑定的微信身份后续可直接登录;禁用账户不能登录。资料、报告、积分、累计用量继续归属原userId。
- 微信登录必须确认已发布的用户协议和隐私政策;服务端验证版本与摘要并事务记录同意。协议未发布时既有密码登录例外继续保留,微信及验证码注册不能绕过。
- 前端只在当前页面及原会话仍有效时接受结果,离页、取消、换账户后的迟到响应不会覆盖新会话。
## 范围与接口
本轮开放微信小程序授权适配。Android/iOS沿用公共手机号账户和手工验证码流程,原生微信SDK及对应应用配置留待App接入;H5和原生App隐藏微信入口。保留密码登录。用户后续明确要求保留运营商本机号码一键登录功能,并暂缓渠道准备、先还原按钮;登录页已恢复原一键登录图标及文字,点击显示“一键登录暂未开放”的原提示。实际运营商认证仍为Android/iOS待接入项,服务接通并完成真机验证后启用。
App一键登录与短信验证码登录共用现有账户:服务端验证运营商凭证并取得手机号后,存在则关联原userId,没有才注册,不能信任客户端直接提交的手机号。保留短信登录供其他号码或设备认证不可用时使用。一键登录需要开通认证服务并完成应用配置、授权页及双端真机测试;DCloud方案需要uniCloud核验取号再接入现有PHP后端,具体供应商尚未确定。微信小程序首次登录手输手机号并短信验证的规则继续适用。
三个POST端点,前缀`/api/v1`:
| 端点 | 用途 |
| --- | --- |
| `/auth/wechat-login` | 接收小程序code及两份协议确认,返回待绑定票据或认证结果 |
| `/auth/wechat/sms-code` | 按票据与手机号发送专用绑定验证码 |
| `/auth/wechat/bind-phone` | 核销验证码、关联账户并返回认证结果 |
精确请求与响应见[设计](superpowers/specs/2026-10-21-wechat-sms-login-design.md)。后端独立表、配置、迁移与渠道说明见`D:/AAdesk/202609/airz/database/wechat-login.md`,不得把既有脏schema.sql作为本轮迁移整体执行。
## 本地验证
- 后续按钮恢复验证:一键登录原图标及文字已还原,点击显示原未开放提示,不发认证请求、不保存会话、不改输入内容;微信绑定期间隐藏,取消绑定后恢复。现有19+30回归及29页小程序编译通过;390×844/1280×900受控页面检查通过,微信入口并列显示使用仅测试运行期的平台替身。证据:`D:/AAdesk/202609/airz-backups/quick-login-restore-20261021-r1`,专用浏览器及Vite已关闭。
- `node scripts/test-wechat-login.cjs`:19个实际UTS服务/页面逻辑场景通过,授权、HTTP和设备存储使用替身。
- `node scripts/test-agreements.cjs`:30个协议/登录回归通过,无网络请求。
- HBuilderX5.26:17:28再次完成微信小程序29页编译,退出码0,未上传或发行。
- Playwright:本机H5,390×844和1280×900;普通H5入口隐藏、协议门禁、短信输入、微信绑定、取消、重新授权、验证及成功回跳通过,无相关控制台错误和外网请求。绑定页面通过仅浏览器运行期替换微信授权边界渲染,页面和HTTP服务代码保持真实,不能替代微信真机授权验收。
- H5编译器对既有`services/layout.uts`的窗口尺寸API类型报告4项警告;页面运行、交互及小程序编译未失败,本轮未改动该文件。
- 后端测试、审查和收尾证据见仓库外`D:/AAdesk/202609/airz-backups/wechat-sms-login-20261021-r1`。后端使用全新本机隔离数据库/Redis/ROOT,未加载生产.env;微信、短信均为测试供应商。
- 后端394项检查、85次本机HTTP及多进程并发通过,主代理独立复跑通过;短信71项、账户短信271项、会话284项回归通过,13个PHP文件语法通过。独立审查两项问题均补失败测试并修复,定向复核通过。
- 既有无关改动和脏schema.sql保持。隔离表/Redis已清空、仅本轮数据库已删除、所属服务退出、端口释放、随机测试凭据移除;专用浏览器和Vite已关闭。Redis停止的连接重置导致清理工具中断,确认服务实际退出后已完成收尾,详见仓库外`backend/cleanup.json`。
## 启用前待办
1. 补齐主体等资料,审核并正式发布用户协议及隐私政策;本轮未把草案发布为正式协议。
2. 审核独立身份表SQL、生产备份与恢复步骤,另行授权迁移和部署。
3. 配置真实小程序AppID/Secret及可用短信渠道。微信配置默认关闭,凭据只存服务端环境。
4. 完成测试环境真实短信、小程序真机授权、新老账户绑定及回跳验收,再决定发行。
5. Android/iOS阶段分别接入原生授权SDK、应用配置与真机验证,不能把跨应用OpenID视为同一身份。
6. 保留并接入Android/iOS运营商本机号码一键登录;确认服务商、开通及费用、完成服务端核验和统一账户关联、授权页与真机验证后开放入口,同时保留短信登录。
本轮仅本地开发,未提交、推送、创建PR、发布或修改生产环境,也未发送真实短信或调用微信/模型服务。
+72
View File
@@ -1,7 +1,79 @@
# AI融资项目新会话交接 # AI融资项目新会话交接
## 最新安卓Logo对齐(2026-10-22,已同步真机)
用户截图反馈登录页“AI融资”Logo歪斜。该组件混用文字A、色块i与中文字体,安卓可见字形底边不同;参考截图A底边126px、i竖条132px、中文129px。本轮仅components/BrandLogo.uvue增加APP-ANDROID条件样式:A向下4rpx、i向上4rpx,保持原尺寸、配色和组件布局,所有使用该组件的安卓页面共享修正。其余77个运行源码文件摘要保持,前序登录、首页及所有既有修改保留。
HBuilderX5.26于11:16:14完成安卓29页编译,11:16:19同步成功,11:16:28进入登录页,用户确认“已显示”。真机Logo区域截图核对A/i竖条/中文底边243/242/243px,差值从参考图6px降至真机1px;组件dex与手机SHA256一致。11:17:36微信29页编译通过,微信Logo样式未包含安卓偏移。验证采用原生真机截图,无浏览器模拟;只保存顶部及Logo区域,未采集输入内容。登录页既有Multiple text nodes警告仍在,本轮未处理。其他安卓机型和iOS未验收,无登录/短信/模型调用、提交/推送或发布。
证据D:/AAdesk/202609/airz-backups/android-brand-align-20261022-r1,含implementation.diff、android-login.log、mp-weixin.log、logo-before-reference.png、logo-after.png、visible-bounds-after.json及verification.json。手机最后停留登录页。
## 最新安卓真机验收(2026-10-22,首页双向滑动已由用户确认)
小米6(ADB 8119de76)已安装标准调试基座并成功同步当前项目。真机截图确认首页蓝色“AI智能评估”横幅及“查看档案”等当前文案;11:05最终同步后再次核对首页、登录页和主模块dex的SHA256均与本机产物一致,记录device-resource-hashes-final.json。“一键登录”按钮点击已由用户操作并截图确认提示“一键登录暂未开放”,实际运营商认证仍未接入。
首页无法滑动已定位为原生scroll-view方向属性:容器1698px、内容2051px有真实溢出,裸scroll-y编译成空字符串。本轮唯一运行源码增量是pages/home/home.uvue将scroll-y改为direction="vertical",共享layout及正式API配置保持。本轮10:50安卓增量编译成功并同步后,用户明确回复“现在可以滑动了”;10:56微信29页编译通过,微信产物正确生成scroll-y="true"。11:00安卓再次29页编译并同步成功。
11:05:15安卓29页再次编译成功,11:05:21同步成功,启动公开新闻页后用户手动点左下角“首页”,明确回复“已回首页,双向滑动正常”。11:06有效截图显示首页已滑到下方“融资课堂”,与此前顶部画面共同证明确实可滚动;该最终截图并非横幅完整展示图,顶部返回依据用户操作确认,不夸大截图证据。上滑后的第一次全黑截图无效。CLI直接启动首页仍会触发欢迎页再转登录,后续需要打开首页时可先启动公开新闻页再点首页,无须修改启动业务逻辑。
用户明确保持“USB调试(安全设置)”关闭,手动点击和滑动;远程输入会被INJECT_EVENTS拒绝,不再尝试。手机60秒息屏,验证时请保持解锁亮屏。公开内容请求仍报网络失败,真实数据与登录链路未验收;iOS未编译或真机验证。另观察到安卓登录页密码遮掩需单独确认,未改该输入属性,勿收集或记录真实凭据。
证据目录D:/AAdesk/202609/airz-backups/android-home-verify-20261022-r1;有效截图home-before-swipe.png、quick-login-modal.png及home-final-bidirectional-confirmed.png,编译日志launch-fresh-scroll-check.log、mp-weixin-final.log、launch-direct-home.log、launch-return-navigation.log。verification-final.json记录最终检查,scroll-direction-only.diff为本轮精确源码增量;初始8文件中其余7文件摘要保持。保留前序所有未提交改动;无提交、推送、发布、真实短信/微信/模型调用或后端改动。下方2026-10-21条目为此前状态,本节优先。
## 最新安卓真机首页排查(2026-10-21,编译通过、待重新连接真机)
用户反馈首页顶部白块、仍像旧版、无法上下滑动。17:50真机截图仍有“查看全部/信用融资方案/3/示例方案”,与当前首页“查看档案”和真实账户摘要不同;手机此前项目资源时间为2026-09-21,不能用该旧资源判断本轮登录开发效果。初次滑动截图时手机息屏,图片全黑,不作为有效滚动验证。后续手机从ADB断开,目前尚未同步并运行最新安卓资源,不登记首页滚动或白块真机验收通过。
已完成最小安卓编译兼容修正:compare和matches服务的字符串兜底改为显式长度判断;产品卡片/详情模板通过本地有类型函数调用导入的展示函数,消除生成代码的invoke错误;匹配列表及报告总数改用parseInt读取已校验的整数串;未开放微信平台分支显式返回Promise<string>。原有展示规则、账户逻辑和全部前序改动保留。首页横幅改为原生支持的background-image方向渐变,并增加蓝色background-color;共享layout未改,滚动应在最新资源实际运行后再定位。
HBuilderX5.26于18:07:20完成安卓29页编译,18:07:53完成微信29页编译。相关现有检查通过:匹配预览20、产品展示19、匹配文案12、列表分页6、微信登录19、报告优先级32、PDF导出38。证据/改前字节位于D:/AAdesk/202609/airz-backups/android-home-fix-20261021-r1,安卓成功日志compile-only-3.log、微信mp-weixin.log。ADB设备8119de76(小米6)重新连接并解锁后,再运行当前项目、核对真实摘要文案、蓝色横幅和双向滑动,并检查一键登录占位入口;不要并发启动多次HBuilderX运行。当前services/config.uts指向正式HTTPS API,勿擅改配置或操作真实短信/微信/模型入口。未提交、推送、发布或改后端;iOS尚未编译/真机验证。
## 最新微信与手机号登录开发(2026-10-21,本地待联调)
用户后续补充:明确保留Android/iOS运营商本机号码一键登录功能,并最新要求暂缓渠道准备、先还原按钮。登录页已恢复原图标/“一键登录”文字,点击仅展示原“暂未开放”提示;进入微信绑号或已认证时隐藏其他登录方式。实际运营商认证仍待App阶段开通服务、完成服务端取号核验及真机验证后启用,与短信登录共用原userId和业务资料。本次未开通服务或接入SDK;微信小程序首次绑号仍手输手机号及短信验证。
按钮还原已通过49项既有回归、29页微信编译,以及390×844/1280×900受控页面显示/点击/输入保持/微信绑定隐藏与取消恢复检查,无相关页面控制台错误或真实外部请求。证据D:/AAdesk/202609/airz-backups/quick-login-restore-20261021-r1;临时浏览器和Vite已关闭,未发布。
用户已确认并授权本地开发:微信首次登录必须手工输入手机号并短信验证;已有手机号关联原userId,没有才注册;后续微信直登,冲突不覆盖。前后端已实现三个精确端点、10分钟临时绑定票据、专用短信挑战、按应用隔离的身份双唯一映射、两份协议同意事务及故障Token撤销。前端增加绑定/取消/过期流程,离页或换账户的迟到结果不保存会话。首期微信小程序适配开放;Android/iOS复用公共手机号账户,原生微信SDK尚未启用。
验证:前端微信19和协议30场景通过,HBuilderX5.26于17:28完成29页小程序编译;受控H5在390×844/1280×900通过协议门禁、短信输入、绑定/取消/重新授权和成功回跳,无相关页面控制台错误。H5编译器既有layout.uts窗口API类型警告未改。绑定UI仅在浏览器测试运行期替换微信授权边界,不等于微信真机验收。后端在全新本机隔离MySQL/Redis/ROOT完成394检查、85次HTTP及真实多进程并发;回归短信71、账户短信271、会话284,13个PHP语法通过。独立审查发现的回滚自身失败跳过新Token回收、显式s被路由消费漏查两项已补RED/GREEN修复。
仅本地开发,微信配置默认关闭;本轮没有提交、推送、PR、发布、正式迁移或生产访问,也未发送真实短信、微信或模型请求。没有读取后端生产.env。原有所有脏改动保持,schema.sql未改;正式迁移提供独立database/wechat_identity.sql。运营主体等资料仍待用户提供,协议草案没有发布,协议未发布的门禁不可绕过。下一步是测试环境渠道配置与真实微信/短信验收;发布、正式DDL和App原生适配须另行明确授权。
两个工作区当前分支均为codex/wechat-sms-login-20261021(未提交)。开发说明:docs/微信与手机号登录开发-20261021.md;后端配置与迁移:D:/AAdesk/202609/airz/database/wechat-login.md。计划及设计在docs/superpowers/下2026-10-21-wechat-sms-login*。原始字节快照、测试/截图、独立审查和清理回执位于D:/AAdesk/202609/airz-backups/wechat-sms-login-20261021-r1。下方为历史记录,本节优先。
## 下一会话:微信与手机号登录讨论(2026-10-20)
用户要求记录进度并新开会话,开始讨论微信和手机号两种登录方式;当前仅授权梳理现状和讨论方案,尚未确定具体登录改造。本轮积分中心中间项改为分享已完成本地验证,待用户发行小程序;正式后端积分查询补丁已上线,详情见下两节,不必重复部署。
已只读核对:`packages/account/login/login.uvue` 的微信与一键登录按钮调用 `socialLogin()`,目前仅弹出暂未开放提示,并非已接入微信授权;`packages/account/services/auth.uts` 已对接手机号验证码及手机号密码登录接口,线上短信服务实际可用性尚未在本轮核验。新会话应先梳理微信身份、手机号与现有用户ID的关系,再讨论是否必须绑定手机、已有账户关联和冲突处理,确保融资档案、报告、积分及Token累计额度不会因两种登录方式分裂或重复发放。还需核对协议未发布对登录/注册的影响,不能绕过既有同意门禁。用户暂缓补运营主体等资料的约定仍有效。
工作区有多轮未提交改动,全部保留;前端 `D:/WorkSpace/cyun/ai融资`,后端 `D:/AAdesk/202609/airz`。后端 `.env` 指向生产,勿为讨论启动数据库/Redis/worker或执行写库测试。正确生产服务器为111.230.144.231,禁止旧219服务器。本次登录讨论不发布、不改正式数据、不发送短信或调用收费模型;形成方案后按用户决定推进。
## 最新积分中心分享入口(2026-10-20,本地待发行)
按用户要求,积分中心中间的“完善资料”改为“分享”,三项顺序为签到、分享、邀请好友;移除该入口跳融资档案的行为。微信端使用原生分享按钮,分享公开首页 `/pages/home/home`;不携带个人资料或报告,不发放分享积分,页面说明同步为分享和邀请奖励尚未开放。仅改前端页面及相关回归脚本,后端无需部署,待用户重新编译发行小程序。22个积分前端场景及HBuilderX 5.26微信29页编译通过,已核对产物的原生 `open-type`、分享回调和图标;未进行微信真机分享或发送操作。编译证据:`D:/AAdesk/202609/airz-backups/points-share-20261020-r1`。下方正式积分接口补丁保持。
## 最新积分入口补丁(2026-10-20 12:59:05)
用户确认已重新发行小程序(本轮未提供新版本号),反馈积分中心“积分请求查询参数不合法”和隐私政策“协议尚未正式发布”。已定位前者为正式Nginx `/index.php?s=$1` 内部参数与ThinkPHP已消费GET参数不一致,原本机直设PATH_INFO测试遗漏该部署路径。在原接口输入测试补真实Request改写/未改写回归,先复现再修复;250接口输入、251积分服务、21前端场景及PHP语法通过。沿用本会话积分后端上线授权,在正确111.230.144.231定向发布20261020-points-query-r1,仅替换后端 `application/api/controller/v1/Points.php`,改用REQUEST_URI原始客户端查询串;重复/未知/显式s/数组/错误编码仍拒绝。原文件c23e85ee6cde6bce38551e2da09db9583884ba9de43661d149c90e0899cb8e22,新文件d0f3a97e921b16b2921d381b988ad6be052150f7ef0ab517a6feac40cdcae619。私密备份后原子替换、温和重载PHP;四项正式积分服务读取正常(每日10分),8公网HTTPS通过,三服务active且worker PID/重启数保持。本轮无业务写入/签到/会员令牌创建/模型调用,不冒充真机或到账验收;无需再次发行小程序。隐私/用户/资料协议线上404/AGREEMENT_NOT_AVAILABLE保持,未发布草案,用户暂缓补资料约定继续有效,照片真实上传仍受门禁。证据D:/AAdesk/202609/airz-backups/points-query-hotfix-20261020-r1及服务器/root/airz-points-query-hotfix-20261020-r1;详见后端database/points-query-hotfix-20261020.md。下方各轮后端已发布内容保持,旧发布清单须叠加本补丁。
## 最新线上补丁(2026-10-19 18:09:09)
用户反馈“分析结果未通过格式校验”。在正确服务器111.230.144.231只读定位17:56:14的AI_INVALID_REPORT:第一批模板conditionOutcome与关联条件最终outcome不一致,旧ProductAiTemplates.php第228行;失败正文未保存,无法确定具体模板或模型混淆方式。定向发布20261019-report-template-r1,仅改该文件提示词,明确条件判定/正文适用性、先完成条件判断再复制引用结果及配对示例,严格校验保持。613项不同纯检查、151源码守卫、11服务器/8公网HTTPS通过,唯一worker PID3121708、心跳ready/idle,模型仍Pro。无本轮真实模型调用、自动重试、DDL、产品或历史改写;真实模型改善仍待用户从原失败记录重新分析验收,小程序无需因此重发。调用用量已正常实记。证据D:/AAdesk/202609/airz-backups/report-template-20261019-r1,后端database/report-template-hotfix-20261019.md;旧发布清单须叠加此单文件新摘要19fc50ee01dab152aa6808290039115d614842b48b1c530670e6eb5fabd1430c,不能盲重跑旧部署。下方前轮照片/积分/Token发布与协议边界保持。
## 最新正式发布(2026-10-19 17:40:33)
用户授权照片开关与后端更新,并追加积分和Token上线;正确宝塔为111.230.144.231:31702,禁止继续沿用219.151.177.172作为本项目目标。后端20261019-photo-points-token-r1已发布15运行文件并完成积分四项结构增量/Token三条ALTER,恢复入口和唯一新worker(PID3114856,ready/idle)。积分每日10分、上海每日一次,其他奖励/兑换关闭;用户累计Token/命中及总Token/累计额度已上线,现有额度NULL不限,不补历史。模型仍deepseek-v4-pro-0813。照片enabled已开,正式document-use版本0,404/AGREEMENT_NOT_AVAILABLE及有效同意校验保持,所以实际上传仍不可用;四份待填草案未发布。151路径、30表456行业务原字段投影保持、历史4报告/16组/4详情及归属/25产品/34省份、11服务器及16公网HTTPS通过;本轮模型调用0。备份与脱敏回执保存在D:/AAdesk/202609/airz-backups/photo-points-token-production-20261019-r1及正确服务器同名/root目录;临时连接文件/外层上传包已清理。小程序需要用户重新编译发行以包含积分文案/完整策略校验,正式签到到账/真机/真实供应商命中字段/照片恢复未验收。详见后端database/photo-points-token-release-20261019.md。下方Token/积分未发布及照片关闭为历史阶段,不覆盖本条;协议待定稿的边界继续有效。
## 新会话接续摘要(2026-10-19) ## 新会话接续摘要(2026-10-19)
- 最新协议草案核对(继续先不发布):用户“继续下一项”承接协议定稿资料准备,已按本地代码及既有回执修订四份正文,纠正职业/单位文字与派生年龄/地区的AI外发范围、匹配分排序、照片关闭与删除边界,以及平台服务器保存外部查询意向的含义;补齐积分和AI用量计量目的及独立待确认期限。积分一期和命中/总Token及累计额度均仍本地待发布,不补历史,不因文案开启功能。新增 `docs/协议草案/定稿资料清单.md`,集中列出主体、有效申请渠道、分类保存期限、服务方主体/关系/留存/地域等资料,暂缺信息不编造、不重复追问;现有21类待填项继续保留,未法律审定、未正式定稿。四份正文经现有 `AgreementRegistry::definition()` 纯离线格式校验通过,清单/摘要已同步,校验用版本及2099生效时间仅作内存夹具、不写正式正文。仅文档修订;无生产/DB/Redis/worker/真实模型访问、无后台写入/迁移/启用/发布/提交/推送,Token和积分前序改动保留。原件、范围基线及验证位于 `D:/AAdesk/202609/airz-backups/agreement-draft-review-20261019-r1`。下一步待资料就绪后补齐并完成内容审核;当前不能将四份草案直接作为正式协议发布,照片/短信/新签到继续关闭。
- 最新积分一期收尾(继续先不发布):用户在一期规则建议后“继续下一步”,按每日10分、上海每日一次、不补签、无连续奖励、不过期、不联动等级;资料/邀请奖励和兑换/提现/AI额度抵扣关闭推进。前端校验完整服务端策略、补全规则弹窗并区分暂停状态;新增后端 `database/points-phase1.env.example`(points-checkin-20261019-v1,默认关闭且不自动加载),真实.env和默认points.php未改。251服务/190输入/110迁移纯检查、98写保护、21前端场景及原协议30/照片26场景通过;写保护旧测试仅补Token只读显示所需Db/view替身,业务代码保持。实际H5手机390×844/桌面1280×900合成接口49项记录通过,截图已查看,规则/暂停/异常/丢响应刷新原键确认覆盖,无运行错误或意外外呼;刻意404及断网控制台记录保留。HBuilderX5.26微信29页编译成功,产物在仓库外。未启数据库/Redis/worker、未重新执行持久化验收、未访问生产/迁移/发布/真实模型调用/提交/推送,前轮Token未发布改动保留。证据 `D:/AAdesk/202609/airz-backups/points-phase1-20261019-r1`,规则说明 `docs/积分规则与开发契约.md`;正式积分仍关闭,邀请体系/资料奖励未实施,正式到账和真机仍未验收。下一项为协议定稿资料核对,主体/有效申请渠道此前暂无,不编造也不反复追问,四份草案及照片/短信关闭状态保持。
- 最新本地开发:已按用户确认实现累计 Token 总额度。`fa_user.ai_token_limit` 留空不限、0 禁止新调用、正整数为跨模型累计上限;`fa_model_user` 按用户唯一累计 `used_quota`,有有效用量的失败也计入;`fa_ai_chat_log` 增加命中/总 Token,未知保留 NULL,不补历史。已接入评估、分批评估、产品解释、助手的提交与实际调用检查,并发在途用量允许实记超额。后台会员编辑可设置额度,用量页显示明细/汇总;记账独立事务且幂等。16 个测试入口、26 PHP/2 JS 语法及实际后台 19 项检查通过,包含 12 进程并发和迁移拒绝场景。迁移及说明位于后端 `database/migrate_ai_token_usage.php`、`database/ai-token-usage.md`。本轮仅本地开发和合成隔离验证,未迁移正式库、未发布、未真实模型调用;后续发布必须另获授权,停入口/worker并备份后配套迁移和部署。证据 `D:/AAdesk/202609/airz-backups/token-usage-20261019-r1`;这不改变下方已完成模型切换的正式状态,也未覆盖已有发布候选包。
- 最新模型切换:用户明确要求 Flash 用完后改用 `deepseek-v4-pro-0813`,2026-10-19 15:36:29 已切换正式后端与本地后端环境配置;补入 assessment_model、将助手/产品解释所用 model_flash 映射为 Pro,原 model_pro 保持。正式策略仍取 environment,无配置表写入;独立 App 只读回读确认三场景新请求均为 Pro,enabled/每日20次/最多3尝试/90秒及其他非目标字节保持。原唯一 worker 已支持 Pro,三服务 active、PID及重启计数保持,心跳 ready;无需重启或重发小程序。161源码/25产品与74规则保持,5历史报告/20匹配组/5详情及归属边界/34省份读取通过。旧评分档案有1会话整行摘要变化,诊断为15:19:51切换前已失败AI_BALANCE,原已发布报告保留;另4会话摘要保持,未自动重试。检查时活动评估/调用/三队列0,本轮模型调用0,Pro额度与真实推理未实测。旧发布环境摘要不回写,后续核验结合本次模型两行差异回执;原报告/任务模型字段未改。详见docs/模型切换回执-20261019.md;私密证据D:/AAdesk/202609/airz-backups/model-switch-pro-20261019-r1、服务器/root/airz-model-pro-20261019-r1。积分/协议等待办尚未推进,下方历史“Flash仍在用”的状态以本条为准。 - 最新模型切换:用户明确要求 Flash 用完后改用 `deepseek-v4-pro-0813`,2026-10-19 15:36:29 已切换正式后端与本地后端环境配置;补入 assessment_model、将助手/产品解释所用 model_flash 映射为 Pro,原 model_pro 保持。正式策略仍取 environment,无配置表写入;独立 App 只读回读确认三场景新请求均为 Pro,enabled/每日20次/最多3尝试/90秒及其他非目标字节保持。原唯一 worker 已支持 Pro,三服务 active、PID及重启计数保持,心跳 ready;无需重启或重发小程序。161源码/25产品与74规则保持,5历史报告/20匹配组/5详情及归属边界/34省份读取通过。旧评分档案有1会话整行摘要变化,诊断为15:19:51切换前已失败AI_BALANCE,原已发布报告保留;另4会话摘要保持,未自动重试。检查时活动评估/调用/三队列0,本轮模型调用0,Pro额度与真实推理未实测。旧发布环境摘要不回写,后续核验结合本次模型两行差异回执;原报告/任务模型字段未改。详见docs/模型切换回执-20261019.md;私密证据D:/AAdesk/202609/airz-backups/model-switch-pro-20261019-r1、服务器/root/airz-model-pro-20261019-r1。积分/协议等待办尚未推进,下方历史“Flash仍在用”的状态以本条为准。
- 最新产品加减分正式配置:用户“写入正式配置”授权后,2026-10-19 15:14:43将25款74条(49加/25减)原方案规则在单一事务中写入,版本各加1;仅物理other/update_time变化,当前正式准入、模板及其他字段保留。1356预检/1389事务检查/1020独立回读通过,161源码守卫/142发布路径、5历史报告/20匹配组/5详情与归属、25产品/34省份读取正常;三服务active且重启计数保持,唯一worker ready,检查时评估/调用/三队列0。模型仍66条件/100模板/6批,本轮调用0次;新捕获目录的评估生效,历史及已冻结任务不重算,无需重发小程序或后端。草案基于QA冻结目录,正式写入另以现场25款配置守卫并完整备份,未覆盖QA资料;本地route既有差异按已发布基线核实,未改生产源码。16份现场档案已下载核SHA、面板退出、一次性入口归档。详见docs/产品材料/产品加减分正式写入回执-20261019.md;私密档案D:/AAdesk/202609/airz-backups/product-soft-scores-production-20261019-r1。下方历史待配置状态以本条为准。 - 最新产品加减分正式配置:用户“写入正式配置”授权后,2026-10-19 15:14:43将25款74条(49加/25减)原方案规则在单一事务中写入,版本各加1;仅物理other/update_time变化,当前正式准入、模板及其他字段保留。1356预检/1389事务检查/1020独立回读通过,161源码守卫/142发布路径、5历史报告/20匹配组/5详情与归属、25产品/34省份读取正常;三服务active且重启计数保持,唯一worker ready,检查时评估/调用/三队列0。模型仍66条件/100模板/6批,本轮调用0次;新捕获目录的评估生效,历史及已冻结任务不重算,无需重发小程序或后端。草案基于QA冻结目录,正式写入另以现场25款配置守卫并完整备份,未覆盖QA资料;本地route既有差异按已发布基线核实,未改生产源码。16份现场档案已下载核SHA、面板退出、一次性入口归档。详见docs/产品材料/产品加减分正式写入回执-20261019.md;私密档案D:/AAdesk/202609/airz-backups/product-soft-scores-production-20261019-r1。下方历史待配置状态以本条为准。
@@ -1,5 +1,9 @@
# 照片存储配置落地记录 # 照片存储配置落地记录
## 2026-10-19 开关更新
正确服务器111.230.144.231;17:40:33随后端20261019-photo-points-token-r1上线,正式private_documents.enabled已设为带引号的true,目录/密钥/备份安排保持。线上document-use无正式版本,实际上传仍被协议及有效同意校验拦截。未发布待填草案、伪造同意或验收真实上传/恢复。新证据D:/AAdesk/202609/airz-backups/photo-points-token-production-20261019-r1;下方关闭状态为10月13日历史记录。
2026-10-13。用户授权继续服务器存储准备,已确认在线照片长期保存、备份副本滚动保留7天。本轮配置完成,照片上传仍关闭;正式协议、主体和删除联系方式尚待补齐。用户继续暂缓功能验收,小程序自行发行。 2026-10-13。用户授权继续服务器存储准备,已确认在线照片长期保存、备份副本滚动保留7天。本轮配置完成,照片上传仍关闭;正式协议、主体和删除联系方式尚待补齐。用户继续暂缓功能验收,小程序自行发行。
## 已落地配置 ## 已落地配置
+19
View File
@@ -1,5 +1,24 @@
# 积分规则与开发契约 # 积分规则与开发契约
## 2026-10-19 正式更新
积分一期已随20261019-photo-points-token-r1于17:40:33上线至111.230.144.231:每日10分,上海每日一次,两个字段及两个索引迁移完成,无历史补发。客户端最新完整策略校验和规则文案仍需用户重新编译发行,正式签到到账/真机未验收。记录见后端database/photo-points-token-release-20261019.md;下方未发布/正式关闭为历史阶段。
## 2026-10-19 一期本地收尾
用户在一期规则建议后要求“继续下一步”,本轮按每日签到10分、上海业务日每日一次、不补签、无连续奖励、积分不过期、不联动会员等级推进;兑换、提现、AI额度抵扣、资料和邀请奖励均关闭。不导入本机体验积分,不补发历史奖励。该决定覆盖下文2026-10-02的“首阶段建议待确认”,不代表正式活动已启用。
- 已补齐积分规则弹窗的业务日、加成、有效期、等级和用途说明;奖励额继续读取服务端,前端没有写死10分。暂停状态明确为“签到已暂停”,保留已配置规则、余额和明细。
- 前端读取规则时校验状态、版本、奖励、时区和完整一期策略。不支持或不一致的策略返回错误,不用一期文案套用未知权益,也不展示部分余额和签到入口。
- 后端新增 `database/points-phase1.env.example` 配置片段,版本 `points-checkin-20261019-v1`,默认关闭;它不由应用自动读取,未修改实际 `.env` 或 `application/extra/points.php`。`false`/`none`使用引号,测试采用框架相同的默认INI解析,避免被解析为空值。未来启用必须先核实积分表、完成配套迁移,再设置明确运行配置。
- 本轮251项后端纯内存检查、190项HTTP输入检查、110项迁移纯检查、98项写保护检查、21个前端规则/页面行为场景,以及原协议30/照片26场景通过。旧写保护测试补齐Token统计新增的只读查询替身,业务守卫保持。未启动数据库、Redis或worker,未重新执行数据库/HTTP持久化检查。
- 实际H5页面在390×844与1280×900检查通过,包含规则弹窗、草稿/暂停/异常规则、签到响应丢失后刷新并用原键确认。49项记录包含各接口鉴权观察;全部接口为浏览器拦截合成响应,不是正式到账或数据库验收。无运行错误和意外外部请求,控制台只有刻意制造的未发布协议404及丢响应网络错误。截图已查看,未见遮挡或横向溢出。
- 使用既有HBuilderX5.26编译器完成微信29页编译,产物仅在仓库外证据目录,不是已上传包。桌面CLI因HBuilderX未打开不可用,改用同一已安装编译器直接构建;Browser插件未提供,使用已有Python Playwright/Chrome,未安装依赖。
证据:`D:/AAdesk/202609/airz-backups/points-phase1-20261019-r1`。正式积分仍关闭、原表迁移未执行、未发布后端/前端或规则、未访问生产、未调用真实模型,真机和正式到账未验证。积分与累计Token额度互相独立;前一轮Token未发布改动完整保留。
下列章节保留前期设计和验收历史;业务规则是否确认以本节为准,历史“前端未发行”仅描述当时积分联调阶段,不否认后续小程序1.1.0已发行记录。积分正式启用须单独安排迁移、配套上线和到账验收;邀请体系及资料奖励不属于本期完成范围。
记录日期:2026-10-02。版本:points-page-20261002-v3。状态:**后端已随5fee3cf部署、积分页面读取与恢复隔离联调通过;业务规则未定稿,正式配置关闭,原表未迁移、前端未发行**。归属既有 POINT-001—004,不新增任务编号。POINT-002/004保持开发中;DEC-POINT/POINT-001仍待确认,POINT-003未实施。 记录日期:2026-10-02。版本:points-page-20261002-v3。状态:**后端已随5fee3cf部署、积分页面读取与恢复隔离联调通过;业务规则未定稿,正式配置关闭,原表未迁移、前端未发行**。归属既有 POINT-001—004,不新增任务编号。POINT-002/004保持开发中;DEC-POINT/POINT-001仍待确认,POINT-003未实施。
本文件作为积分实施入口;数据库具体增量见[积分数据库方案](D:/AAdesk/202609/airz/database/points-design.md),最新页面状态与证据见[开发进度0.51](开发进度.md)。未获规则确认时不填真实奖励,配置默认false/null;合成联调不代表正式积分可用。 本文件作为积分实施入口;数据库具体增量见[积分数据库方案](D:/AAdesk/202609/airz/database/points-design.md),最新页面状态与证据见[开发进度0.51](开发进度.md)。未获规则确认时不填真实奖励,配置默认false/null;合成联调不代表正式积分可用。
+106 -18
View File
@@ -6,15 +6,16 @@
<BrandLogo /> <BrandLogo />
<text class="welcome">欢迎登录 AI融资</text> <text class="welcome">欢迎登录 AI融资</text>
<text class="login-subtitle">智能评估,让融资更简单</text> <text class="login-subtitle">智能评估,让融资更简单</text>
<text class="form-heading">{{ mode == 'phone' ? '手机号登录 / 注册' : '密码登录' }}</text> <text class="form-heading">{{ wechatBindingTicket.length > 0 ? '微信绑定手机号' : (mode == 'phone' ? '手机号登录 / 注册' : '密码登录') }}</text>
<text v-if="wechatBindingTicket.length > 0" class="binding-note">首次微信登录需验证手机号。已有账户将直接关联,保留原有资料和积分。</text>
<view class="input-box"><text class="input-prefix">+86</text><input v-model="phone" :disabled="loading || authenticated" type="number" maxlength="11" placeholder="请输入手机号" class="field-input" /></view> <view class="input-box"><text class="input-prefix">+86</text><input v-model="phone" :disabled="loading || authenticated" type="number" maxlength="11" placeholder="请输入手机号" class="field-input" /></view>
<view v-if="mode == 'password'" class="input-box"><input v-model="password" :disabled="loading || authenticated" password placeholder="请输入登录密码" class="field-input" /></view> <view v-if="mode == 'password'" class="input-box"><input v-model="password" :disabled="loading || authenticated" password placeholder="请输入登录密码" class="field-input" /></view>
<view v-if="mode == 'phone' && smsSent" class="input-box"><input v-model="smsCode" :disabled="loading || authenticated" type="number" maxlength="6" placeholder="请输入 6 位验证码" class="field-input" /><text class="countdown" @click="resendSms">{{ countdown > 0 ? countdown.toString() + 's 后重试' : '重新获取' }}</text></view> <view v-if="mode == 'phone' && smsSent" class="input-box"><input v-model="smsCode" :disabled="loading || authenticated" type="number" maxlength="6" placeholder="请输入 6 位验证码" class="field-input" /><text class="countdown" @click="resendSms">{{ countdown > 0 ? countdown.toString() + 's 后重试' : '重新获取' }}</text></view>
<text v-if="errorMessage.length > 0" class="error-message">{{ errorMessage }}</text> <text v-if="errorMessage.length > 0" class="error-message">{{ errorMessage }}</text>
<button class="login-button" :disabled="loading" @click="login">{{ loading ? '处理中…' : (authenticated ? '确认同意记录并继续' : (mode == 'phone' ? (smsSent ? '登录' : '获取验证码') : '登录')) }}</button> <button class="login-button" :disabled="loading" @click="login">{{ loading ? '处理中…' : (authenticated ? '确认同意记录并继续' : (mode == 'phone' ? (smsSent ? (wechatBindingTicket.length > 0 ? '验证并登录' : '登录') : '获取验证码') : '登录')) }}</button>
<view class="mode-row"><text class="mode-link" @click="switchMode">{{ mode == 'phone' ? '密码登录' : '使用验证码登录' }}</text></view> <view class="mode-row"><text v-if="wechatBindingTicket.length == 0" class="mode-link" @click="switchMode">{{ mode == 'phone' ? '密码登录' : '使用验证码登录' }}</text><text v-else class="mode-link" @click="cancelWechatBinding">取消绑定,使用手机号登录</text></view>
<view class="other-login"><view class="line"></view><text class="other-text">其他登录方式</text><view class="line"></view></view> <view v-if="wechatBindingTicket.length == 0 && !authenticated" class="other-login"><view class="line"></view><text class="other-text">其他登录方式</text><view class="line"></view></view>
<view class="social-row"><view class="social-button" @click="socialLogin('微信')"><image class="social-icon" src="/packages/account/static/wechat.svg" mode="aspectFit" /><text class="social-label">微信</text></view><view class="social-button" @click="socialLogin('一键登录')"><image class="social-icon" src="/packages/account/static/quick-login.svg" mode="aspectFit" /><text class="social-label">一键登录</text></view></view> <view v-if="wechatBindingTicket.length == 0 && !authenticated" class="social-row"><view v-if="wechatSupported" class="social-button" @click="beginWechatLogin"><image class="social-icon" src="/packages/account/static/wechat.svg" mode="aspectFit" /><text class="social-label">微信登录</text></view><view class="social-button" @click="quickLogin"><image class="social-icon" src="/packages/account/static/quick-login.svg" mode="aspectFit" /><text class="social-label">一键登录</text></view></view>
<view class="agreement"><view class="check-hit" @click="toggleAgreement"><view :class="agreed ? 'check checked' : 'check'"><text v-if="agreed" class="check-mark">✓</text></view></view><text class="agreement-copy">我已阅读并同意<text class="agreement-link" @click="showAgreement('terms')">《用户协议》</text>和<text class="agreement-link" @click="showAgreement('privacy')">《隐私政策》</text></text></view> <view class="agreement"><view class="check-hit" @click="toggleAgreement"><view :class="agreed ? 'check checked' : 'check'"><text v-if="agreed" class="check-mark">✓</text></view></view><text class="agreement-copy">我已阅读并同意<text class="agreement-link" @click="showAgreement('terms')">《用户协议》</text>和<text class="agreement-link" @click="showAgreement('privacy')">《隐私政策》</text></text></view>
<text v-if="agreementState == 'ready'" class="agreement-note">确认版本:用户协议 {{ terms.version }} / 隐私政策 {{ privacy.version }}</text> <text v-if="agreementState == 'ready'" class="agreement-note">确认版本:用户协议 {{ terms.version }} / 隐私政策 {{ privacy.version }}</text>
<text v-else class="agreement-note">{{ agreementNote }}</text> <text v-else class="agreement-note">{{ agreementNote }}</text>
@@ -28,8 +29,9 @@
<script setup lang="uts"> <script setup lang="uts">
import { usePageLayout } from '@/services/layout.uts' import { usePageLayout } from '@/services/layout.uts'
import BrandLogo from '@/components/BrandLogo.uvue' import BrandLogo from '@/components/BrandLogo.uvue'
import { API_BASE_URL, requestSmsCode, loginWithSms, loginWithPassword } from '@/packages/account/services/auth.uts' import { API_BASE_URL, requestSmsCode, requestSmsLogin, requestPasswordLogin, acceptSession } from '@/packages/account/services/auth.uts'
import { getSession } from '@/services/api.uts' import { wechatLoginSupported, requestWechatAuthorization, requestWechatLogin, requestWechatBindingCode, bindWechatPhone, acceptWechatSession } from '@/packages/account/services/wechat-login.uts'
import { getSession, ApiRequestError } from '@/services/api.uts'
import { Agreement, emptyAgreement, fetchAgreement, agreementRoute, agreementUnavailable, agreementChanged, grantConsent, consentRequestId, sameConsentSession } from '@/services/agreements.uts' import { Agreement, emptyAgreement, fetchAgreement, agreementRoute, agreementUnavailable, agreementChanged, grantConsent, consentRequestId, sameConsentSession } from '@/services/agreements.uts'
import { consumeInitialLaunch } from '@/services/launch.uts' import { consumeInitialLaunch } from '@/services/launch.uts'
import { assistantReturnPath } from '@/services/assistant-return.uts' import { assistantReturnPath } from '@/services/assistant-return.uts'
@@ -79,6 +81,9 @@
const errorMessage = ref('') const errorMessage = ref('')
const agreed = ref(false) const agreed = ref(false)
const authenticated = ref(false) const authenticated = ref(false)
const wechatSupported = wechatLoginSupported()
const wechatBindingTicket = ref('')
let wechatBindingExpiresAt = 0; let wechatOriginSession = ''
const terms = ref<Agreement>(emptyAgreement()); const privacy = ref<Agreement>(emptyAgreement()) const terms = ref<Agreement>(emptyAgreement()); const privacy = ref<Agreement>(emptyAgreement())
const agreementState = ref('loading'); const agreementNote = ref('正在加载用户协议与隐私政策…') const agreementState = ref('loading'); const agreementNote = ref('正在加载用户协议与隐私政策…')
const isDemo = API_BASE_URL.length == 0 const isDemo = API_BASE_URL.length == 0
@@ -86,6 +91,10 @@
type LoginConsentCommand = { agreement: Agreement, requestId: string } type LoginConsentCommand = { agreement: Agreement, requestId: string }
let pendingConsents: LoginConsentCommand[] = []; let confirmedCount = 0; let selectedTerms: Agreement | null = null; let selectedPrivacy: Agreement | null = null let pendingConsents: LoginConsentCommand[] = []; let confirmedCount = 0; let selectedTerms: Agreement | null = null; let selectedPrivacy: Agreement | null = null
const showAgreement = (code: string) => { const item = code == 'terms' ? terms.value : privacy.value; const url = agreementRoute(code, item.version); if (pageVisible && url.length > 0) uni.navigateTo({ url }) } const showAgreement = (code: string) => { const item = code == 'terms' ? terms.value : privacy.value; const url = agreementRoute(code, item.version); if (pageVisible && url.length > 0) uni.navigateTo({ url }) }
const quickLogin = () => {
if (!pageVisible || loading.value || authenticated.value || wechatBindingTicket.value.length > 0) return
uni.showModal({ title: '一键登录暂未开放', content: '请先使用手机号登录。该登录方式将在正式服务接入后开放。', showCancel: false, confirmText: '我知道了' })
}
const sameAuthentication = (): boolean => { const current = getSession(); return sameConsentSession(authToken, authUserId, current?.getString('token') ?? '', current?.getString('userId') ?? '') } const sameAuthentication = (): boolean => { const current = getSession(); return sameConsentSession(authToken, authUserId, current?.getString('token') ?? '', current?.getString('userId') ?? '') }
const clearAuthentication = () => { authToken = ''; authUserId = ''; authenticated.value = false; pendingConsents = []; confirmedCount = 0; agreed.value = false; selectedTerms = null; selectedPrivacy = null; generation++ } const clearAuthentication = () => { authToken = ''; authUserId = ''; authenticated.value = false; pendingConsents = []; confirmedCount = 0; agreed.value = false; selectedTerms = null; selectedPrivacy = null; generation++ }
const toggleAgreement = () => { const toggleAgreement = () => {
@@ -121,7 +130,8 @@
clearInterval(countdownTimer) clearInterval(countdownTimer)
}) })
const switchMode = () => { if (loading.value || authenticated.value) return; mode.value = mode.value == 'phone' ? 'password' : 'phone'; errorMessage.value = '' } const switchMode = () => { if (loading.value || authenticated.value) return; mode.value = mode.value == 'phone' ? 'password' : 'phone'; errorMessage.value = '' }
const socialLogin = (provider: string) => { uni.showModal({ title: provider + '暂未开放', content: '请先使用手机号登录。该登录方式将在正式服务接入后开放。', showCancel: false, confirmText: '我知道了' }) } const sessionIdentity = (): string => { const session = getSession(); return (session?.getString('token') ?? '') + ':' + (session?.getString('userId') ?? '') }
const currentLoginRequest = (request: number, origin: string): boolean => pageVisible && request == generation && origin == sessionIdentity()
const validPhone = (): boolean => { const validPhone = (): boolean => {
if (!/^1[3-9][0-9]{9}$/.test(phone.value)) { errorMessage.value = '请输入正确的 11 位手机号'; return false } if (!/^1[3-9][0-9]{9}$/.test(phone.value)) { errorMessage.value = '请输入正确的 11 位手机号'; return false }
return true return true
@@ -133,6 +143,68 @@
if (countdown.value <= 1) { countdown.value = 0; clearInterval(countdownTimer) } else countdown.value -= 1 if (countdown.value <= 1) { countdown.value = 0; clearInterval(countdownTimer) } else countdown.value -= 1
}, 1000) }, 1000)
} }
const resetWechatBinding = () => {
wechatBindingTicket.value = ''; wechatBindingExpiresAt = 0; wechatOriginSession = ''
smsSent.value = false; smsCode.value = ''; countdown.value = 0; clearInterval(countdownTimer)
}
const cancelWechatBinding = () => {
if (loading.value || authenticated.value) return
generation++; resetWechatBinding(); mode.value = 'phone'; errorMessage.value = ''
}
const bindingReady = (): boolean => {
if (wechatBindingTicket.value.length == 0) return true
if (wechatOriginSession != sessionIdentity()) {
resetWechatBinding(); errorMessage.value = '登录状态已变化,请重新微信登录'; return false
}
if (Date.now() >= wechatBindingExpiresAt) {
resetWechatBinding(); errorMessage.value = '绑定已过期,请重新微信登录'; return false
}
return true
}
async function handleWechatError(error: any | null, request: number) {
if (!pageVisible || request != generation) return
if (agreementChanged(error)) {
resetWechatBinding(); agreed.value = false; selectedTerms = null; selectedPrivacy = null
loading.value = false; await loadAgreements(); errorMessage.value = '协议版本已更新,请重新阅读并勾选后微信登录。'
} else {
if (error instanceof ApiRequestError && error.errorCode == 'WECHAT_BINDING_EXPIRED') resetWechatBinding()
errorMessage.value = error instanceof Error ? error.message : '微信登录失败,请重新操作'
}
}
const acceptWechatResult = (result: UTSJSONObject, request: number, origin: string) => {
if (!currentLoginRequest(request, origin)) return
const token = acceptWechatSession(result)
const session = getSession()
if (session?.getString('token') != token) throw new Error('登录状态已变化,请重新登录')
authToken = token; authUserId = session?.getString('userId') ?? ''; authenticated.value = true
pendingConsents = []; resetWechatBinding()
uni.redirectTo({ url: afterLoginUrl })
}
const beginWechatLogin = async () => {
if (!pageVisible || loading.value || authenticated.value || !wechatSupported) return
// 密码登录保留的协议未发布例外,不适用于新微信入口。
if (agreementState.value != 'ready' || !agreed.value || selectedTerms == null || selectedPrivacy == null) {
errorMessage.value = '请先阅读并确认已正式发布的用户协议和隐私政策'; return
}
const commands: UTSJSONObject[] = [
{ agreementCode: 'terms', agreementVersion: selectedTerms!.version, contentHash: selectedTerms!.contentHash, requestId: consentRequestId() },
{ agreementCode: 'privacy', agreementVersion: selectedPrivacy!.version, contentHash: selectedPrivacy!.contentHash, requestId: consentRequestId() }
]
const request = ++generation; const origin = sessionIdentity()
loading.value = true; errorMessage.value = ''
try {
const code = await requestWechatAuthorization()
if (!currentLoginRequest(request, origin)) return
const result = await requestWechatLogin(code, commands)
if (!currentLoginRequest(request, origin)) return
if (result.getString('status') == 'bind_required') {
resetWechatBinding(); mode.value = 'phone'; phone.value = ''; password.value = ''
wechatBindingTicket.value = result.getString('bindingTicket') ?? ''
wechatBindingExpiresAt = Date.now() + (result.getNumber('expiresIn') ?? 0) * 1000; wechatOriginSession = origin
} else acceptWechatResult(result, request, origin)
} catch (error) { if (currentLoginRequest(request, origin)) await handleWechatError(error, request) }
finally { if (request == generation) loading.value = false }
}
async function confirmLoginConsents(request: number) { async function confirmLoginConsents(request: number) {
if (!pageVisible || request != generation || !sameAuthentication()) throw new Error('登录状态已变化,请重新登录') if (!pageVisible || request != generation || !sameAuthentication()) throw new Error('登录状态已变化,请重新登录')
if (pendingConsents.length == 0 && agreementState.value == 'ready') { if (pendingConsents.length == 0 && agreementState.value == 'ready') {
@@ -149,41 +221,53 @@
} }
const resendSms = async () => { const resendSms = async () => {
if (!pageVisible || authenticated.value || loading.value || countdown.value > 0 || !validPhone()) return if (!pageVisible || authenticated.value || loading.value || countdown.value > 0 || !validPhone()) return
if (!bindingReady()) return
if (!agreementReadyForLogin()) return if (!agreementReadyForLogin()) return
const request = generation const request = generation; const origin = sessionIdentity()
loading.value = true; errorMessage.value = '' loading.value = true; errorMessage.value = ''
try { try {
if (!(await requestSmsCode(phone.value))) throw new Error('验证码发送失败,请稍后重试') const sent = wechatBindingTicket.value.length > 0 ? await requestWechatBindingCode(wechatBindingTicket.value, phone.value) : await requestSmsCode(phone.value)
if (!pageVisible || request != generation) return if (!sent) throw new Error('验证码发送失败,请稍后重试')
if (!currentLoginRequest(request, origin)) return
startCountdown() startCountdown()
uni.showToast({ title: isDemo ? '请输入 6 位验证码' : '验证码已重新发送', icon: 'none' }) uni.showToast({ title: isDemo ? '请输入 6 位验证码' : '验证码已重新发送', icon: 'none' })
} catch (error) { if (pageVisible && request == generation) errorMessage.value = error instanceof Error ? error.message : '验证码发送失败,请稍后重试' } finally { if (request == generation) loading.value = false } } catch (error) { if (currentLoginRequest(request, origin)) await handleWechatError(error, request) } finally { if (request == generation) loading.value = false }
} }
const login = async () => { const login = async () => {
if (!pageVisible || loading.value) return if (!pageVisible || loading.value) return
errorMessage.value = '' errorMessage.value = ''
if (!bindingReady()) return
if (authenticated.value && !sameAuthentication()) { clearAuthentication(); errorMessage.value = '登录状态已变化,请重新登录'; return } if (authenticated.value && !sameAuthentication()) { clearAuthentication(); errorMessage.value = '登录状态已变化,请重新登录'; return }
if (pendingConsents.length == 0 && !agreementReadyForLogin()) return if (pendingConsents.length == 0 && !agreementReadyForLogin()) return
if (!authenticated.value && !validPhone()) return if (!authenticated.value && !validPhone()) return
const request = ++generation const request = ++generation; const origin = sessionIdentity()
loading.value = true loading.value = true
try { try {
if (authenticated.value) { await confirmLoginConsents(request); return } if (authenticated.value) { await confirmLoginConsents(request); return }
let token = '' let token = ''
if (mode.value == 'phone') { if (mode.value == 'phone') {
if (!smsSent.value) { if (!smsSent.value) {
if (!(await requestSmsCode(phone.value))) throw new Error('验证码发送失败,请稍后重试') const sent = wechatBindingTicket.value.length > 0 ? await requestWechatBindingCode(wechatBindingTicket.value, phone.value) : await requestSmsCode(phone.value)
if (!pageVisible || request != generation) return if (!sent) throw new Error('验证码发送失败,请稍后重试')
if (!currentLoginRequest(request, origin)) return
smsSent.value = true; startCountdown() smsSent.value = true; startCountdown()
uni.showToast({ title: isDemo ? '请输入 6 位验证码' : '验证码已发送', icon: 'none' }) uni.showToast({ title: isDemo ? '请输入 6 位验证码' : '验证码已发送', icon: 'none' })
return return
} else { } else {
if (!/^[0-9]{6}$/.test(smsCode.value)) throw new Error('请输入 6 位数字验证码') if (!/^[0-9]{6}$/.test(smsCode.value)) throw new Error('请输入 6 位数字验证码')
token = await loginWithSms(phone.value, smsCode.value) if (wechatBindingTicket.value.length > 0) {
const result = await bindWechatPhone(wechatBindingTicket.value, phone.value, smsCode.value)
acceptWechatResult(result, request, origin); return
}
const result = await requestSmsLogin(phone.value, smsCode.value)
if (!currentLoginRequest(request, origin)) return
token = acceptSession(result, phone.value)
} }
} else { } else {
if (password.value.length < 6) throw new Error('密码至少需要 6 位') if (password.value.length < 6) throw new Error('密码至少需要 6 位')
token = await loginWithPassword(phone.value, password.value) const result = await requestPasswordLogin(phone.value, password.value)
if (!currentLoginRequest(request, origin)) return
token = acceptSession(result, phone.value)
} }
if (!pageVisible || request != generation) return if (!pageVisible || request != generation) return
const session = getSession() const session = getSession()
@@ -192,19 +276,22 @@
await confirmLoginConsents(request) await confirmLoginConsents(request)
} catch (err) { } catch (err) {
if (!pageVisible || request != generation) return if (!pageVisible || request != generation) return
if (!authenticated.value && origin != sessionIdentity()) return
if (wechatBindingTicket.value.length > 0) { await handleWechatError(err, request); return }
if (agreementChanged(err)) { pendingConsents = []; confirmedCount = 0; agreed.value = false; selectedTerms = null; selectedPrivacy = null; loading.value = false; await loadAgreements(); errorMessage.value = '协议版本已更新,请重新阅读并勾选后继续。' } if (agreementChanged(err)) { pendingConsents = []; confirmedCount = 0; agreed.value = false; selectedTerms = null; selectedPrivacy = null; loading.value = false; await loadAgreements(); errorMessage.value = '协议版本已更新,请重新阅读并勾选后继续。' }
else errorMessage.value = (err instanceof Error ? err.message : '登录失败,请稍后重试') + (authenticated.value ? ';同意记录未确认,请点击按钮以原请求重试。' : '') else errorMessage.value = (err instanceof Error ? err.message : '登录失败,请稍后重试') + (authenticated.value ? ';同意记录未确认,请点击按钮以原请求重试。' : '')
} finally { if (request == generation) loading.value = false } } finally { if (request == generation) loading.value = false }
} }
onShow(async () => { onShow(async () => {
pageVisible = true pageVisible = true
if (wechatBindingTicket.value.length > 0) bindingReady()
if (authenticated.value && !sameAuthentication()) clearAuthentication() if (authenticated.value && !sameAuthentication()) clearAuthentication()
clearInterval(accountTimer); accountTimer = setInterval(() => { if (pageVisible && authenticated.value && !sameAuthentication()) { clearAuthentication(); loading.value = false; errorMessage.value = '登录状态已变化,请重新登录'; loadAgreements() } }, 1000) clearInterval(accountTimer); accountTimer = setInterval(() => { if (pageVisible && authenticated.value && !sameAuthentication()) { clearAuthentication(); loading.value = false; errorMessage.value = '登录状态已变化,请重新登录'; loadAgreements() } }, 1000)
if (pendingConsents.length == 0) await loadAgreements() if (pendingConsents.length == 0) await loadAgreements()
}) })
const hidePage = () => { pageVisible = false; generation++; agreementGeneration++; loading.value = false; clearInterval(accountTimer) } const hidePage = () => { pageVisible = false; generation++; agreementGeneration++; loading.value = false; clearInterval(accountTimer) }
onHide(() => hidePage()) onHide(() => hidePage())
onUnload(() => { hidePage(); clearInterval(countdownTimer); clearAuthentication() }) onUnload(() => { hidePage(); clearInterval(countdownTimer); resetWechatBinding(); clearAuthentication() })
onUnmounted(() => { clearInterval(countdownTimer); clearInterval(accountTimer) }) onUnmounted(() => { clearInterval(countdownTimer); clearInterval(accountTimer) })
</script> </script>
@@ -213,6 +300,7 @@
.welcome { font-size: 42rpx; line-height: 60rpx; font-weight: 800; margin-top: 38rpx; color: #152443; } .welcome { font-size: 42rpx; line-height: 60rpx; font-weight: 800; margin-top: 38rpx; color: #152443; }
.login-subtitle { font-size: 26rpx; color: #748198; line-height: 40rpx; margin-top: 8rpx; } .login-subtitle { font-size: 26rpx; color: #748198; line-height: 40rpx; margin-top: 8rpx; }
.form-heading { margin-top: 54rpx; font-size: 28rpx; font-weight: 600; color: #22324a; } .form-heading { margin-top: 54rpx; font-size: 28rpx; font-weight: 600; color: #22324a; }
.binding-note { margin-top: 16rpx; color: #748198; font-size: 24rpx; line-height: 38rpx; }
.input-box { height: 96rpx; margin-top: 22rpx; border: 1rpx solid #dce4ef; border-radius: 16rpx; padding: 0 24rpx; flex-direction: row; align-items: center; background-color: #fafcff; } .input-box { height: 96rpx; margin-top: 22rpx; border: 1rpx solid #dce4ef; border-radius: 16rpx; padding: 0 24rpx; flex-direction: row; align-items: center; background-color: #fafcff; }
.input-prefix { font-size: 28rpx; color: #31415a; margin-right: 22rpx; flex-shrink: 0; } .input-prefix { font-size: 28rpx; color: #31415a; margin-right: 22rpx; flex-shrink: 0; }
.field-input { flex: 1; min-width: 0; font-size: 28rpx; color: #17243c; } .field-input { flex: 1; min-width: 0; font-size: 28rpx; color: #17243c; }
+24 -8
View File
@@ -13,14 +13,14 @@
</view> </view>
<view v-if="errorMessage.length > 0" class="inline-error"><text class="inline-error-copy">{{ errorMessage }}</text><text class="inline-error-action" @click="load">重新加载</text></view> <view v-if="errorMessage.length > 0" class="inline-error"><text class="inline-error-copy">{{ errorMessage }}</text><text class="inline-error-action" @click="load">重新加载</text></view>
<view class="task-grid"> <view class="task-grid">
<view class="task" v-for="item in taskViews" :key="item.id" @click="doTask(item)"> <button class="task" v-for="item in taskViews" :key="item.id" :open-type="item.id == 'share' ? 'share' : ''" @click="doTask(item)">
<AppIcon :name="item.icon" :size="52" /> <AppIcon :name="item.icon" :size="52" />
<text class="task-name">{{ item.name }}</text> <text class="task-name">{{ item.name }}</text>
<text :class="item.status == 'completed' ? 'task-caption completed-caption' : 'task-caption'">{{ taskCaption(item) }}</text> <text :class="item.status == 'completed' ? 'task-caption completed-caption' : 'task-caption'">{{ taskCaption(item) }}</text>
</view> </button>
</view> </view>
<view class="invite-panel"> <view class="invite-panel">
<text class="panel-title">邀请与资料奖励</text><text class="invite-copy">当前只展示已发布的积分规则,邀请和资料奖励尚未开放。</text> <text class="panel-title">分享与邀请</text><text class="invite-copy">将小程序分享给好友,分享和邀请奖励尚未开放。</text>
</view> </view>
<view class="records"> <view class="records">
<view class="record-head" @click="toggleRecords"><text class="panel-title">积分明细</text><view class="record-toggle"><text class="more">{{ recordsExpanded ? '收起' : '展开' }}</text><AppIcon name="chevron-right" :size="26" :class="recordsExpanded ? 'record-arrow expanded-arrow' : 'record-arrow'" muted /></view></view> <view class="record-head" @click="toggleRecords"><text class="panel-title">积分明细</text><view class="record-toggle"><text class="more">{{ recordsExpanded ? '收起' : '展开' }}</text><AppIcon name="chevron-right" :size="26" :class="recordsExpanded ? 'record-arrow expanded-arrow' : 'record-arrow'" muted /></view></view>
@@ -48,7 +48,7 @@
type TaskView = { id: string, icon: string, name: string, status: string, reward: number | null, ruleVersion: string | null } type TaskView = { id: string, icon: string, name: string, status: string, reward: number | null, ruleVersion: string | null }
const taskDefinitions: UTSJSONObject[] = [ const taskDefinitions: UTSJSONObject[] = [
{ id: 'checkin', icon: 'calendar-check', name: '签到' }, { id: 'checkin', icon: 'calendar-check', name: '签到' },
{ id: 'profile', icon: 'edit-file', name: '完善资料' }, { id: 'share', icon: 'share', name: '分享' },
{ id: 'invitation', icon: 'gift', name: '邀请好友' } { id: 'invitation', icon: 'gift', name: '邀请好友' }
] ]
const pendingKey = 'finance.points.checkin.pending' const pendingKey = 'finance.points.checkin.pending'
@@ -75,6 +75,7 @@
const pointsNote = computed((): string => { const pointsNote = computed((): string => {
if (points.value == null) return '正在同步账户数据' if (points.value == null) return '正在同步账户数据'
if (points.value.balanceState == 'anomaly') return '账户积分状态异常,请稍后重试' if (points.value.balanceState == 'anomaly') return '账户积分状态异常,请稍后重试'
if (rules.value?.status == 'paused') return '签到已暂停,已有积分和明细仍可查看'
if (rules.value == null || rules.value.status != 'published') return '积分服务尚未发布,签到暂不可用' if (rules.value == null || rules.value.status != 'published') return '积分服务尚未发布,签到暂不可用'
return '服务器同步 · ' + points.value.serverDate return '服务器同步 · ' + points.value.serverDate
}) })
@@ -125,6 +126,7 @@
const checkInTask = taskPage.items.find((item: PointsTask): boolean => item.task == 'checkin') const checkInTask = taskPage.items.find((item: PointsTask): boolean => item.task == 'checkin')
if (summary.serverDate != taskPage.serverDate || summary.timezone != taskPage.timezone || summary.timezone != currentRules.timezone if (summary.serverDate != taskPage.serverDate || summary.timezone != taskPage.timezone || summary.timezone != currentRules.timezone
|| summary.ruleVersion != currentRules.ruleVersion || summary.checkInReward != currentRules.checkInReward || checkInTask == null || summary.ruleVersion != currentRules.ruleVersion || summary.checkInReward != currentRules.checkInReward || checkInTask == null
|| (summary.checkInEnabled && currentRules.status != 'published')
|| checkInTask.ruleVersion != summary.ruleVersion || checkInTask.reward != summary.checkInReward || checkInTask.ruleVersion != summary.ruleVersion || checkInTask.reward != summary.checkInReward
|| (checkInTask.status == 'completed') != (summary.ruleVersion != null && summary.signedToday) || (checkInTask.status == 'completed') != (summary.ruleVersion != null && summary.signedToday)
|| (checkInTask.status == 'available') != (summary.checkInEnabled && !summary.signedToday)) throw new Error('积分状态已变化,请重新加载') || (checkInTask.status == 'available') != (summary.checkInEnabled && !summary.signedToday)) throw new Error('积分状态已变化,请重新加载')
@@ -164,12 +166,14 @@
if (!sameAccount()) return if (!sameAccount()) return
const currentRules = rules.value const currentRules = rules.value
if (currentRules == null) { uni.showModal({ title: '积分规则', content: '积分规则暂时无法读取,请稍后重试。', showCancel: false, confirmText: '知道了' }); return } if (currentRules == null) { uni.showModal({ title: '积分规则', content: '积分规则暂时无法读取,请稍后重试。', showCancel: false, confirmText: '知道了' }); return }
if (currentRules.status != 'published' || currentRules.checkInReward == null || currentRules.ruleVersion == null) { if (currentRules.status == 'draft' || currentRules.publishedRules == null || currentRules.checkInReward == null || currentRules.ruleVersion == null) {
uni.showModal({ title: '积分规则', content: '积分服务尚未发布,签到和奖励暂未开放。', showCancel: false, confirmText: '知道了' }); return uni.showModal({ title: '积分规则', content: '积分服务尚未发布,签到和奖励暂未开放。', showCancel: false, confirmText: '知道了' }); return
} }
uni.showModal({ title: '积分规则', content: '当前规则版本:' + currentRules.ruleVersion + '\n每日签到奖励:' + currentRules.checkInReward.toString() + ' 分\n每日限签到 1 次,不支持补签。', showCancel: false, confirmText: '知道了' }) const statusNote = currentRules.status == 'paused' ? '签到已暂停,已有积分和明细仍可查看。\n\n' : ''
uni.showModal({ title: '积分规则', content: statusNote + '每日签到奖励:' + currentRules.checkInReward.toString() + ' 分\n按北京时间每日0点划分,每日限签到1次,不支持补签。\n连续签到无额外加成,分享和邀请奖励暂未开放。\n积分不过期,不影响会员等级。\n暂不支持兑换、提现或抵扣AI调用额度。', showCancel: false, confirmText: '知道了' })
} }
const taskCaption = (item: TaskView): string => { const taskCaption = (item: TaskView): string => {
if (item.id == 'share') return '分享小程序'
if (item.id == 'checkin' && checkingIn.value) return '正在确认…' if (item.id == 'checkin' && checkingIn.value) return '正在确认…'
if (item.id == 'checkin' && pendingRequest.value != null) return '确认上次状态' if (item.id == 'checkin' && pendingRequest.value != null) return '确认上次状态'
if (item.status == 'completed') return '今日已完成' if (item.status == 'completed') return '今日已完成'
@@ -209,9 +213,17 @@
const doTask = (item: TaskView): void => { const doTask = (item: TaskView): void => {
if (!sameAccount()) return if (!sameAccount()) return
if (item.id == 'checkin') { checkIn(); return } if (item.id == 'checkin') { checkIn(); return }
if (item.id == 'profile') { uni.navigateTo({ url: '/packages/finance/archive/archive' }); return } if (item.id == 'share') {
// #ifndef MP-WEIXIN
uni.showModal({ title: '分享', content: '请使用浏览器或客户端的分享功能,将页面分享给好友。', showCancel: false, confirmText: '知道了' })
// #endif
return
}
uni.showToast({ title: '邀请奖励暂未开放', icon: 'none' }) uni.showToast({ title: '邀请奖励暂未开放', icon: 'none' })
} }
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({ title: 'AI融资 · 智能融资分析与产品匹配', path: '/pages/home/home' }))
// #endif
const recordName = (item: PointsLedgerItem): string => item.businessType == 'checkin' ? '每日签到' : '历史积分记录' const recordName = (item: PointsLedgerItem): string => item.businessType == 'checkin' ? '每日签到' : '历史积分记录'
const recordDate = (item: PointsLedgerItem): string => item.businessDate ?? item.createdAt ?? '日期待核实' const recordDate = (item: PointsLedgerItem): string => item.businessDate ?? item.createdAt ?? '日期待核实'
const deltaLabel = (item: PointsLedgerItem): string => item.delta >= 0 ? '+' + item.delta.toString() : item.delta.toString() const deltaLabel = (item: PointsLedgerItem): string => item.delta >= 0 ? '+' + item.delta.toString() : item.delta.toString()
@@ -236,7 +248,11 @@
.inline-error-copy { flex: 1; color: #b0473e; font-size: 23rpx; line-height: 34rpx; } .inline-error-copy { flex: 1; color: #b0473e; font-size: 23rpx; line-height: 34rpx; }
.inline-error-action { color: #0868f7; font-size: 23rpx; margin-left: 18rpx; } .inline-error-action { color: #0868f7; font-size: 23rpx; margin-left: 18rpx; }
.task-grid { min-height: 186rpx; flex-direction: row; background-color: #ffffff; border-radius: 18rpx; margin-top: 20rpx; } .task-grid { min-height: 186rpx; flex-direction: row; background-color: #ffffff; border-radius: 18rpx; margin-top: 20rpx; }
.task { flex: 1; align-items: center; justify-content: center; padding: 24rpx 0; } .task { flex: 1; flex-direction: column; align-items: center; justify-content: center; padding: 24rpx 0; margin: 0; border-radius: 0; background-color: transparent; line-height: 1.5;
/* #ifndef APP */
display: flex;
/* #endif */
}
.task-name { margin-top: 14rpx; font-size: 24rpx; color: #172640; font-weight: 600; } .task-name { margin-top: 14rpx; font-size: 24rpx; color: #172640; font-weight: 600; }
.task-caption { margin-top: 7rpx; font-size: 22rpx; color: #66758a; } .task-caption { margin-top: 7rpx; font-size: 22rpx; color: #66758a; }
.completed-caption { color: #168454; } .completed-caption { color: #168454; }
+12 -3
View File
@@ -7,7 +7,7 @@ export async function requestSmsCode(phone: string): Promise<boolean> {
return true return true
} }
function acceptSession(result: UTSJSONObject, phone: string): string { export function acceptSession(result: UTSJSONObject, phone: string): string {
const token = result['token'] as string | null const token = result['token'] as string | null
const userId = result['userId'] as string | null const userId = result['userId'] as string | null
const expiresAt = result['expiresAt'] as string | null const expiresAt = result['expiresAt'] as string | null
@@ -19,11 +19,20 @@ function acceptSession(result: UTSJSONObject, phone: string): string {
} }
export async function loginWithSms(phone: string, code: string): Promise<string> { export async function loginWithSms(phone: string, code: string): Promise<string> {
return acceptSession(await requestJson('/auth/sms-login', 'POST', { phone, code }), phone) return acceptSession(await requestSmsLogin(phone, code), phone)
} }
export async function loginWithPassword(phone: string, password: string): Promise<string> { export async function loginWithPassword(phone: string, password: string): Promise<string> {
return acceptSession(await requestJson('/auth/password-login', 'POST', { phone, password }), phone) return acceptSession(await requestPasswordLogin(phone, password), phone)
}
// 页面先校验自身代次与原会话,再接受认证结果;迟到响应不能切换当前账户。
export async function requestSmsLogin(phone: string, code: string): Promise<UTSJSONObject> {
return await requestJson('/auth/sms-login', 'POST', { phone, code })
}
export async function requestPasswordLogin(phone: string, password: string): Promise<UTSJSONObject> {
return await requestJson('/auth/password-login', 'POST', { phone, password })
} }
export async function logoutAccount(): Promise<void> { export async function logoutAccount(): Promise<void> {
@@ -0,0 +1,90 @@
import { requestJson } from '@/services/api.uts'
import { acceptSession } from './auth.uts'
export function wechatLoginSupported(): boolean {
// App微信SDK及各平台应用凭据接入后再开放原生入口。
// #ifdef MP-WEIXIN
return true
// #endif
// #ifndef MP-WEIXIN
return false
// #endif
}
export function requestWechatAuthorization(): Promise<string> {
// #ifdef MP-WEIXIN
return new Promise<string>((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (result) => {
const code = result.code
if (code.length == 0) { reject(new Error('未能取得微信登录凭证,请重新操作')); return }
resolve(code)
},
fail: (error) => {
const cancelled = error.errMsg.toLowerCase().includes('cancel') || error.errCode == 1310509
reject(new Error(cancelled ? '已取消微信登录' : '微信授权失败,请重新操作'))
}
})
})
// #endif
// #ifndef MP-WEIXIN
return new Promise<string>((resolve, reject) => {
reject(new Error('当前平台暂未开放微信登录,请使用手机号登录'))
})
// #endif
}
function validTicket(value: string): boolean { return /^[A-Za-z0-9_-]{32,128}$/.test(value) }
function validPhone(value: string): boolean { return /^1[3-9][0-9]{9}$/.test(value) }
function validateResult(result: UTSJSONObject): UTSJSONObject {
const status = result.getString('status') ?? ''
if (status == 'bind_required') {
const expires = result.getNumber('expiresIn') ?? 0
if (!validTicket(result.getString('bindingTicket') ?? '') || expires < 1 || expires > 600 || Math.floor(expires) != expires || result['token'] != null)
throw new Error('微信绑定响应格式不完整,请重新微信登录')
} else if (status == 'authenticated') {
const expiry = Date.parse(result.getString('expiresAt') ?? '')
if (result.getBoolean('consentsConfirmed') != true || !validPhone(result.getString('phone') ?? '')
|| !/^[1-9][0-9]{0,18}$/.test(result.getString('userId') ?? '') || (result.getString('token') ?? '').length == 0
|| isNaN(expiry) || expiry <= Date.now()) throw new Error('微信登录响应缺少有效账户或协议确认')
} else throw new Error('微信登录响应状态无效')
return result
}
export async function requestWechatLogin(code: string, agreements: UTSJSONObject[]): Promise<UTSJSONObject> {
if (code.length == 0 || code.length > 256 || agreements.length != 2) throw new Error('请重新确认协议并微信登录')
let terms = false; let privacy = false; let previousRequestId = ''
for (const item of agreements) {
const agreementCode = item['agreementCode'] as string | null ?? ''
const requestId = item['requestId'] as string | null ?? ''
if ((agreementCode == 'terms' && !terms)) terms = true
else if ((agreementCode == 'privacy' && !privacy)) privacy = true
else throw new Error('用户协议和隐私政策必须分别确认')
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(item['agreementVersion'] as string | null ?? '')
|| !/^[a-f0-9]{64}$/.test(item['contentHash'] as string | null ?? '') || !/^[A-Za-z0-9_-]{16,64}$/.test(requestId)
|| requestId == previousRequestId) throw new Error('协议确认信息不完整,请重新阅读并确认')
previousRequestId = requestId
}
return validateResult(await requestJson('/auth/wechat-login', 'POST', { platform: 'mp-weixin', code, agreements }))
}
export async function requestWechatBindingCode(bindingTicket: string, phone: string): Promise<boolean> {
if (!validTicket(bindingTicket) || !validPhone(phone)) throw new Error('请重新微信登录并输入正确手机号')
await requestJson('/auth/wechat/sms-code', 'POST', { bindingTicket, phone })
return true
}
export async function bindWechatPhone(bindingTicket: string, phone: string, code: string): Promise<UTSJSONObject> {
if (!validTicket(bindingTicket) || !validPhone(phone) || !/^[0-9]{6}$/.test(code)) throw new Error('请核对手机号和6位验证码')
const result = validateResult(await requestJson('/auth/wechat/bind-phone', 'POST', { bindingTicket, phone, code }))
if (result.getString('status') != 'authenticated') throw new Error('手机号绑定尚未完成,请重新微信登录')
return result
}
export function acceptWechatSession(result: UTSJSONObject): string {
validateResult(result)
if (result.getString('status') != 'authenticated') throw new Error('请先完成手机号绑定')
return acceptSession(result, result.getString('phone') ?? '')
}
+3 -3
View File
@@ -18,7 +18,7 @@
import { usePageLayout } from '@/services/layout.uts' import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts' import { getSession } from '@/services/api.uts'
import { fetchAssessmentStatus, retryAssessment } from '@/packages/finance/services/records.uts' import { fetchAssessmentStatus, retryAssessment } from '@/packages/finance/services/records.uts'
import { reportRoute } from '@/services/report-route.uts' import { matchReturnPath } from '@/services/match-route.uts'
const { statusStyle, pageStyle } = usePageLayout() const { statusStyle, pageStyle } = usePageLayout()
const title = ref('正在读取评估状态') const title = ref('正在读取评估状态')
const message = ref(''); const error = ref(''); const assessmentId = ref('') const message = ref(''); const error = ref(''); const assessmentId = ref('')
@@ -62,7 +62,7 @@
message.value = result.message.length > 0 ? result.message : (result.status == 'processing' ? (result.stage == 'queued' ? '资料已保存,等待分析。您可以稍后从档案查看。' : '正在分析自填资料,您可以稍后从档案查看。') : (result.status == 'failed' ? '分析未完成,可重新分析或刷新状态。已提交的资料无需重新填写。' : '分析已完成,可查看本次报告。')) message.value = result.message.length > 0 ? result.message : (result.status == 'processing' ? (result.stage == 'queued' ? '资料已保存,等待分析。您可以稍后从档案查看。' : '正在分析自填资料,您可以稍后从档案查看。') : (result.status == 'failed' ? '分析未完成,可重新分析或刷新状态。已提交的资料无需重新填写。' : '分析已完成,可查看本次报告。'))
if (result.status == 'succeeded') { if (result.status == 'succeeded') {
// 只打开本次已发布版本;重评处理中或旧报告回包不会触发跳转。 // 只打开本次已发布版本;重评处理中或旧报告回包不会触发跳转。
const target = result.hasReport && result.publishedRevisionId == result.revisionId ? reportRoute(assessmentId.value, 'matches', result.publishedRevisionId) : '' const target = result.hasReport && result.publishedRevisionId == result.revisionId ? matchReturnPath('matches', assessmentId.value, '', result.publishedRevisionId, '', 'all') : ''
succeeded.value = target.length > 0 succeeded.value = target.length > 0
if (target.length > 0) { publishedRevisionId.value = result.publishedRevisionId; uni.redirectTo({ url: target }); return } if (target.length > 0) { publishedRevisionId.value = result.publishedRevisionId; uni.redirectTo({ url: target }); return }
message.value = '本次报告尚未就绪,请刷新状态。' message.value = '本次报告尚未就绪,请刷新状态。'
@@ -95,7 +95,7 @@
} catch (err) { if (visible && request == generation && getSession()?.getString('token') == owner) error.value = err instanceof Error ? err.message : '重新分析失败,请重试' } } catch (err) { if (visible && request == generation && getSession()?.getString('token') == owner) error.value = err instanceof Error ? err.message : '重新分析失败,请重试' }
finally { retrying.value = false } finally { retrying.value = false }
} }
const report = () => { const target = reportRoute(assessmentId.value, 'matches', publishedRevisionId.value); if (succeeded.value && target.length > 0) uni.redirectTo({ url: target }) } const report = () => { const target = matchReturnPath('matches', assessmentId.value, '', publishedRevisionId.value, '', 'all'); if (succeeded.value && target.length > 0) uni.redirectTo({ url: target }) }
const archive = () => { uni.reLaunch({ url: '/packages/finance/archive/archive?assessmentId=' + encodeURIComponent(assessmentId.value) }) } const archive = () => { uni.reLaunch({ url: '/packages/finance/archive/archive?assessmentId=' + encodeURIComponent(assessmentId.value) }) }
onLoad((options) => { if (options != null) assessmentId.value = options['assessmentId'] ?? '' }) onLoad((options) => { if (options != null) assessmentId.value = options['assessmentId'] ?? '' })
onShow(() => { visible = true; loadStatus() }) onShow(() => { visible = true; loadStatus() })
+1 -1
View File
@@ -19,7 +19,7 @@
<view class="context-heading" @click="contextOpen = !contextOpen"><text class="context-summary">{{ context!.title }} · {{ context!.products.length > 0 ? context!.products.length.toString() + '项产品' : '报告解读' }}</text><text class="context-toggle">{{ contextOpen ? '收起' : '展开' }}</text></view> <view class="context-heading" @click="contextOpen = !contextOpen"><text class="context-summary">{{ context!.title }} · {{ context!.products.length > 0 ? context!.products.length.toString() + '项产品' : '报告解读' }}</text><text class="context-toggle">{{ contextOpen ? '收起' : '展开' }}</text></view>
<template v-if="contextOpen"> <template v-if="contextOpen">
<text v-if="context!.products.length == 0" class="context-note">围绕这份已保存报告解答,不重新评估</text> <text v-if="context!.products.length == 0" class="context-note">围绕这份已保存报告解答,不重新评估</text>
<view v-for="item in context!.products" :key="item.id" class="context-product"><text class="context-product-name">{{ item.name }}</text><text :class="item.state == 'eligible' ? 'product-state good-state' : 'product-state'">{{ item.stateLabel }}</text></view> <view v-for="item in context!.products" :key="item.id" class="context-product"><text class="context-product-name">{{ item.name }}</text></view>
<text class="context-link context-source" @click="source">{{ sourceLabel }}</text> <text class="context-link context-source" @click="source">{{ sourceLabel }}</text>
</template> </template>
</view> </view>
+43 -61
View File
@@ -29,29 +29,25 @@
</view> </view>
<text class="overview-note">{{ orderingNote }}</text> <text class="overview-note">{{ orderingNote }}</text>
<view class="details-toggle" @click="detailsOpen = !detailsOpen"><text class="summary-title">详细条件与匹配依据</text><text class="detail-toggle-label">{{ detailsOpen ? '收起' : '展开' }}</text></view> <view class="details-toggle" @click="detailsOpen = !detailsOpen"><text class="summary-title">详细条件与匹配依据</text><text class="detail-toggle-label">{{ detailsOpen ? '收起' : '展开' }}</text></view>
<text v-if="detailsOpen" class="scroll-hint">左右滑动查看各产品 · 长内容可在单元格内上下滑动</text> <text v-if="detailsOpen" class="scroll-hint">各产品并排展示 · 向下查看完整条件与依据</text>
<view v-if="detailsOpen" class="table"> <view v-if="detailsOpen" class="overview-table detail-table">
<view class="rail"><view class="rail-head"><text class="rail-title">对比指标</text><text class="hint">同次评估</text></view><view v-for="row in rows" :key="row.id" class="label-cell" :style="rowStyle(row)"><text class="row-label">{{ row.label }}</text></view></view> <view class="overview-row">
<scroll-view scroll-x class="horizontal"> <view class="overview-label"><text class="row-label">对比指标</text><text class="hint">同次评估</text></view>
<view class="columns" :style="columnsStyle()"> <view v-for="column in columns" :key="column.id" class="overview-value detail-value">
<view v-for="column in columns" :key="column.id" class="column"> <template v-if="usable(column)">
<scroll-view scroll-y class="product-head"><view class="head-content"> <image class="overview-logo" :src="column.data!.item.product.logo.length > 0 ? column.data!.item.product.logo : '/static/mobile/icons-generated/building.png'" mode="aspectFit" @error="column.data!.item.product.logo = ''" />
<template v-if="usable(column)"> <text class="overview-name">{{ column.data!.item.product.name }}</text>
<image class="overview-logo" :src="column.data!.item.product.logo.length > 0 ? column.data!.item.product.logo : '/static/mobile/icons-generated/building.png'" mode="aspectFit" @error="column.data!.item.product.logo = ''" /> <text class="overview-bank">{{ column.data!.item.product.bank }} · v{{ column.data!.item.product.version }}</text>
<text class="product-name">{{ column.data!.item.product.name }}</text> <text class="overview-link" @click="detail(column.id)">查看依据与详情 ›</text>
<text class="hint">{{ column.data!.item.product.bank }} · v{{ column.data!.item.product.version }}</text> </template>
<text :class="column.data!.item.state == 'eligible' ? 'state good' : (column.data!.item.state == 'ineligible' ? 'state blocked' : 'state pending')">{{ column.data!.item.stateLabel }}</text> <template v-else><text class="overview-name">产品 #{{ column.id }}</text><text class="hint">{{ column.loading ? '正在读取历史资料…' : problem(column) }}</text><text v-if="!column.loading" class="overview-link" @click="loadColumn(column.id)">重试此列</text></template>
<text class="detail" @click="detail(column.id)">查看依据与详情 ›</text> <text class="overview-remove" @click="remove(column.id)">移除</text>
</template>
<template v-else><text class="product-name">产品 #{{ column.id }}</text><text class="hint">{{ column.loading ? '正在读取历史资料…' : problem(column) }}</text><button v-if="!column.loading" class="retry" @click="loadColumn(column.id)">重试此列</button></template>
<text class="remove" @click="remove(column.id)">移除</text>
</view></scroll-view>
<view v-for="row in rows" :key="row.id" class="cell" :style="rowStyle(row)">
<scroll-view scroll-y class="cell-scroll"><text :class="row.id == 'amount' ? 'cell-text amount' : 'cell-text'">{{ cell(column, row.id) }}</text></scroll-view>
</view>
</view>
</view> </view>
</scroll-view> </view>
<view v-for="row in rows" :key="row.id" class="overview-row">
<view class="overview-label"><text class="row-label">{{ row.label }}</text></view>
<view v-for="column in columns" :key="column.id" class="overview-value detail-value"><text :class="row.id == 'amount' ? 'cell-text cell-amount' : 'cell-text'">{{ cell(column, row.id) }}</text></view>
</view>
</view> </view>
</template> </template>
<text class="footer-note">历史参数保留评估时版本;当前可用状态单独展示。缺失信息不会被推断为零。</text> <text class="footer-note">历史参数保留评估时版本;当前可用状态单独展示。缺失信息不会被推断为零。</text>
@@ -70,12 +66,12 @@
import { MatchDetail, MatchReason, validRecordId, fetchMatchDetailRecord, hasProductValue, matchPercentage } from '@/packages/finance/services/matches.uts' import { MatchDetail, MatchReason, validRecordId, fetchMatchDetailRecord, hasProductValue, matchPercentage } from '@/packages/finance/services/matches.uts'
import { parseCompareIds, saveCompareSelection, compareQuery, sameMatchSnapshot, compareReasonText } from '@/packages/finance/services/compare.uts' import { parseCompareIds, saveCompareSelection, compareQuery, sameMatchSnapshot, compareReasonText } from '@/packages/finance/services/compare.uts'
type CompareColumn = { id: string, loading: boolean, error: string, data: MatchDetail | null } type CompareColumn = { id: string, loading: boolean, error: string, data: MatchDetail | null }
type CompareRow = { id: string, label: string, height: number } type CompareRow = { id: string, label: string }
const { pageStyle, contentStyle } = usePageLayout() const { pageStyle, contentStyle } = usePageLayout()
const revisionId = ref(''); const assessmentId = ref(''); const ids = ref<string[]>([]); const columns = ref<CompareColumn[]>([]); const routeError = ref('') const revisionId = ref(''); const assessmentId = ref(''); const ids = ref<string[]>([]); const columns = ref<CompareColumn[]>([]); const routeError = ref('')
const detailsOpen = ref(false) const detailsOpen = ref(false)
// 首屏并排展示短参数;长证据保留原宽表,展开后读取完整内容,不截断判断依据。 // 按指标组织整行,产品列均分可用宽度,长证据自然撑高行而不截断。
const overviewDefinitions: CompareRow[] = [{ id: 'state', label: '匹配状态', height: 0 }, { id: 'score', label: '匹配度', height: 0 }, { id: 'amount', label: '额度范围', height: 0 }, { id: 'term', label: '期限范围', height: 0 }, { id: 'rate', label: '年化利率', height: 0 }] const overviewDefinitions: CompareRow[] = [{ id: 'score', label: '匹配度' }, { id: 'amount', label: '额度范围' }, { id: 'term', label: '期限范围' }, { id: 'rate', label: '年化利率' }, { id: 'availability', label: '当前状态' }]
let visible = false; let generation = 0 let visible = false; let generation = 0
let owner = ''; let accountTimer = 0; let frozenDetail: MatchDetail | null = null let owner = ''; let accountTimer = 0; let frozenDetail: MatchDetail | null = null
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 } const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
@@ -93,16 +89,14 @@
// #endif // #endif
} }
const detailRows: CompareRow[] = [ const detailRows: CompareRow[] = [
{ id: 'availability', label: '当前状态', height: 160 }, { id: 'availability', label: '当前状态' },
{ id: 'score', label: '匹配度', height: 120 }, { id: 'score', label: '匹配度' },
{ id: 'amount', label: '额度范围', height: 120 }, { id: 'term', label: '期限范围', height: 120 }, { id: 'rate', label: '年化利率', height: 120 }, { id: 'amount', label: '额度范围' }, { id: 'term', label: '期限范围' }, { id: 'rate', label: '年化利率' },
{ id: 'objects', label: '适用对象', height: 120 }, { id: 'regions', label: '适用地区', height: 140 }, { id: 'objects', label: '适用对象' }, { id: 'regions', label: '适用地区' },
{ id: 'positive', label: '推荐与参考理由', height: 360 }, { id: 'blocking', label: '不推荐原因', height: 300 }, { id: 'cautions', label: '注意事项', height: 300 }, { id: 'positive', label: '推荐与参考理由' }, { id: 'blocking', label: '不推荐原因' }, { id: 'cautions', label: '注意事项' },
{ id: 'conditions', label: '准入条件', height: 300 }, { id: 'repayment', label: '还款方式', height: 120 }, { id: 'conditions', label: '准入条件' }, { id: 'repayment', label: '还款方式' },
{ id: 'materials', label: '准备材料', height: 260 }, { id: 'channel', label: '办理渠道', height: 120 }, { id: 'source', label: '资料来源', height: 260 } { id: 'materials', label: '准备材料' }, { id: 'channel', label: '办理渠道' }, { id: 'source', label: '资料来源' }
] ]
const rowStyle = (row: CompareRow): string => 'height:' + row.height + 'rpx;'
const columnsStyle = (): string => 'width:' + (columns.value.length * 350) + 'rpx;'
const problem = (column: CompareColumn): string => { const problem = (column: CompareColumn): string => {
if (column.error.length > 0) return column.error if (column.error.length > 0) return column.error
if (column.data == null) return '资料尚未读取' if (column.data == null) return '资料尚未读取'
@@ -114,15 +108,15 @@
const complete = computed((): boolean => ids.value.length >= 2 && columns.value.length == ids.value.length && columns.value.every((c: CompareColumn): boolean => usable(c))) const complete = computed((): boolean => ids.value.length >= 2 && columns.value.length == ids.value.length && columns.value.every((c: CompareColumn): boolean => usable(c)))
const overviewClass = (column: CompareColumn, key: string): string => { const overviewClass = (column: CompareColumn, key: string): string => {
if (key == 'amount') return 'overview-text overview-amount' if (key == 'amount') return 'overview-text overview-amount'
if (key != 'state' || !usable(column)) return 'overview-text' return 'overview-text'
return 'overview-text ' + (column.data!.item.state == 'eligible' ? 'good' : (column.data!.item.state == 'ineligible' ? 'blocked' : 'pending'))
} }
const cell = (column: CompareColumn, key: string): string => { const cell = (column: CompareColumn, key: string): string => {
if (!usable(column)) return column.loading ? '读取中…' : '本列未就绪' if (!usable(column)) return column.loading ? '读取中…' : '本列未就绪'
const m = column.data!.item; const p = m.product const m = column.data!.item; const p = m.product
// 核心表也保留当前不可用提示,不能让历史符合条件被误读为现在仍可办理。 if (key == 'score') {
if (key == 'state') return m.stateLabel + (m.availability != 'available' ? ' · ' + m.availabilityLabel : '') const percentage = matchPercentage(m.state, m.score)
if (key == 'score') return matchPercentage(m.state, m.score) || '—' return percentage.length > 0 ? percentage : '—'
}
if (key == 'availability') return m.availabilityLabel + ';不改变历史结论' if (key == 'availability') return m.availabilityLabel + ';不改变历史结论'
if (key == 'positive') return compareReasonText(m.positive, '暂无已确认满足的条件') if (key == 'positive') return compareReasonText(m.positive, '暂无已确认满足的条件')
if (key == 'blocking') return compareReasonText(m.blocking, '未发现已知不符条件;结果仅供参考') if (key == 'blocking') return compareReasonText(m.blocking, '未发现已知不符条件;结果仅供参考')
@@ -136,7 +130,10 @@
if (key == 'materials') return p.materials if (key == 'materials') return p.materials
if (key == 'repayment') return hasProductValue(p.repayment) ? p.repayment : '—' if (key == 'repayment') return hasProductValue(p.repayment) ? p.repayment : '—'
if (key == 'channel') return hasProductValue(p.channel) ? p.channel : '—' if (key == 'channel') return hasProductValue(p.channel) ? p.channel : '—'
if (key == 'source') return [hasProductValue(p.source) ? p.source : '', hasProductValue(p.sourceRef) ? '引用:' + p.sourceRef : '', hasProductValue(p.checkedOn) ? '核对日期:' + p.checkedOn : ''].filter((value: string): boolean => value.length > 0).join('\n') || '—' if (key == 'source') {
const sourceText = [hasProductValue(p.source) ? p.source : '', hasProductValue(p.sourceRef) ? '引用:' + p.sourceRef : '', hasProductValue(p.checkedOn) ? '核对日期:' + p.checkedOn : ''].filter((value: string): boolean => value.length > 0).join('\n')
return sourceText.length > 0 ? sourceText : '—'
}
return '未提供' return '未提供'
} }
const rowHasValue = (row: CompareRow): boolean => columns.value.some((column: CompareColumn): boolean => !usable(column) || hasProductValue(cell(column, row.id))) const rowHasValue = (row: CompareRow): boolean => columns.value.some((column: CompareColumn): boolean => !usable(column) || hasProductValue(cell(column, row.id)))
@@ -229,33 +226,18 @@
.hint { color: #788ba2; font-size: 22rpx; line-height: 34rpx; margin-top: 8rpx; } .hint { color: #788ba2; font-size: 22rpx; line-height: 34rpx; margin-top: 8rpx; }
.add { color: #0868f7; background-color: #e6f0ff; font-size: 24rpx; border-radius: 12rpx; margin-left: 14rpx; } .add { color: #0868f7; background-color: #e6f0ff; font-size: 24rpx; border-radius: 12rpx; margin-left: 14rpx; }
.scroll-hint { font-size: 21rpx; color: #8494a7; margin-bottom: 16rpx; } .scroll-hint { font-size: 21rpx; color: #8494a7; margin-bottom: 16rpx; }
.table { flex-direction: row; border: 1rpx solid #dfe8f3; border-radius: 14rpx; overflow: hidden; background-color: #fff; }
.rail { width: 152rpx; flex-shrink: 0; background-color: #eef4fb; }
.rail-head { height: 350rpx; padding: 26rpx 16rpx; border-bottom: 1rpx solid #dfe8f3; }
.rail-title { color: #244566; font-size: 24rpx; font-weight: 700; }
.label-cell { padding: 24rpx 16rpx; border-bottom: 1rpx solid #dfe8f3; }
.row-label { font-size: 23rpx; line-height: 36rpx; color: #48627e; } .row-label { font-size: 23rpx; line-height: 36rpx; color: #48627e; }
.horizontal { flex: 1; width: 0; }
.columns { flex-direction: row; }
/* #ifndef APP */
.columns { min-width: 100%; }
/* #endif */
.column { width: 350rpx; flex-grow: 1; flex-shrink: 0; border-left: 1rpx solid #e7edf5; }
.product-head { height: 350rpx; border-bottom: 1rpx solid #dfe8f3; background-color: #fafdff; }
.head-content { padding: 22rpx 20rpx; }
.product-name { font-size: 26rpx; line-height: 37rpx; color: #1d3d60; font-weight: 700; }
.state { font-size: 22rpx; margin-top: 12rpx; } .state { font-size: 22rpx; margin-top: 12rpx; }
.good { color: #18805d; } .good { color: #18805d; }
.blocked { color: #b26341; } .blocked { color: #b26341; }
.pending { color: #9b751f; } .pending { color: #9b751f; }
.demo { color: #8b97a8; font-size: 20rpx; margin-top: 6rpx; } .demo { color: #8b97a8; font-size: 20rpx; margin-top: 6rpx; }
.detail { color: #0868f7; font-size: 23rpx; padding-top: 16rpx; } .detail-value { justify-content: flex-start; }
.remove { color: #77889e; font-size: 22rpx; padding-top: 16rpx; } .cell-text { font-size: 23rpx; line-height: 36rpx; color: #455f7c; }
.retry { font-size: 23rpx; color: #0868f7; background-color: #eaf3ff; margin-top: 12rpx; } .cell-amount { color: #154779; font-weight: 700; }
.cell { padding: 20rpx; border-bottom: 1rpx solid #e7edf5; } /* #ifndef APP */
.cell-scroll { flex: 1; height: 0; } .detail-table .cell-text, .detail-table .overview-name, .detail-table .overview-bank { white-space: pre-wrap; word-break: break-all; }
.cell-text { font-size: 24rpx; line-height: 39rpx; color: #455f7c; } /* #endif */
.amount { color: #154779; font-weight: 700; font-size: 27rpx; }
.footer-note { color: #8997a8; font-size: 22rpx; line-height: 36rpx; margin-top: 24rpx; } .footer-note { color: #8997a8; font-size: 22rpx; line-height: 36rpx; margin-top: 24rpx; }
.secondary { color: #0868f7; background-color: #eaf3ff; font-size: 26rpx; border-radius: 12rpx; margin-top: 20rpx; } .secondary { color: #0868f7; background-color: #eaf3ff; font-size: 26rpx; border-radius: 12rpx; margin-top: 20rpx; }
</style> </style>
@@ -3,7 +3,7 @@
<view class="match-head"> <view class="match-head">
<view :class="markClass(item.product.bank)"><image v-if="item.product.logo.length > 0 && !logoFailed" class="institution-logo" :src="item.product.logo" mode="aspectFit" @error="logoFailed = true" /><text v-else class="institution-letter">{{ item.product.bank.length > 0 ? item.product.bank.slice(0, 1) : item.product.name.slice(0, 1) }}</text></view> <view :class="markClass(item.product.bank)"><image v-if="item.product.logo.length > 0 && !logoFailed" class="institution-logo" :src="item.product.logo" mode="aspectFit" @error="logoFailed = true" /><text v-else class="institution-letter">{{ item.product.bank.length > 0 ? item.product.bank.slice(0, 1) : item.product.name.slice(0, 1) }}</text></view>
<view class="identity"><text v-if="item.product.bank.length > 0" class="institution-name">{{ item.product.bank }}</text><text :class="item.product.bank.length > 0 ? 'product-name' : 'institution-name'">{{ item.product.name }}</text></view> <view class="identity"><text v-if="item.product.bank.length > 0" class="institution-name">{{ item.product.bank }}</text><text :class="item.product.bank.length > 0 ? 'product-name' : 'institution-name'">{{ item.product.name }}</text></view>
<view class="match-status"><text v-if="matchPercentage(item.state, item.score).length > 0" :class="item.state == 'eligible' ? 'percentage' : 'percentage reference-percentage'">匹配度 {{ matchPercentage(item.state, item.score) }}</text><text :class="item.state == 'eligible' ? 'state eligible' : (item.state == 'ineligible' ? 'state ineligible' : 'state unknown')">{{ item.stateLabel }}</text></view><text class="arrow">›</text> <view v-if="matchPercentage(item.state, item.score).length > 0" class="match-status"><text :class="item.state == 'eligible' ? 'percentage' : 'percentage reference-percentage'">匹配度 {{ matchPercentage(item.state, item.score) }}</text></view><text class="arrow">›</text>
</view> </view>
<view class="product-tags"><text v-if="hasProductValue(item.product.rate)" class="product-tag">年化 {{ item.product.rate }}</text><text v-if="hasProductValue(item.product.term)" class="product-tag">{{ item.product.term }}</text><text v-if="hasProductValue(item.product.channel)" class="product-tag">{{ item.product.channel }}</text><text v-if="!hasProductValue(item.product.rate) && !hasProductValue(item.product.term) && !hasProductValue(item.product.channel)" class="product-tag">{{ item.product.objects }}</text></view> <view class="product-tags"><text v-if="hasProductValue(item.product.rate)" class="product-tag">年化 {{ item.product.rate }}</text><text v-if="hasProductValue(item.product.term)" class="product-tag">{{ item.product.term }}</text><text v-if="hasProductValue(item.product.channel)" class="product-tag">{{ item.product.channel }}</text><text v-if="!hasProductValue(item.product.rate) && !hasProductValue(item.product.term) && !hasProductValue(item.product.channel)" class="product-tag">{{ item.product.objects }}</text></view>
<text v-if="item.availability != 'available'" class="availability">{{ item.availabilityLabel }}</text> <text v-if="item.availability != 'available'" class="availability">{{ item.availabilityLabel }}</text>
@@ -11,7 +11,9 @@
</view> </view>
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { MatchView, hasProductValue, matchPercentage } from '@/packages/finance/services/matches.uts' import { MatchView, hasProductValue as productHasValue, matchPercentage as productMatchPercentage } from '@/packages/finance/services/matches.uts'
const hasProductValue = (value: string): boolean => productHasValue(value)
const matchPercentage = (state: string, score: number | null): string => productMatchPercentage(state, score)
const props = defineProps({ item: { type: Object as PropType<MatchView>, required: true }, selectable: { type: Boolean, default: false }, selected: { type: Boolean, default: false } }) const props = defineProps({ item: { type: Object as PropType<MatchView>, required: true }, selectable: { type: Boolean, default: false }, selected: { type: Boolean, default: false } })
const emit = defineEmits(['open', 'select']) const emit = defineEmits(['open', 'select'])
const logoFailed = ref(false) const logoFailed = ref(false)
+15 -12
View File
@@ -12,7 +12,7 @@
<text v-if="loading && matches.length == 0" class="state-copy">正在读取本次匹配结果…</text> <text v-if="loading && matches.length == 0" class="state-copy">正在读取本次匹配结果…</text>
<view v-if="error.length > 0" class="state-panel"><text class="state-title">暂时无法读取</text><text class="muted">{{ error }}</text><button class="secondary" @click="retry">重新加载</button><text class="link" @click="history">返回融资档案</text></view> <view v-if="error.length > 0" class="state-panel"><text class="state-title">暂时无法读取</text><text class="muted">{{ error }}</text><button class="secondary" @click="retry">重新加载</button><text class="link" @click="history">返回融资档案</text></view>
<view v-if="pageError.length > 0" class="state-panel"><text class="muted">{{ pageError }}</text><button class="secondary" :disabled="loading" @click="loadMore">重试加载更多</button></view> <view v-if="pageError.length > 0" class="state-panel"><text class="muted">{{ pageError }}</text><button class="secondary" :disabled="loading" @click="loadMore">重试加载更多</button></view>
<view v-if="!loading && error.length == 0 && matches.length == 0" class="state-panel"><text class="state-title">{{ status == 'unavailable' ? '本次未执行匹配' : (group == 'all' ? '本次暂无可展示的产品' : (group == 'eligible' ? '暂无符合已知条件的产品' : (group == 'ineligible' ? '暂无条件不符的产品' : '暂无资料不足或待确认的产品'))) }}</text><text class="muted">{{ status == 'unavailable' ? '历史结果不会自动补算。可从融资档案发起新的评估。' : '可切换分组查看其他结果,或返回报告了解资料准备情况。' }}</text><text class="link" @click="report">查看本次报告</text></view> <view v-if="!loading && error.length == 0 && matches.length == 0" class="state-panel"><text class="state-title">{{ status == 'unavailable' ? '本次未执行匹配' : (group == 'all' ? '本次暂无可展示的产品' : '本组暂无可展示的产品') }}</text><text class="muted">{{ status == 'unavailable' ? '历史结果不会自动补算。可从融资档案发起新的评估。' : '可切换分组查看其他结果,或返回报告了解资料准备情况。' }}</text><text class="link" @click="report">查看本次报告</text></view>
<MatchProductCard v-for="item in matches" :key="item.id" :item="item" :selectable="true" :selected="selectedIds.includes(item.id)" @open="openProduct" @select="toggleCompare" /> <MatchProductCard v-for="item in matches" :key="item.id" :item="item" :selectable="true" :selected="selectedIds.includes(item.id)" @open="openProduct" @select="toggleCompare" />
<button v-if="hasMore && pageError.length == 0" class="more-button" :disabled="loading" @click="loadMore">{{ loading ? '正在加载…' : '查看更多产品(共' + count(group) + '项)' }}</button> <button v-if="hasMore && pageError.length == 0" class="more-button" :disabled="loading" @click="loadMore">{{ loading ? '正在加载…' : '查看更多产品(共' + count(group) + '项)' }}</button>
<view v-if="error.length == 0 && matches.length > 0" class="compare-bar"><text class="compare-caption">已选 {{ selectedIds.length }} / 3 项</text><button class="compare-button" :disabled="selectedIds.length < 2" @click="openCompare">对比所选产品</button></view> <view v-if="error.length == 0 && matches.length > 0" class="compare-bar"><text class="compare-caption">已选 {{ selectedIds.length }} / 3 项</text><button class="compare-button" :disabled="selectedIds.length < 2" @click="openCompare">对比所选产品</button></view>
@@ -41,7 +41,7 @@
const eligible = ref('—'); const ineligible = ref('—'); const unknown = ref('—'); const hasMore = ref(false) const eligible = ref('—'); const ineligible = ref('—'); const unknown = ref('—'); const hasMore = ref(false)
let cursor = ''; let generation = 0; let visible = false; let routeError = ''; let owner = '' let cursor = ''; let generation = 0; let visible = false; let routeError = ''; let owner = ''
let firstPage: MatchPage | null = null; let accountTimer = 0 let firstPage: MatchPage | null = null; let accountTimer = 0
const count = (id: string): string => id == 'all' ? (eligible.value == '—' ? '—' : (Number(eligible.value) + Number(unknown.value) + Number(ineligible.value)).toString()) : (id == 'eligible' ? eligible.value : (id == 'ineligible' ? ineligible.value : unknown.value)) const count = (id: string): string => id == 'all' ? (eligible.value == '—' ? '—' : (parseInt(eligible.value) + parseInt(unknown.value) + parseInt(ineligible.value)).toString()) : (id == 'eligible' ? eligible.value : (id == 'ineligible' ? ineligible.value : unknown.value))
const summaryTitle = computed((): string => { const summaryTitle = computed((): string => {
if (assessmentId.value.length == 0) return '选择评估,查看融资匹配' if (assessmentId.value.length == 0) return '选择评估,查看融资匹配'
if (error.value.length > 0) return '匹配结果暂不可读取' if (error.value.length > 0) return '匹配结果暂不可读取'
@@ -77,16 +77,19 @@
const token = session.getString('token') ?? ''; if (owner.length > 0 && owner != token) selectedIds.value = []; owner = token; loading.value = true const token = session.getString('token') ?? ''; if (owner.length > 0 && owner != token) selectedIds.value = []; owner = token; loading.value = true
if (accountTimer == 0) accountTimer = setInterval(() => { current(generation, owner) }, 1000) if (accountTimer == 0) accountTimer = setInterval(() => { current(generation, owner) }, 1000)
try { try {
const result = await fetchMatchPage(assessmentId.value, group.value, append ? cursor : '', revisionId.value) // 首屏保留摘要;点击查看更多后连续读取全部剩余页,失败可从当前游标重试。
if (!current(request, token)) return do {
// 分页只追加同一冻结结果,失败时保留已读行与原游标供局部重试。 const result = await fetchMatchPage(assessmentId.value, group.value, append ? cursor : '', revisionId.value)
if (append && (firstPage == null || firstPage!.assessmentId != result.assessmentId || firstPage!.revisionId != result.revisionId || firstPage!.resultVersion != result.resultVersion || firstPage!.snapshotId != result.snapshotId || firstPage!.ruleVersion != result.ruleVersion || firstPage!.orderingVersion != result.orderingVersion || firstPage!.status != result.status || firstPage!.eligible != result.eligible || firstPage!.ineligible != result.ineligible || firstPage!.unknown != result.unknown || result.items.some((item: MatchView): boolean => matches.value.some((old: MatchView): boolean => old.id == item.id)) || (result.hasMore && result.cursor == cursor))) throw new Error('分页与已读取的冻结结果不一致,请重试或重新加载。') if (!current(request, token)) return
revisionId.value = result.revisionId // 分页只追加同一冻结结果,失败时保留已读行与原游标供局部重试。
if (!append) firstPage = result if (append && (firstPage == null || firstPage!.assessmentId != result.assessmentId || firstPage!.revisionId != result.revisionId || firstPage!.resultVersion != result.resultVersion || firstPage!.snapshotId != result.snapshotId || firstPage!.ruleVersion != result.ruleVersion || firstPage!.orderingVersion != result.orderingVersion || firstPage!.status != result.status || firstPage!.eligible != result.eligible || firstPage!.ineligible != result.ineligible || firstPage!.unknown != result.unknown || result.items.some((item: MatchView): boolean => matches.value.some((old: MatchView): boolean => old.id == item.id)) || (result.hasMore && result.cursor == cursor))) throw new Error('分页与已读取的冻结结果不一致,请重试或重新加载。')
saveCompareSelection(assessmentId.value, selectedIds.value, revisionId.value); updateUrl() revisionId.value = result.revisionId
matches.value = append ? matches.value.concat(result.items) : result.items if (!append) firstPage = result
message.value = result.message; status.value = result.status; eligible.value = result.eligible; ineligible.value = result.ineligible; unknown.value = result.unknown saveCompareSelection(assessmentId.value, selectedIds.value, revisionId.value); updateUrl()
cursor = result.cursor; hasMore.value = result.hasMore matches.value = append ? matches.value.concat(result.items) : result.items
message.value = result.message; status.value = result.status; eligible.value = result.eligible; ineligible.value = result.ineligible; unknown.value = result.unknown
cursor = result.cursor; hasMore.value = result.hasMore
} while (append && hasMore.value)
} catch (err) { if (current(request, token)) { const message = err instanceof Error ? err.message : '匹配结果读取失败'; if (append) pageError.value = message; else error.value = message } } } catch (err) { if (current(request, token)) { const message = err instanceof Error ? err.message : '匹配结果读取失败'; if (append) pageError.value = message; else error.value = message } }
finally { if (request == generation) loading.value = false } finally { if (request == generation) loading.value = false }
} }
+1 -1
View File
@@ -21,7 +21,7 @@
<template v-else-if="matchError.length > 0"><text class="body">{{ matchError }}</text><button class="secondary" @click="load">重新核对产品</button></template> <template v-else-if="matchError.length > 0"><text class="body">{{ matchError }}</text><button class="secondary" @click="load">重新核对产品</button></template>
<template v-else-if="match != null"> <template v-else-if="match != null">
<text class="product-title">{{ match!.product.name }}</text><text class="note">{{ match!.product.bank }} · 评估时版本 v{{ match!.product.version }}</text> <text class="product-title">{{ match!.product.name }}</text><text class="note">{{ match!.product.bank }} · 评估时版本 v{{ match!.product.version }}</text>
<text class="status">{{ match!.stateLabel }} · {{ match!.availabilityLabel }}</text> <text class="status">{{ match!.availabilityLabel }}</text>
<template v-if="canPrepare"> <template v-if="canPrepare">
<text class="label">所需材料与办理说明</text><text class="body">{{ match!.product.materials }}</text> <text class="label">所需材料与办理说明</text><text class="body">{{ match!.product.materials }}</text>
<text class="label">申请条件(评估时)</text><text v-for="item in match!.product.conditions" :key="item" class="body">{{ item }}</text><text v-if="match!.product.conditions.length == 0" class="body">未提供</text> <text class="label">申请条件(评估时)</text><text v-for="item in match!.product.conditions" :key="item" class="body">{{ item }}</text><text v-if="match!.product.conditions.length == 0" class="body">未提供</text>
+6 -6
View File
@@ -9,8 +9,7 @@
<view class="bank-header"> <view class="bank-header">
<view class="bank-mark"><image class="bank-icon" :src="product!.logo.length > 0 ? product!.logo : '/static/mobile/icons-generated/building.png'" @error="product!.logo = ''" mode="aspectFit" /></view> <view class="bank-mark"><image class="bank-icon" :src="product!.logo.length > 0 ? product!.logo : '/static/mobile/icons-generated/building.png'" @error="product!.logo = ''" mode="aspectFit" /></view>
<view class="bank-info"> <view class="bank-info">
<view class="bank-title-row"><text v-if="product!.bank.length > 0" class="bank-name">{{ product!.bank }}</text><text v-if="match != null" :class="match!.state == 'eligible' ? 'badge good' : (match!.state == 'ineligible' ? 'badge blocked' : 'badge pending')">{{ matchPercentage(match!.state, match!.score).length > 0 ? '匹配度 ' + matchPercentage(match!.state, match!.score) : (match!.state == 'eligible' ? '可供参考' : (match!.state == 'ineligible' ? '暂不推荐' : match!.stateLabel)) }}</text></view> <view class="bank-title-row"><text v-if="product!.bank.length > 0" class="bank-name">{{ product!.bank }}</text><text v-if="match != null && matchPercentage(match!.state, match!.score).length > 0" :class="match!.state == 'eligible' ? 'badge good' : (match!.state == 'ineligible' ? 'badge blocked' : 'badge pending')">{{ '匹配度 ' + matchPercentage(match!.state, match!.score) }}</text></view>
<text v-if="match != null" :class="match!.state == 'eligible' ? 'state good-text' : 'state attention-text'">{{ match!.stateLabel }}</text>
<text class="product-name">{{ product!.name }}</text> <text class="product-name">{{ product!.name }}</text>
</view> </view>
</view> </view>
@@ -63,7 +62,6 @@
<view v-if="hasProductValue(product!.channel)" class="row"><text class="label">办理渠道</text><text class="value">{{ product!.channel }}</text></view> <view v-if="hasProductValue(product!.channel)" class="row"><text class="label">办理渠道</text><text class="value">{{ product!.channel }}</text></view>
<view v-if="hasProductValue(product!.approval)" class="row"><text class="label">审批时长</text><text class="value">{{ product!.approval }}</text></view> <view v-if="hasProductValue(product!.approval)" class="row"><text class="label">审批时长</text><text class="value">{{ product!.approval }}</text></view>
</view> </view>
<view v-if="hasProductValue(product!.source) || hasProductValue(product!.sourceRef) || hasProductValue(product!.checkedOn)" class="details-section"><text class="heading">资料来源</text><text v-if="hasProductValue(product!.source)" class="body">{{ product!.source }}</text><text v-if="hasProductValue(product!.sourceRef)" class="evidence">引用:{{ product!.sourceRef }}</text><text v-if="hasProductValue(product!.checkedOn)" class="evidence">核对日期:{{ product!.checkedOn }}</text></view>
</template> </template>
<template v-if="tab == 'conditions'"> <template v-if="tab == 'conditions'">
<view class="details-section"><text class="heading">产品准入条件</text><text class="muted">公开条件不代表个人已符合,缺少核验依据时仍需核实。</text><text v-for="(condition, index) in product!.conditions" :key="index" class="condition">{{ condition }}</text></view> <view class="details-section"><text class="heading">产品准入条件</text><text class="muted">公开条件不代表个人已符合,缺少核验依据时仍需核实。</text><text v-for="(condition, index) in product!.conditions" :key="index" class="condition">{{ condition }}</text></view>
@@ -84,7 +82,9 @@
import AppHeader from '@/components/AppHeader.uvue' import AppHeader from '@/components/AppHeader.uvue'
import { usePageLayout } from '@/services/layout.uts' import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts' import { getSession } from '@/services/api.uts'
import { MatchView, MatchReason, MatchDetail, ProductView, fetchMatchDetailRecord, fetchPublicProduct, validRecordId, requestProductExplanation, hasProductValue, matchPercentage } from '@/packages/finance/services/matches.uts' import { MatchView, MatchReason, MatchDetail, ProductView, fetchMatchDetailRecord, fetchPublicProduct, validRecordId, requestProductExplanation, hasProductValue as productHasValue, matchPercentage as productMatchPercentage } from '@/packages/finance/services/matches.uts'
const hasProductValue = (value: string): boolean => productHasValue(value)
const matchPercentage = (state: string, score: number | null): string => productMatchPercentage(state, score)
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery, sameMatchSnapshot, matchReturnPath } from '@/packages/finance/services/compare.uts' import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery, sameMatchSnapshot, matchReturnPath } from '@/packages/finance/services/compare.uts'
import { assistantRoute } from '@/packages/finance/services/assistant.uts' import { assistantRoute } from '@/packages/finance/services/assistant.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout() const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
@@ -102,8 +102,8 @@
const attentionVisible = computed((): MatchReason[] => attentionExpanded.value ? attention.value : attention.value.slice(0, 3)) const attentionVisible = computed((): MatchReason[] => attentionExpanded.value ? attention.value : attention.value.slice(0, 3))
const aiPending = computed((): boolean => match.value != null && ['queued','running'].includes(match.value!.aiStatus)) const aiPending = computed((): boolean => match.value != null && ['queued','running'].includes(match.value!.aiStatus))
const canPrepare = computed((): boolean => match.value != null && match.value!.state == 'eligible' && match.value!.availability == 'available') const canPrepare = computed((): boolean => match.value != null && match.value!.state == 'eligible' && match.value!.availability == 'available')
// 缩短规则模板标题,不改判定含义。 // 加减分附注仅在本页隐藏,保留原理由、评分与冻结数据。
const reasonTitle = (reason: MatchReason): string => reason.text.replace('本产品已配置条件', '产品条件') const reasonTitle = (reason: MatchReason): string => reason.text.replace('本产品已配置条件', '产品条件').replace(/[((]\s*匹配度\s*(?:[+\-−]|加|减)\s*\d+(?:\.\d+)?\s*分\s*[))]/g, '').trim()
function stopPoll(): void { if (timer != 0) clearTimeout(timer); timer = 0 } function stopPoll(): void { if (timer != 0) clearTimeout(timer); timer = 0 }
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 } const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeMatch=1&matchPage=product&assessmentId=' + assessmentId.value + '&revisionId=' + revisionId.value + '&productIds=' + selectedIds.value.join(',') + '&productId=' + productId.value + '&tab=' + tab.value + '&from=' + from }) } const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeMatch=1&matchPage=product&assessmentId=' + assessmentId.value + '&revisionId=' + revisionId.value + '&productIds=' + selectedIds.value.join(',') + '&productId=' + productId.value + '&tab=' + tab.value + '&from=' + from }) }
+2 -2
View File
@@ -56,7 +56,7 @@
<view v-if="report!.suggestions.length > 0" class="panel"><view class="panel-heading" @click="detailsOpen = !detailsOpen"><text class="section-title">建议依据</text><text class="toggle">{{ detailsOpen ? '收起' : '展开' }}</text></view><template v-if="detailsOpen"><text v-for="item in report!.suggestions" :key="item" class="body-text">{{ item }}</text></template></view> <view v-if="report!.suggestions.length > 0" class="panel"><view class="panel-heading" @click="detailsOpen = !detailsOpen"><text class="section-title">建议依据</text><text class="toggle">{{ detailsOpen ? '收起' : '展开' }}</text></view><template v-if="detailsOpen"><text v-for="item in report!.suggestions" :key="item" class="body-text">{{ item }}</text></template></view>
<view v-if="report!.version == 2" class="panel"><text class="section-title">优先补充与核实</text><text v-if="missingItems.length == 0" class="report-note">{{ report!.completenessPercent == null ? '本次未评定缺失字段,不能据此认为资料齐全。' : '本次字段清单没有待补充或格式待核实项,仍需准备核验材料。' }}</text><view class="fact-row" v-for="item in missingItems" :key="item.label"><text class="fact-label">{{ item.label }}</text><text class="fact-value">{{ item.value }}</text></view><text class="body-text" v-for="(item, index) in report!.readinessBasis" :key="index">{{ item }}</text></view> <view v-if="report!.version == 2" class="panel"><text class="section-title">优先补充与核实</text><text v-if="missingItems.length == 0" class="report-note">{{ report!.completenessPercent == null ? '本次未评定缺失字段,不能据此认为资料齐全。' : '本次字段清单没有待补充或格式待核实项,仍需准备核验材料。' }}</text><view class="fact-row" v-for="item in missingItems" :key="item.label"><text class="fact-label">{{ item.label }}</text><text class="fact-value">{{ item.value }}</text></view><text class="body-text" v-for="(item, index) in report!.readinessBasis" :key="index">{{ item }}</text></view>
<view v-if="report!.matching.status != 'completed'" class="panel"><text class="section-title">产品条件提示</text><text class="report-note">本次未执行产品匹配,没有可关联的产品改善依据。</text></view> <view v-if="report!.matching.status != 'completed'" class="panel"><text class="section-title">产品条件提示</text><text class="report-note">本次未执行产品匹配,没有可关联的产品改善依据。</text></view>
<template v-else><view v-for="group in adviceGroups" :key="group.id" class="panel"><text class="section-title">{{ group.label }}</text><text class="report-note">仅展示本组前3项产品的已有依据,可进入完整列表查看更多。</text><text v-if="group.loading" class="body-text">正在读取产品依据…</text><template v-if="group.error.length > 0"><text class="body-text">{{ group.error }}</text><button class="secondary" @click="loadAdvice(group.id)">重试{{ group.label }}</button></template><text v-if="!group.loading && group.error.length == 0 && group.items.length == 0" class="report-note">本组暂无产品依据。</text><view v-for="item in group.items" :key="item.id" class="advice-product"><text class="product-name" @click="openProduct(item.id)">{{ item.product.name }} ›</text><text class="source-note">{{ item.stateLabel }} · 评估时版本 v{{ item.product.version }}</text><view v-for="reason in adviceReasons(item)" :key="reason.id"><text class="body-text">{{ reason.text }}</text><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view><text v-if="item.availability != 'available'" class="availability">{{ item.availabilityLabel }}</text></view><text class="action" @click="allMatches(group.id)">查看全部{{ group.label }} ›</text></view></template> <template v-else><view v-for="group in adviceGroups" :key="group.id" class="panel"><text class="section-title">{{ group.label }}</text><text class="report-note">仅展示本组前3项产品的已有依据,可进入完整列表查看更多。</text><text v-if="group.loading" class="body-text">正在读取产品依据…</text><template v-if="group.error.length > 0"><text class="body-text">{{ group.error }}</text><button class="secondary" @click="loadAdvice(group.id)">重试{{ group.label }}</button></template><text v-if="!group.loading && group.error.length == 0 && group.items.length == 0" class="report-note">本组暂无产品依据。</text><view v-for="item in group.items" :key="item.id" class="advice-product"><text class="product-name" @click="openProduct(item.id)">{{ item.product.name }} ›</text><text class="source-note">评估时版本 v{{ item.product.version }}</text><view v-for="reason in adviceReasons(item)" :key="reason.id"><text class="body-text">{{ reason.text }}</text><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view><text v-if="item.availability != 'available'" class="availability">{{ item.availabilityLabel }}</text></view><text class="action" @click="allMatches(group.id)">查看全部{{ group.label }} ›</text></view></template>
</template> </template>
<view v-if="tab == 'matches'" class="matching-footer"><text class="report-link" @click="editAssessment">修改资料,重新评估 ›</text><text class="matching-disclaimer">匹配度仅供参考,不代表获批概率。</text></view> <view v-if="tab == 'matches'" class="matching-footer"><text class="report-link" @click="editAssessment">修改资料,重新评估 ›</text><text class="matching-disclaimer">匹配度仅供参考,不代表获批概率。</text></view>
<template v-else> <template v-else>
@@ -101,7 +101,7 @@
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeReport=1&assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&tab=' + tab.value }) } const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeReport=1&assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&tab=' + tab.value }) }
const missingItems = computed((): AssessmentField[] => report.value == null ? [] : report.value!.completenessItems.filter((item: AssessmentField): boolean => ['待补充','需核实'].includes(item.value))) const missingItems = computed((): AssessmentField[] => report.value == null ? [] : report.value!.completenessItems.filter((item: AssessmentField): boolean => ['待补充','需核实'].includes(item.value)))
const priorityItems = computed((): string[] => report.value == null ? [] : report.value!.priorityItems) const priorityItems = computed((): string[] => report.value == null ? [] : report.value!.priorityItems)
const matchingTotal = computed((): string => report.value == null || report.value!.matching.status != 'completed' ? '0' : (Number(report.value!.matching.eligible) + Number(report.value!.matching.unknown) + Number(report.value!.matching.ineligible)).toString()) const matchingTotal = computed((): string => report.value == null || report.value!.matching.status != 'completed' ? '0' : (parseInt(report.value!.matching.eligible) + parseInt(report.value!.matching.unknown) + parseInt(report.value!.matching.ineligible)).toString())
const adviceReasons = (item: MatchView): MatchReason[] => item.state == 'ineligible' ? item.blocking.concat(item.cautions) : item.cautions const adviceReasons = (item: MatchView): MatchReason[] => item.state == 'ineligible' ? item.blocking.concat(item.cautions) : item.cautions
const reset = () => { report.value = null; candidates.value = []; adviceGroups.value = []; candidateLoading.value = false; candidateError.value = ''; factsOpen.value = false; completenessOpen.value = false; metadataOpen.value = false; detailsOpen.value = false; downloadStatus.value = '' } const reset = () => { report.value = null; candidates.value = []; adviceGroups.value = []; candidateLoading.value = false; candidateError.value = ''; factsOpen.value = false; completenessOpen.value = false; metadataOpen.value = false; detailsOpen.value = false; downloadStatus.value = '' }
const current = (request: number, token: string): boolean => visible && request == generation && getSession()?.getString('token') == token const current = (request: number, token: string): boolean => visible && request == generation && getSession()?.getString('token') == token
+9 -5
View File
@@ -11,7 +11,10 @@ export type MatchDetail = { assessmentId: string, revisionId: string, resultVers
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 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 } 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) ?? '' } 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 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 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 hasProductValue(value: string): boolean { return value.trim().length > 0 && !['未提供', '待核实', '—'].includes(value.trim()) }
@@ -73,14 +76,15 @@ function productView(data: UTSJSONObject, publicMode: boolean): ProductView {
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(' / ') } 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 rawPhone = text(data, 'consultPhone')
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, const sourceText = [text(source, 'publisher'), text(source, 'title')].filter((value: string): boolean => value.length > 0).join(' · ')
amount: text(data, 'expectation') || rangeText(text(data, 'amountMinYuan').length == 0 ? '' : yuanToWan(text(data, 'amountMinYuan')), text(data, 'amountMaxYuan').length == 0 ? '' : yuanToWan(text(data, 'amountMaxYuan')), ' 万元'), 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')), '%'), rate: rangeText(rateText(text(data, 'annualRateMinPercent')), rateText(text(data, 'annualRateMaxPercent')), '%'),
term: text(data, 'quota') || rangeText(data.getNumber('termMinMonths')?.toString() ?? '', data.getNumber('termMaxMonths')?.toString() ?? '', ' 个月'), 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('、')), 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') : '未提供', valid: validityText(data), materials: text(data, 'information').length > 0 ? text(data, 'information') : '未提供',
// 内部引用用于定位资料来源,不把测试环境标识当作面向用户的产品说明。 // 内部引用用于定位资料来源,不把测试环境标识当作面向用户的产品说明。
source: [text(source, 'publisher'), text(source, 'title')].filter((value: string): boolean => value.length > 0).join(' · ') || '待核实', sourceRef: text(source, 'reference').startsWith('demo:airz-') ? '产品规则档案' : (text(source, 'reference') || '未提供'), checkedOn: text(source, 'checkedOn') || '未提供', conditions } 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 { function matchView(data: UTSJSONObject): MatchView {
const snapshot = data['productSnapshot'] as UTSJSONObject const snapshot = data['productSnapshot'] as UTSJSONObject
+2 -2
View File
@@ -15,7 +15,7 @@
</view> </view>
</view> </view>
</view> </view>
<scroll-view scroll-y class="home-scroll" :style="contentStyle(104, false)"> <scroll-view direction="vertical" class="home-scroll" :style="contentStyle(104, false)">
<view class="home-content"> <view class="home-content">
<view v-if="banners.length > 0" class="ai-banner" @click="openBanner(banners[0].actionUrl)"> <view v-if="banners.length > 0" class="ai-banner" @click="openBanner(banners[0].actionUrl)">
<view class="banner-copy"><text class="banner-title">{{ banners[0].title }}</text><text class="banner-subtitle">{{ banners[0].subtitle }}</text></view> <view class="banner-copy"><text class="banner-title">{{ banners[0].title }}</text><text class="banner-subtitle">{{ banners[0].subtitle }}</text></view>
@@ -182,7 +182,7 @@
.red-dot { position: absolute; width: 16rpx; height: 16rpx; right: 2rpx; top: 2rpx; z-index: 1; background-color: #ff4d4f; border-radius: 50%; border: 2rpx solid #fff; } .red-dot { position: absolute; width: 16rpx; height: 16rpx; right: 2rpx; top: 2rpx; z-index: 1; background-color: #ff4d4f; border-radius: 50%; border: 2rpx solid #fff; }
.home-scroll { } .home-scroll { }
.home-content { padding: 18rpx 28rpx 180rpx; } .home-content { padding: 18rpx 28rpx 180rpx; }
.ai-banner { height: 246rpx; border-radius: 20rpx; padding: 30rpx; overflow: hidden; flex-direction: row; align-items: center; position: relative; background: linear-gradient(125deg, #1263f0 0%, #0788ff 100%); } .ai-banner { height: 246rpx; border-radius: 20rpx; padding: 30rpx; overflow: hidden; flex-direction: row; align-items: center; position: relative; background-color: #1263f0; background-image: linear-gradient(to bottom right, #1263f0, #0788ff); }
.banner-copy { z-index: 2; } .banner-copy { z-index: 2; }
.banner-title { color: #ffffff; font-size: 39rpx; font-weight: 800; } .banner-title { color: #ffffff; font-size: 39rpx; font-weight: 800; }
.banner-subtitle { color: #ffffff; font-size: 30rpx; font-weight: 700; margin-top: 12rpx; } .banner-subtitle { color: #ffffff; font-size: 30rpx; font-weight: 700; margin-top: 12rpx; }
+5 -3
View File
@@ -4,6 +4,7 @@ const ts=require('D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dclou
const repo=path.resolve(__dirname,'..'); const repo=path.resolve(__dirname,'..');
const compile=source=>ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText; const compile=source=>ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText;
const routeExports={};vm.runInNewContext(compile(fs.readFileSync(path.join(repo,'services/report-route.uts'),'utf8')),{exports:routeExports}); const routeExports={};vm.runInNewContext(compile(fs.readFileSync(path.join(repo,'services/report-route.uts'),'utf8')),{exports:routeExports});
const matchRoutes={};vm.runInNewContext(compile(fs.readFileSync(path.join(repo,'services/match-route.uts'),'utf8')),{exports:matchRoutes});
const script=fs.readFileSync(path.join(repo,'packages/finance/analyzing/analyzing.uvue'),'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1]; const script=fs.readFileSync(path.join(repo,'packages/finance/analyzing/analyzing.uvue'),'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
function status(overrides={}){return {id:'42',revisionId:'9',publishedRevisionId:'9',status:'succeeded',statusLabel:'分析完成',message:'',hasReport:true,stage:'completed',canRetry:false,taskId:'task9',...overrides};} function status(overrides={}){return {id:'42',revisionId:'9',publishedRevisionId:'9',status:'succeeded',statusLabel:'分析完成',message:'',hasReport:true,stage:'completed',canRetry:false,taskId:'task9',...overrides};}
function page(reply){ function page(reply){
@@ -13,7 +14,7 @@ function page(reply){
onLoad:fn=>{onLoad=fn},onShow:fn=>{onShow=fn},onHide:fn=>{onHide=fn},onUnload:fn=>{onUnload=fn}, onLoad:fn=>{onLoad=fn},onShow:fn=>{onShow=fn},onHide:fn=>{onHide=fn},onUnload:fn=>{onUnload=fn},
setTimeout:fn=>{timers.push(fn);return timers.length},clearTimeout:()=>{},setInterval:()=>1,clearInterval:()=>{}, setTimeout:fn=>{timers.push(fn);return timers.length},clearTimeout:()=>{},setInterval:()=>1,clearInterval:()=>{},
uni:{redirectTo:({url})=>{navigations.push({kind:'redirect',url})},navigateTo:({url})=>{navigations.push({kind:'navigate',url})}}, uni:{redirectTo:({url})=>{navigations.push({kind:'redirect',url})},navigateTo:({url})=>{navigations.push({kind:'navigate',url})}},
require:module=>module.endsWith('layout.uts')?{usePageLayout:()=>({})}:module.endsWith('api.uts')?{getSession:session}:module.endsWith('records.uts')?{fetchAssessmentStatus:async()=>typeof response==='function'?response():response}:module.endsWith('report-route.uts')?routeExports:(()=>{throw Error('Unexpected dependency '+module)})()}; require:module=>module.endsWith('layout.uts')?{usePageLayout:()=>({})}:module.endsWith('api.uts')?{getSession:session}:module.endsWith('records.uts')?{fetchAssessmentStatus:async()=>typeof response==='function'?response():response}:module.endsWith('report-route.uts')?routeExports:module.endsWith('match-route.uts')?matchRoutes:(()=>{throw Error('Unexpected dependency '+module)})()};
vm.runInNewContext(compile(script+'\nexports.refreshStatus=refreshStatus; exports.report=report; exports.statusMessage=()=>message.value;'),context); vm.runInNewContext(compile(script+'\nexports.refreshStatus=refreshStatus; exports.report=report; exports.statusMessage=()=>message.value;'),context);
onLoad({assessmentId:'42'}); onLoad({assessmentId:'42'});
return {navigations,timers,show:()=>onShow(),hide:()=>onHide(),setReply:r=>{response=r},setToken:t=>{token=t},refresh:()=>context.exports.refreshStatus(),manualReport:()=>context.exports.report(),message:()=>context.exports.statusMessage()}; return {navigations,timers,show:()=>onShow(),hide:()=>onHide(),setReply:r=>{response=r},setToken:t=>{token=t},refresh:()=>context.exports.refreshStatus(),manualReport:()=>context.exports.report(),message:()=>context.exports.statusMessage()};
@@ -23,12 +24,13 @@ let checks=0;const check=(ok,label)=>{assert.ok(ok,label);checks++};
(async()=>{ (async()=>{
let p=page(status());p.show();await flush(); let p=page(status());p.show();await flush();
check(p.navigations.length===1,'successful analysis automatically opens its result'); check(p.navigations.length===1,'successful analysis automatically opens its result');
assert.deepEqual(p.navigations[0],{kind:'redirect',url:'/packages/finance/report/report?assessmentId=42&tab=matches&revisionId=9'});checks++; assert.deepEqual(p.navigations[0],{kind:'redirect',url:'/packages/finance/matches/matches?assessmentId=42&productIds=&revisionId=9&group=all'});checks++;
p.manualReport();assert.deepEqual(p.navigations[1],p.navigations[0]);checks++;
check(p.timers.length===0,'successful result does not keep polling'); check(p.timers.length===0,'successful result does not keep polling');
p=page(status({status:'processing',stage:'queued',revisionId:'10',publishedRevisionId:'9'}));p.show();await flush(); p=page(status({status:'processing',stage:'queued',revisionId:'10',publishedRevisionId:'9'}));p.show();await flush();
check(p.navigations.length===0&&p.timers.length===1,'processing revision never opens the older published report'); check(p.navigations.length===0&&p.timers.length===1,'processing revision never opens the older published report');
p.setReply(status({revisionId:'10',publishedRevisionId:'10'}));p.timers[0]();await flush(); p.setReply(status({revisionId:'10',publishedRevisionId:'10'}));p.timers[0]();await flush();
check(p.navigations.length===1&&p.navigations[0].url.endsWith('tab=matches&revisionId=10'),'poll completion opens the newly published revision'); check(p.navigations.length===1&&p.navigations[0].url==='/packages/finance/matches/matches?assessmentId=42&productIds=&revisionId=10&group=all','poll completion opens the newly published match results');
p=page(status({status:'failed',canRetry:true}));p.show();await flush();check(p.navigations.length===0,'failed assessment stays on recovery page'); p=page(status({status:'failed',canRetry:true}));p.show();await flush();check(p.navigations.length===0,'failed assessment stays on recovery page');
check(p.message().includes('分析未完成')&&!p.message().includes('可查看本次报告'),'failed analysis without server message does not announce completed report'); check(p.message().includes('分析未完成')&&!p.message().includes('可查看本次报告'),'failed analysis without server message does not announce completed report');
p=page(status({hasReport:false,publishedRevisionId:''}));p.show();await flush();check(p.navigations.length===0,'missing report never navigates to an unfrozen result'); p=page(status({hasReport:false,publishedRevisionId:''}));p.show();await flush();check(p.navigations.length===0,'missing report never navigates to an unfrozen result');
+78
View File
@@ -0,0 +1,78 @@
// Exercise list pagination and lifecycle with synthetic frozen pages; no network/model access.
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'), assert = require('node:assert/strict');
const ts = require('D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dcloudio/uni-uts-v1/lib/typescript/lib/typescript.js');
const source = fs.readFileSync(path.join(__dirname, '../packages/finance/matches/matches.uvue'), 'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
function item(id) {
return { id: String(id), score: 95, state: 'eligible', stateLabel: '符合已知条件', availability: 'available', availabilityLabel: '当前产品可查看', product: { id: String(id), version: 1, name: '合成产品' + id, bank: '合成银行' }, positive: [], blocking: [], cautions: [], aiText: '', aiStatus: 'not_requested', aiCanRetry: false, aiCanRequest: false, aiGeneratedAt: '' };
}
function frozen(ids, cursor = '', overrides = {}) {
return { assessmentId: '9', revisionId: '11', resultVersion: 2, snapshotId: 'frozen', ruleVersion: 'product-matching-v2', orderingVersion: 'product-score-descending-v1', status: 'completed', message: '', eligible: '8', ineligible: '0', unknown: '0', items: ids.map(item), hasMore: cursor.length > 0, cursor, ...overrides };
}
function harness(respond) {
let token = 'synthetic-owner', onLoad, onShow, onHide;
const requests = [];
const context = { exports: {}, Error, Number, Promise,
ref: value => ({ value }), computed: read => ({ get value() { return read(); } }),
onLoad: fn => { onLoad = fn; }, onShow: fn => { onShow = fn; }, onHide: fn => { onHide = fn; }, onUnload: () => {},
setInterval: () => 1, clearInterval: () => {}, uni: { redirectTo: () => {} },
require: name => name.endsWith('layout.uts') ? { usePageLayout: () => ({}) } : name.endsWith('api.uts') ? { getSession: () => ({ getString: key => key === 'token' ? token : '' }) } : name.endsWith('matches.uts') ? {
validRecordId: value => /^[1-9][0-9]{0,9}$/.test(value),
fetchMatchPage: async (id, group, cursor, revisionId) => { const request = { id, group, cursor, revisionId }; requests.push(request); return respond(request); }
} : name.endsWith('compare.uts') ? { compareSelection: () => [], saveCompareSelection: () => {}, parseCompareIds: () => [], compareQuery: () => '', matchReturnPath: () => '' } : {}
};
// Remove the Web-only address-bar update; the lifecycle and pagination remain the production code.
const executable = source.replace(/\s*\/\/ #ifdef WEB[\s\S]*?\/\/ #endif/g, '');
const script = ts.transpileModule(executable + '\nexports.view={matches,loading,pageError,error,hasMore,revisionId,group}; exports.more=loadMore; exports.select=selectGroup;', { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
vm.runInNewContext(script, context);
onLoad({ assessmentId: '9', revisionId: '11' });
return { ...context.exports, requests, show: () => onShow(), hide: () => onHide(), switchAccount: () => { token = 'other-user'; } };
}
const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
const ids = h => Array.from(h.view.matches.value, row => row.id);
const pages = { '': frozen([1, 2, 3], 'next-3'), 'next-3': frozen([4, 5, 6], 'next-6'), 'next-6': frozen([7, 8]) };
let checks = 0;
async function test(name, action) { await action(); checks++; console.log('PASS ' + name); }
(async () => {
await test('one click appends every remaining page in order', async () => {
const h = harness(({ cursor }) => pages[cursor]); h.show(); await flush();
assert.deepEqual(ids(h), ['1', '2', '3']); assert.equal(h.requests.length, 1);
h.more(); await flush();
assert.deepEqual(ids(h), ['1', '2', '3', '4', '5', '6', '7', '8']); assert.equal(h.view.hasMore.value, false); assert.equal(h.view.loading.value, false);
assert.deepEqual(h.requests.map(r => [r.group, r.cursor, r.revisionId]), [['all', '', '11'], ['all', 'next-3', '11'], ['all', 'next-6', '11']]);
});
await test('failure preserves appended rows and retry resumes at the failing page', async () => {
let fail = true;
const h = harness(({ cursor }) => { if (cursor === 'next-6' && fail) throw Error('synthetic timeout'); return pages[cursor]; });
h.show(); await flush(); h.more(); await flush();
assert.deepEqual(ids(h), ['1', '2', '3', '4', '5', '6']); assert.equal(h.view.pageError.value, 'synthetic timeout'); assert.equal(h.view.loading.value, false);
fail = false; h.more(); await flush();
assert.deepEqual(ids(h), ['1', '2', '3', '4', '5', '6', '7', '8']); assert.equal(h.requests.at(-1).cursor, 'next-6'); assert.equal(h.view.pageError.value, '');
});
await test('duplicate products and changed snapshots cannot enter the existing list', async () => {
for (const bad of [frozen([3, 4, 5], 'next-6'), frozen([4, 5, 6], 'next-6', { snapshotId: 'changed' })]) {
const h = harness(({ cursor }) => cursor === '' ? pages[''] : bad); h.show(); await flush(); h.more(); await flush();
assert.deepEqual(ids(h), ['1', '2', '3']); assert.ok(h.view.pageError.value.length > 0); assert.equal(h.requests.length, 2);
}
});
await test('leaving or switching accounts stops subsequent pagination and discards late rows', async () => {
for (const action of ['hide', 'switchAccount']) {
let resolve;
const h = harness(({ cursor }) => cursor === '' ? pages[''] : new Promise(done => { resolve = done; }));
h.show(); await flush(); h.more(); await flush(); h[action](); resolve(pages['next-3']); await flush();
assert.deepEqual(ids(h), []); assert.equal(h.requests.length, 2);
}
});
await test('group change ignores the old request and keeps the new group', async () => {
let resolve;
const h = harness(({ group, cursor }) => group === 'unknown' ? frozen([20], '', { eligible: '0', unknown: '1' }) : cursor === '' ? pages[''] : new Promise(done => { resolve = done; }));
h.show(); await flush(); h.more(); await flush(); h.select('unknown'); await flush(); resolve(pages['next-3']); await flush();
assert.deepEqual(ids(h), ['20']); assert.equal(h.view.group.value, 'unknown'); assert.equal(h.requests.length, 3); assert.equal(h.view.loading.value, false);
});
await test('repeated clicks share the running load', async () => {
let resolve;
const h = harness(({ cursor }) => cursor === '' ? pages[''] : cursor === 'next-3' ? new Promise(done => { resolve = done; }) : pages[cursor]);
h.show(); await flush(); h.more(); h.more(); await flush(); assert.equal(h.requests.length, 2);
resolve(pages['next-3']); await flush(); assert.deepEqual(ids(h), ['1', '2', '3', '4', '5', '6', '7', '8']); assert.equal(h.requests.length, 3);
});
console.log('PASS: ' + checks + ' match list scenarios; no network/model access');
})().catch(error => { console.error(error); process.exitCode = 1; });
+143
View File
@@ -0,0 +1,143 @@
// Pure UTS and page behavior checks; no real account, network or database.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require(process.env.AIRZ_TYPESCRIPT_PATH || 'D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dcloudio/uni-uts-v1/lib/typescript/lib/typescript.js');
const root = path.resolve(__dirname, '..');
class JsonObject {
constructor(value = {}) { Object.assign(this, value); }
getString(key) { return typeof this[key] === 'string' ? this[key] : null; }
getNumber(key) { return typeof this[key] === 'number' ? this[key] : null; }
getBoolean(key) { return typeof this[key] === 'boolean' ? this[key] : null; }
}
const json = value => Array.isArray(value) ? value.map(json) : value && typeof value === 'object' ? new JsonObject(Object.fromEntries(Object.entries(value).map(([key, item]) => [key, json(item)]))) : value;
const plain = value => JSON.parse(JSON.stringify(value));
const policy = { dailyLimit: 1, makeUpAllowed: false, consecutiveBonusEnabled: false, profileRewardEnabled: false, invitationRewardEnabled: false, expiryPolicy: 'none', levelPolicy: 'unchanged', benefitsPolicy: 'none' };
const published = { status: 'published', ruleVersion: 'points-checkin-20261019-v1', checkInReward: 10, timezone: 'Asia/Shanghai', publishedRules: policy };
const draft = { status: 'draft', ruleVersion: null, checkInReward: null, timezone: 'Asia/Shanghai', publishedRules: null };
let ruleReply = published;
let signed = false;
let loseResponse = false;
let ledgerWrites = 0;
let calls = [];
let session = json({ userId: '1', token: 'synthetic-points-token' });
class ApiRequestError extends Error { constructor(message, status, errorCode) { super(message); this.status = status; this.errorCode = errorCode; } }
const summary = () => ({ balance: signed ? 30 : 20, serverDate: '2026-10-19', timezone: 'Asia/Shanghai', signedToday: signed, checkInEnabled: ruleReply.status === 'published', ruleVersion: ruleReply.ruleVersion, checkInReward: ruleReply.checkInReward, balanceState: 'normal' });
const api = { ApiRequestError, getSession: () => session, requestJson: async (url, method, body) => {
calls.push({ url, method, body: plain(body) });
if (url === '/points/rules') return json(ruleReply);
if (url === '/points') return json(summary());
if (url === '/points/tasks') return json({ serverDate: '2026-10-19', timezone: 'Asia/Shanghai', items: [
{ task: 'checkin', status: ruleReply.ruleVersion == null ? 'unavailable' : signed ? 'completed' : ruleReply.status === 'published' ? 'available' : 'unavailable', reward: ruleReply.checkInReward, ruleVersion: ruleReply.ruleVersion },
...['profile', 'invitation'].map(task => ({ task, status: 'unavailable', reward: null, ruleVersion: null }))
] });
if (url === '/points/ledger') return json({ items: [], snapshotId: '0', nextBeforeId: null });
if (url === '/points/check-in') {
const replayed = signed;
if (!signed) { signed = true; ledgerWrites++; }
if (loseResponse) { loseResponse = false; throw new Error('Synthetic response lost'); }
return json({ ledgerId: '91', delta: 10, balanceAfter: 30, signedDate: body.expectedDate, ruleVersion: body.expectedRuleVersion, replayed });
}
throw new Error('Unexpected points request: ' + url);
} };
function transpile(source) {
const result = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, reportDiagnostics: true });
assert.equal(result.diagnostics.filter(item => item.category === ts.DiagnosticCategory.Error).length, 0);
return result.outputText;
}
const service = {};
vm.runInNewContext(transpile(fs.readFileSync(path.join(root, 'services/points.uts'), 'utf8')), {
exports: service, UTSJSONObject: JsonObject, Date, Math, Number, Error, require: name => {
assert.equal(name, '@/services/api.uts'); return api;
}
});
function page() {
let shown;
let share;
const storage = new Map();
const modals = [], toasts = [];
const source = fs.readFileSync(path.join(root, 'packages/account/points/points.uvue'), 'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
const exports = {};
vm.runInNewContext(transpile(source + '\ntaskDefinitions.forEach(definition => Object.setPrototypeOf(definition, UTSJSONObject.prototype));\nexports.page = { load, checkIn, showRules, pointsNote, pendingRequest, points, errorMessage, taskViews, taskCaption, doTask };'), {
exports, UTSJSONObject: JsonObject, Date, Math, Number, Error,
ref: value => ({ value }), computed: get => ({ get value() { return get(); } }), watch: () => {},
onShow: callback => { shown = callback; }, onHide: () => {}, onUnload: () => {},
onShareAppMessage: callback => { share = callback; },
uni: { showModal: value => modals.push(value), showToast: value => toasts.push(value), navigateTo: () => {} },
require: name => {
if (name === '@/services/api.uts') return api;
if (name === '@/services/points.uts') return service;
if (name === '@/services/storage.uts') return { getUserStorage: key => storage.get(key) ?? null, setUserStorage: (key, value) => storage.set(key, json(value)), removeUserStorage: key => storage.delete(key) };
if (name === '@/services/layout.uts') return { usePageLayout: () => ({ contentStyle: () => '', pageStyle: () => '' }) };
if (name.endsWith('.uvue')) return {};
throw new Error('Unexpected page dependency: ' + name);
}
});
return { ...exports.page, show: shown, share: () => share?.(), storage, modals, toasts };
}
const flush = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
let checks = 0, failures = 0;
async function test(name, action) {
ruleReply = plain(published); signed = false; loseResponse = false; ledgerWrites = 0; calls = [];
try { await action(); checks++; console.log('PASS ' + name); }
catch (error) { failures++; console.error('FAIL ' + name + ': ' + error.message); }
}
(async () => {
await test('published phase-one policy is parsed from the server', async () => assert.deepEqual(plain(await service.fetchPointsRules()), published));
await test('middle action shares the public home page without issuing points', async () => {
const view = page(); view.show(); await flush();
const task = view.taskViews.value[1];
assert.equal(task.id, 'share'); assert.equal(task.name, '分享');
assert.equal(view.taskCaption(task), '分享小程序');
view.doTask(task);
assert.deepEqual(plain(view.share()), { title: 'AI融资 · 智能融资分析与产品匹配', path: '/pages/home/home' });
assert.equal(calls.filter(item => item.method === 'POST').length, 0);
assert.equal(ledgerWrites, 0);
});
for (const [name, value] of Object.entries({ timezone: 'UTC', checkInReward: 0, ruleVersion: 'INVALID', publishedRules: null })) {
await test('reject inconsistent rule field ' + name, async () => { ruleReply[name] = value; await assert.rejects(service.fetchPointsRules()); });
}
for (const [name, value] of Object.entries({ dailyLimit: 2, makeUpAllowed: true, consecutiveBonusEnabled: true, profileRewardEnabled: true, invitationRewardEnabled: true, expiryPolicy: '30-days', levelPolicy: 'upgrade', benefitsPolicy: 'cash' })) {
await test('do not advertise unsupported policy ' + name, async () => { ruleReply.publishedRules[name] = value; await assert.rejects(service.fetchPointsRules()); });
}
await test('draft has no invented reward or policy', async () => { ruleReply = plain(draft); assert.deepEqual(plain(await service.fetchPointsRules()), draft); });
await test('draft cannot smuggle a published reward', async () => { ruleReply = { ...plain(draft), checkInReward: 10 }; await assert.rejects(service.fetchPointsRules()); });
await test('published rule dialog explains the complete approved policy', async () => {
const view = page(); view.show(); await flush(); view.showRules();
const content = view.modals.at(-1).content;
for (const text of ['10', '北京时间', '0点', '不支持补签', '连续签到', '不过期', '会员等级', '兑换', '提现', 'AI']) assert.ok(content.includes(text), 'Missing policy explanation: ' + text);
});
await test('reward wording follows the server instead of hardcoding ten', async () => {
ruleReply.checkInReward = 7;
const view = page(); view.show(); await flush(); view.showRules();
assert.match(view.modals.at(-1).content, /7 分/); assert.doesNotMatch(view.modals.at(-1).content, /10 分/);
});
await test('paused is distinct from unpublished and preserves rule information', async () => {
ruleReply.status = 'paused';
const view = page(); view.show(); await flush();
assert.match(view.pointsNote.value, /暂停/);
view.showRules(); assert.match(view.modals.at(-1).content, /暂停/); assert.match(view.modals.at(-1).content, /10 分/);
await view.checkIn(); assert.equal(calls.filter(item => item.method === 'POST').length, 0);
});
await test('draft dialog does not present ten points as an active reward', async () => {
ruleReply = plain(draft); const view = page(); view.show(); await flush(); view.showRules();
assert.match(view.modals.at(-1).content, /尚未|暂未/); assert.doesNotMatch(view.modals.at(-1).content, /10|不过期/);
});
await test('malformed policy blocks partial page data and check-in', async () => {
ruleReply.publishedRules.expiryPolicy = '30-days';
const view = page(); view.show(); await flush();
assert.equal(view.points.value, null); assert.notEqual(view.errorMessage.value, '');
await view.checkIn(); assert.equal(calls.filter(item => item.method === 'POST').length, 0);
});
await test('lost response retains the original request and confirms without a second reward', async () => {
const view = page(); view.show(); await flush(); loseResponse = true;
await view.checkIn(); const original = plain(view.pendingRequest.value);
assert.equal(ledgerWrites, 1); assert.equal(view.storage.size, 1);
await view.load(); assert.equal(view.points.value.signedToday, true);
await view.checkIn(); assert.equal(ledgerWrites, 1); assert.equal(view.pendingRequest.value, null); assert.equal(view.storage.size, 0);
const posts = calls.filter(item => item.method === 'POST'); assert.equal(posts.length, 2); assert.deepEqual(posts[0].body, original); assert.deepEqual(posts[1].body, original);
});
console.log('Points phase one: ' + checks + ' passed, ' + failures + ' failed; networkCalls=0.');
if (failures) process.exitCode = 1;
})().catch(error => { console.error(error); process.exitCode = 1; });
+211
View File
@@ -0,0 +1,211 @@
// 执行真实UTS服务及页面逻辑;仅微信授权、HTTP和设备存储采用边界替身。
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require(process.env.AIRZ_TYPESCRIPT_PATH || 'D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dcloudio/uni-uts-v1/lib/typescript/lib/typescript.js');
const root = path.resolve(__dirname, '..');
class JsonObject {
constructor(value = {}) { Object.assign(this, value); }
getString(key) { return typeof this[key] === 'string' ? this[key] : null; }
getNumber(key) { return typeof this[key] === 'number' ? this[key] : null; }
getBoolean(key) { return typeof this[key] === 'boolean' ? this[key] : null; }
}
const json = value => Array.isArray(value) ? value.map(json) : value && typeof value === 'object' ? new JsonObject(Object.fromEntries(Object.entries(value).map(([k, v]) => [k, json(v)]))) : value;
const hash = 'a'.repeat(64);
const ticket = 'b'.repeat(64);
const terms = { revisionId: '1', code: 'terms', version: '2026.10-v1', scope: 'account-terms', title: '合成用户协议', content: '合成全文', contentHash: hash, effectiveAt: '2026-10-21T00:00:00Z' };
const privacy = { ...terms, revisionId: '2', code: 'privacy', scope: 'account-privacy' };
const agreements = [terms, privacy].map((item, i) => json({ agreementCode: item.code, agreementVersion: item.version, contentHash: hash, requestId: 'synthetic_consent_' + i }));
const authenticated = () => ({ status: 'authenticated', token: 'synthetic-wechat-token', userId: '7', phone: '13800000000', expiresAt: '2099-01-01T00:00:00Z', profile: { nickname: '合成用户', avatarUrl: '' }, consentsConfirmed: true });
const pending = () => ({ status: 'bind_required', bindingTicket: ticket, expiresIn: 600 });
function preprocess(source, platform) {
const stack = [true];
return source.split(/\r?\n/).filter(line => {
let match = line.match(/^\s*\/\/\s*#(ifdef|ifndef)\s+(.+)$/);
if (match) { const found = match[2].split(/\s*\|\|\s*/).includes(platform); stack.push(stack.at(-1) && (match[1] === 'ifdef' ? found : !found)); return false; }
if (/^\s*\/\/\s*#endif/.test(line)) { stack.pop(); return false; }
return stack.at(-1);
}).join('\n');
}
function environment(platform = 'MP-WEIXIN') {
let now = Date.now();
class Clock extends Date { static now() { return now; } }
const state = { session: null, calls: [], saves: [], redirects: [], authorizationCalls: 0, show: null, hide: null, unload: null };
state.reply = url => {
if (url === '/agreements/terms') return terms;
if (url === '/agreements/privacy') return privacy;
if (url === '/auth/wechat-login') return pending();
if (url === '/auth/wechat/sms-code' || url === '/auth/sms-code') return { retryAfter: 60, expiresIn: 300 };
if (url === '/auth/wechat/bind-phone') return authenticated();
throw Error('Unexpected request ' + url);
};
state.authorize = options => options.success({ code: 'synthetic-wechat-code' });
class ApiError extends Error { constructor(message, status, errorCode) { super(message); this.status = status; this.errorCode = errorCode; } }
state.ApiError = ApiError;
const api = { API_BASE_URL: 'https://synthetic.invalid/api/v1', ApiRequestError: ApiError,
getSession: () => state.session,
clearSession: () => { state.session = null; },
saveSession: (token, phone, userId, expiresAt) => { state.session = json({ token, phone, userId, expiresAt }); state.saves.push(state.session); },
requestJson: async (url, method, payload) => { state.calls.push({ url, method, payload: JSON.parse(JSON.stringify(payload)) }); return json(await state.reply(url, method, payload)); }
};
const watches = new Map();
const uni = { login: options => { state.authorizationCalls++; state.authorize(options); }, showToast() {}, showModal() {}, navigateTo() {}, redirectTo: ({ url }) => state.redirects.push(url), setStorageSync() {}, getStorageSync() { return null; } };
const modules = {};
function load(relative) {
if (modules[relative]) return modules[relative];
const full = path.join(root, relative);
assert.ok(fs.existsSync(full), '微信绑定服务尚未实现:' + relative);
const exports = {}; modules[relative] = exports;
run(preprocess(fs.readFileSync(full, 'utf8'), platform), relative, exports);
return exports;
}
function dependencies(name, relative) {
if (name === '@/services/api.uts' || name === './api.uts') return api;
if (name === '@/services/storage.uts') return { setUserStorage() {} };
if (name === '@/services/layout.uts') return { usePageLayout: () => ({ pageStyle() {}, contentStyle() {}, statusStyle() {} }) };
if (name === '@/services/launch.uts') return { consumeInitialLaunch() {} };
if (name.endsWith('.uvue')) return {};
return load(name.startsWith('@/') ? name.slice(2) : path.posix.normalize(path.posix.join(path.posix.dirname(relative), name)));
}
function run(source, relative, exports) {
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
vm.runInNewContext(code, { exports, require: name => dependencies(name, relative), UTSJSONObject: JsonObject, Date: Clock, Error, Math, Number, uni,
ref: value => { const item = {}; Object.defineProperty(item, 'value', { get: () => value, set: next => { value = next; watches.get(item)?.(); } }); return item; },
computed: fn => ({ get value() { return fn(); } }), watch: (item, fn) => watches.set(item, fn),
onLoad() {}, onShow: fn => { state.show = fn; }, onHide: fn => { state.hide = fn; }, onUnload: fn => { state.unload = fn; }, onUnmounted() {}, setInterval: () => 1, clearInterval() {}
}, { filename: relative });
}
state.load = load;
state.advance = ms => { now += ms; };
state.page = () => {
const relative = 'packages/account/login/login.uvue';
const source = fs.readFileSync(path.join(root, relative), 'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
const exports = {};
const names = ['login', 'beginWechatLogin', 'cancelWechatBinding', 'resendSms', 'phone', 'password', 'mode', 'smsCode', 'smsSent', 'countdown', 'loading', 'agreed', 'authenticated', 'errorMessage', 'wechatBindingTicket', 'toggleAgreement'];
run(preprocess(source, platform) + '\nexports.page={' + names.map(name => name + ':typeof ' + name + '==="undefined"?null:' + name).join(',') + '};', relative, exports);
return exports.page;
};
return state;
}
let checks = 0;
async function test(name, action) { await action(); checks++; console.log('PASS ' + name); }
async function ready(state) { const page = state.page(); await state.show(); page.toggleAgreement(); return page; }
async function bindPage(state) { const page = await ready(state); await page.beginWechatLogin(); return page; }
(async () => {
await test('authorization and pending binding create no local login session', async () => {
const e = environment(); const service = e.load('packages/account/services/wechat-login.uts');
const result = await service.requestWechatLogin('synthetic-code', agreements);
assert.equal(result.status, 'bind_required'); assert.equal(e.saves.length, 0);
assert.deepEqual(e.calls[0], { url: '/auth/wechat-login', method: 'POST', payload: { platform: 'mp-weixin', code: 'synthetic-code', agreements: JSON.parse(JSON.stringify(agreements)) } });
});
await test('binding sends its own ticket and SMS endpoint instead of logging in a separate account', async () => {
const e = environment(); const service = e.load('packages/account/services/wechat-login.uts');
await service.requestWechatBindingCode(ticket, '13800000000');
const result = await service.bindWechatPhone(ticket, '13800000000', '123456');
assert.deepEqual(e.calls.map(call => call.url), ['/auth/wechat/sms-code', '/auth/wechat/bind-phone']);
assert.deepEqual(e.calls[1].payload, { bindingTicket: ticket, phone: '13800000000', code: '123456' });
assert.equal(e.saves.length, 0); service.acceptWechatSession(result);
assert.equal(e.session.userId, '7'); assert.equal(e.session.phone, '13800000000');
});
await test('malformed, pending, or unconfirmed results never save a session', async () => {
const e = environment(); const s = e.load('packages/account/services/wechat-login.uts');
for (const value of [pending(), { ...authenticated(), consentsConfirmed: false }, { ...authenticated(), phone: '' }, { ...authenticated(), userId: '' }, { ...authenticated(), expiresAt: '' }, { ...authenticated(), token: '' }]) assert.throws(() => s.acceptWechatSession(json(value)));
assert.equal(e.saves.length, 0);
e.reply = () => ({ ...pending(), token: 'must-not-be-issued' });
await assert.rejects(s.requestWechatLogin('synthetic-code', agreements));
});
await test('invalid phone, ticket or code is rejected before sending', async () => {
const e = environment(); const s = e.load('packages/account/services/wechat-login.uts');
await assert.rejects(s.requestWechatBindingCode('short', '13800000000'));
await assert.rejects(s.requestWechatBindingCode(ticket, '1380000'));
await assert.rejects(s.bindWechatPhone(ticket, '13800000000', '123'));
assert.equal(e.calls.length, 0);
});
await test('web and native app do not invoke the unconfigured WeChat adapter', async () => {
for (const platform of ['WEB', 'APP-ANDROID', 'APP-IOS']) {
const e = environment(platform); const s = e.load('packages/account/services/wechat-login.uts');
assert.equal(s.wechatLoginSupported(), false);
await assert.rejects(s.requestWechatAuthorization()); assert.equal(e.authorizationCalls, 0);
}
});
await test('missing agreements block WeChat authorization even in password mode', async () => {
const e = environment(); e.reply = () => { throw new e.ApiError('协议未发布', 404, 'AGREEMENT_NOT_AVAILABLE'); };
const page = e.page(); await e.show(); await page.beginWechatLogin();
assert.equal(e.authorizationCalls, 0); assert.equal(e.saves.length, 0); assert.ok(page.errorMessage.value.length > 0);
});
await test('first WeChat authorization shows binding without creating a session', async () => {
const e = environment(); const page = await bindPage(e);
assert.equal(page.wechatBindingTicket.value, ticket); assert.equal(page.mode.value, 'phone');
assert.equal(e.saves.length, 0); assert.equal(e.redirects.length, 0);
const commands = e.calls.find(call => call.url === '/auth/wechat-login').payload.agreements;
assert.deepEqual(commands.map(item => item.agreementCode), ['terms', 'privacy']);
assert.notEqual(commands[0].requestId, commands[1].requestId);
});
await test('verified binding saves original account exactly once and returns to the intended page', async () => {
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000';
await page.login(); assert.equal(page.smsSent.value, true); assert.equal(e.saves.length, 0);
page.smsCode.value = '123456'; await page.login();
assert.equal(e.session.userId, '7'); assert.equal(e.saves.length, 1); assert.equal(e.redirects.length, 1);
assert.equal(e.calls.filter(call => call.url === '/consents').length, 0, 'server-confirmed agreements are not submitted twice');
});
await test('already bound WeChat can log in without requesting SMS', async () => {
const e = environment(); const reply = e.reply; e.reply = url => url === '/auth/wechat-login' ? authenticated() : reply(url);
const page = await ready(e); await page.beginWechatLogin();
assert.equal(e.saves.length, 1); assert.equal(e.redirects.length, 1);
assert.equal(e.calls.filter(call => call.url.includes('sms')).length, 0);
});
await test('cancelling pending binding returns to SMS login and removes its ticket', async () => {
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000'; page.cancelWechatBinding();
assert.equal(page.wechatBindingTicket.value, ''); assert.equal(page.smsCode.value, '');
await page.login(); assert.equal(e.calls.at(-1).url, '/auth/sms-code'); assert.equal(e.saves.length, 0);
});
await test('expired ticket cannot send SMS or complete binding', async () => {
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000'; e.advance(600001);
const before = e.calls.length; await page.login();
assert.equal(e.calls.length, before); assert.equal(page.wechatBindingTicket.value, ''); assert.match(page.errorMessage.value, /过期/);
});
await test('WeChat cancellation exposes an error and creates no account session', async () => {
const e = environment(); e.authorize = options => options.fail({ errCode: 1310509, errMsg: 'cancel' });
const page = await ready(e); await page.beginWechatLogin();
assert.equal(e.calls.filter(call => call.url === '/auth/wechat-login').length, 0); assert.equal(e.saves.length, 0); assert.match(page.errorMessage.value, /取消/);
});
await test('a WeChat response arriving after page hide cannot save or redirect', async () => {
const e = environment(); let release; const reply = e.reply;
e.reply = url => url === '/auth/wechat-login' ? new Promise(resolve => { release = resolve; }) : reply(url);
const page = await ready(e); const work = page.beginWechatLogin(); for (let i = 0; i < 12; i++) await Promise.resolve();
assert.equal(typeof release, 'function'); e.hide(); release(authenticated()); await work;
assert.equal(e.saves.length, 0); assert.equal(e.redirects.length, 0); assert.equal(page.loading.value, false);
});
await test('a binding response cannot replace another account that logged in meanwhile', async () => {
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000'; await page.login(); page.smsCode.value = '123456';
let release; e.reply = () => new Promise(resolve => { release = resolve; });
const work = page.login(); for (let i = 0; i < 8; i++) await Promise.resolve();
e.session = json({ token: 'new-account-token', userId: '99' }); release(authenticated()); await work;
assert.equal(e.session.userId, '99'); assert.equal(e.saves.length, 0); assert.equal(e.redirects.length, 0);
});
await test('double clicking sends only one binding command while the first is pending', async () => {
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000'; await page.login(); page.smsCode.value = '123456';
let release; e.reply = () => new Promise(resolve => { release = resolve; });
const first = page.login(); await page.login(); release(authenticated()); await first;
assert.equal(e.calls.filter(call => call.url === '/auth/wechat/bind-phone').length, 1); assert.equal(e.saves.length, 1);
});
await test('binding conflict keeps the account logged out and does not automatically retry', async () => {
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000'; await page.login(); page.smsCode.value = '123456';
e.reply = () => { throw new e.ApiError('绑定存在冲突,请使用原账户登录', 409, 'WECHAT_BINDING_CONFLICT'); };
await page.login(); assert.equal(e.saves.length, 0); assert.match(page.errorMessage.value, /冲突/);
assert.equal(e.calls.filter(call => call.url === '/auth/wechat/bind-phone').length, 1);
});
await test('an updated agreement cancels pending binding and requires a fresh checkbox', async () => {
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000'; await page.login(); page.smsCode.value = '123456';
const reply = e.reply; e.reply = url => url === '/auth/wechat/bind-phone' ? Promise.reject(new e.ApiError('协议已更新', 409, 'AGREEMENT_CHANGED')) : reply(url);
await page.login(); assert.equal(page.wechatBindingTicket.value, ''); assert.equal(page.agreed.value, false); assert.equal(e.saves.length, 0);
});
for (const kind of ['sms', 'password']) await test(kind + ' login also ignores a response arriving after leaving the page', async () => {
const e = environment(); const page = await ready(e); page.phone.value = '13800000000'; page.password.value = 'SyntheticPass9'; page.mode.value = kind === 'sms' ? 'phone' : 'password';
if (kind === 'sms') { await page.login(); page.smsCode.value = '123456'; }
let release; e.reply = () => new Promise(resolve => { release = resolve; }); const work = page.login(); e.hide();
release(authenticated()); await work; assert.equal(e.saves.length, 0); assert.equal(e.redirects.length, 0);
});
console.log(JSON.stringify({ checks, passed: true }));
})().catch(error => { console.error(error.stack || error); process.exitCode = 1; });
+17 -4
View File
@@ -74,12 +74,25 @@ export async function fetchPointsSummary(): Promise<PointsSummary> {
export async function fetchPointsRules(): Promise<PointsRules> { export async function fetchPointsRules(): Promise<PointsRules> {
ensureSession() ensureSession()
const data = await requestJson('/points/rules', 'GET', {}) const data = await requestJson('/points/rules', 'GET', {})
const status = ensureStatus(text(data, 'status'))
const ruleVersion = nullableText(data, 'ruleVersion')
const checkInReward = nullableInteger(data, 'checkInReward')
const timezone = text(data, 'timezone')
const published = data['publishedRules'] const published = data['publishedRules']
return { if (timezone != 'Asia/Shanghai') throw new Error('积分规则时区无效')
status: ensureStatus(text(data, 'status')), ruleVersion: nullableText(data, 'ruleVersion'), if (status == 'draft') {
checkInReward: nullableInteger(data, 'checkInReward'), timezone: text(data, 'timezone'), if (data['ruleVersion'] !== null || data['checkInReward'] !== null || published !== null) throw new Error('积分规则状态不一致')
publishedRules: published != null && typeof published == 'object' ? published as UTSJSONObject : null return { status, ruleVersion: null, checkInReward: null, timezone, publishedRules: null }
} }
if (ruleVersion == null || !/^[a-z][a-z0-9-]{1,63}$/.test(ruleVersion) || checkInReward == null
|| published == null || typeof published != 'object' || Array.isArray(published)) throw new Error('积分规则格式无效')
const policy = published as UTSJSONObject
// 展示的权益必须与服务端已实现的策略一致,未知策略不能套用一期说明。
if (policy.getNumber('dailyLimit') != 1 || policy.getBoolean('makeUpAllowed') != false
|| policy.getBoolean('consecutiveBonusEnabled') != false || policy.getBoolean('profileRewardEnabled') != false
|| policy.getBoolean('invitationRewardEnabled') != false || policy.getString('expiryPolicy') != 'none'
|| policy.getString('levelPolicy') != 'unchanged' || policy.getString('benefitsPolicy') != 'none') throw new Error('积分规则暂不支持,请稍后重试')
return { status, ruleVersion, checkInReward, timezone, publishedRules: policy }
} }
function ledgerFrom(data: UTSJSONObject): PointsLedgerItem { function ledgerFrom(data: UTSJSONObject): PointsLedgerItem {