refactor: 重构存储与接口适配,统一金额单位与会话管理

- 新增配置、存储、金额转换工具服务
- 按userId隔离本地缓存,替换旧设备级存储
- 统一移动端金额单位为万元,新增转换适配逻辑
- 重构认证与请求逻辑,支持真实后端会话
- 调整搜索、帮助、融资模块的模拟开关与数据适配
- 更新文档说明开发状态与配置规则
This commit is contained in:
hujun
2026-09-22 17:25:52 +08:00
parent 4a69357606
commit dcf12a4471
25 changed files with 1619 additions and 143 deletions
+17 -5
View File
@@ -4,7 +4,7 @@
## 本地运行 ## 本地运行
在 HBuilderX 中导入当前目录,选择“运行 → 运行到浏览器”。共享请求配置位于 `services/api.uts`,账户、融资的接口模拟实现分别位于对应分包的 `services/`,没有连接真实短信、贷款申请或 AI 服务。 在 HBuilderX 中导入当前目录,选择“运行 → 运行到浏览器”。共享请求配置位于 `services/api.uts`,账户、融资服务分别位于对应分包的 `services/`。现已接入本地后端密码登录、读取资料、退出及原密码改密;短信、贷款申请和 AI 服务尚未接入。
也可使用 HBuilderX CLI。以下 PowerShell 命令中的安装路径应按本机环境调整;在项目根目录执行: 也可使用 HBuilderX CLI。以下 PowerShell 命令中的安装路径应按本机环境调整;在项目根目录执行:
@@ -38,13 +38,13 @@ $projectPath = (Get-Location).Path
- 首页及跨包共用的轻量位图位于 `static/mobile/`,专属大图位于各分包的 `static/`,原始图片保留在 `static/images/` 与 `static/icons-generated/`。箭头继续使用 `static/icons/` 下的 SVG。 - 首页及跨包共用的轻量位图位于 `static/mobile/`,专属大图位于各分包的 `static/`,原始图片保留在 `static/images/` 与 `static/icons-generated/`。箭头继续使用 `static/icons/` 下的 SVG。
- 底栏使用 `static/mobile/navigation/` 的专用位图,由现有高清图标按可见边界裁切、等高居中得到。更换素材时保持一致的图案高度和画布留白,避免仅统一图片元素尺寸却出现视觉错位。 - 底栏使用 `static/mobile/navigation/` 的专用位图,由现有高清图标按可见边界裁切、等高居中得到。更换素材时保持一致的图案高度和画布留白,避免仅统一图片元素尺寸却出现视觉错位。
- 微信打包忽略原始图片目录和未使用的 `static/logo.png`,规则位于 `manifest.json` 的 `mp-weixin.packOptions.ignore`。新增资源要检查引用与排除规则,最终包体以微信开发者工具结果为准。 - 微信打包忽略原始图片目录和未使用的 `static/logo.png`,规则位于 `manifest.json` 的 `mp-weixin.packOptions.ignore`。新增资源要检查引用与排除规则,最终包体以微信开发者工具结果为准。
- API 地址目前为空,表示模拟模式。接入后端时需补齐环境配置、认证、错误契约及真实业务校验,再进行联调。 - API 环境配置在 `services/config.uts`,开发地址为 `http://127.0.0.1/api/v1`。共享请求已统一Bearer、FastAdmin响应解包及401处理;模拟开关关闭,缺服务配置时失败而非模拟成功。测试/生产地址待配置,生产只允许HTTPS。真机需改为可访问的局域网/正式地址,不能使用手机自身127.0.0.1。
## 当前前端交互 ## 当前前端交互
个人与企业评估分别保存本机草稿;选图仅供本机预览,资料上传和数据查询尚未接入。课程章节进度、消息已读与体验签到均保存在当前设备,不代表服务器账户数据。 个人与企业评估分别保存本机草稿;选图仅供本机预览,资料上传和数据查询尚未接入。课程章节进度、消息已读与体验签到均保存在当前设备,不代表服务器账户数据。
个人中心顶部的头像、昵称、手机号区域统一进入修改资料;账户安全独立提供修改密码、验证码重设密码及换绑手机号。昵称和头像按当前手机号隔离保存在本机,头像使用 500KB 以内的 JPG / PNG / WebP 数据,不保存会失效的临时路径,不修改融资档案的实名信息。真实账户认证尚未接入,修改密码和换绑手机号不会在本机模拟成功,也不保存密码、验证码或其散列。安全接口成功后清除当前会话并要求重新登录;验证码计时仅在服务端发送成功后启动。 个人中心顶部的头像、昵称、手机号区域统一进入修改资料;账户安全独立提供修改密码、验证码重设密码及换绑手机号。昵称和头像缓存按稳定userId隔离,手机号仅保留为内部兼容键。头像编辑交互限制 500KB 以内的 JPG / PNG / WebP 数据,正式上传和持久化仍待开发,不修改融资档案的实名信息。密码登录、原密码改密及退出已接入Redis会话;短信验证和换绑尚未接入,不在本机模拟成功,也不保存密码、验证码或其散列。安全接口成功后清除当前会话并要求重新登录;验证码计时仅在服务端发送成功后启动。
账户接口集中在 `packages/account/services/account.uts`,请求携带当前会话的 `Authorization: Bearer <token>`;后端需根据 token 确定账户,校验验证码用途与有效期、限制发送频率并完成旧会话撤销。预留: 账户接口集中在 `packages/account/services/account.uts`,请求携带当前会话的 `Authorization: Bearer <token>`;后端需根据 token 确定账户,校验验证码用途与有效期、限制发送频率并完成旧会话撤销。预留:
@@ -53,9 +53,9 @@ $projectPath = (Get-Location).Path
- `PUT /account/password`:`verification`(`password` / `sms`)、`oldPassword` 或 `code`、`newPassword`。新密码为 8–32 位可打印 ASCII,需含字母和数字,不允许空格。 - `PUT /account/password`:`verification`(`password` / `sms`)、`oldPassword` 或 `code`、`newPassword`。新密码为 8–32 位可打印 ASCII,需含字母和数字,不允许空格。
- `PUT /account/phone`:`newPhone`、`currentCode`、`newCode`;后端验证两组验证码及新号码占用情况。 - `PUT /account/phone`:`newPhone`、`currentCode`、`newCode`;后端验证两组验证码及新号码占用情况。
资产与负债包含 `creditLoanCount`(信用贷款笔数)、`totalDebtBalance`(欠款总余额)、`monthlyLoanPayment`(贷款月还款金额)、`creditCardCount`(银行信用卡张数)、`creditCardTotalLimit`(总额度)、`creditCardUsedLimit`(已使用总额度)、`house` / `car`(有 / 无)。金额以元计,最多两位小数,数量为非负整数;保持原来的可跳过规则,填满八项后计为档案已完善。旧草稿的房车选项自动映射,新数字字段留空,不从旧存款区间或信用卡状态猜测金额;旧字段保留在草稿中以免丢失历史输入。 资产与负债包含 `creditLoanCount`(信用贷款笔数)、`totalDebtBalance`(欠款总余额)、`monthlyLoanPayment`(贷款月还款金额)、`creditCardCount`(银行信用卡张数)、`creditCardTotalLimit`(总额度)、`creditCardUsedLimit`(已使用总额度)、`house` / `car`(有 / 无)。移动端所有金额统一以万元输入和展示,最多六位小数;提交接口前精确换算成元的两位小数字符串,范围为 0–999999999.99 元(DECIMAL(11,2))。数量为非负整数;保持原来的可跳过规则,填满八项后计为档案已完善。草稿使用 `moneyUnit: wan`、`draftVersion: 2` 标记;无标记旧草稿的月收入及贷款/信用卡金额从元换算成万元,年营收原本就是万元,保留原值。旧草稿的房车选项自动映射,新数字字段留空,不从旧存款区间或信用卡状态猜测金额;旧字段保留在草稿中以免丢失历史输入。
新闻与课程的数据定义位于 `packages/content/services/content.uts`,产品定义位于 `packages/finance/services/finance.uts`。产品列表与详情共用 ID;预留 `GET /matches`(`items` 数组)及 `GET /products/:id`(单个产品),字段见 `FinanceProduct`,其中 `amount` 为万元数值、`rate` 为年化百分比数值。申请入口目前仅引导准备资料,不向金融机构提交申请。 新闻与课程的数据定义位于 `packages/content/services/content.uts`,产品定义位于 `packages/finance/services/finance.uts`。产品列表与详情共用 ID;预留 `GET /matches`(`items` 数组)及 `GET /products/:id`(单个产品),字段见 `FinanceProduct`,其中视图模型的 `amount` 为万元数值、`rate` 为年化百分比数值。真实接口应返回 `amountYuan`(元的两位小数字符串),由适配层转换;旧 `amount`(万元)仅保留兼容。后端产品表使用独立的额度、期限和利率范围字段,匹配分数与理由属于评估结果。申请入口目前仅引导准备资料,不向金融机构提交申请。
首页搜索进入内容分包的 `search/search`,支持全部、新闻、融资课堂筛选及对应详情跳转。搜索服务位于 `packages/content/services/search.uts`,当前匹配本地标题、简介、分类和正文;空格分隔的多个关键词需同时命中。预留 `GET /content/search`,参数为 `keyword` 与 `type`(`all` / `news` / `lesson`),返回 `{ items: ContentSearchResult[] }`,每项包含 `id`、`type`、`title`、`summary`、`category`、`meta`、`image`。后端接入时需同步接入新闻和课程详情的数据源,确保返回的 ID 可查询详情。 首页搜索进入内容分包的 `search/search`,支持全部、新闻、融资课堂筛选及对应详情跳转。搜索服务位于 `packages/content/services/search.uts`,当前匹配本地标题、简介、分类和正文;空格分隔的多个关键词需同时命中。预留 `GET /content/search`,参数为 `keyword` 与 `type`(`all` / `news` / `lesson`),返回 `{ items: ContentSearchResult[] }`,每项包含 `id`、`type`、`title`、`summary`、`category`、`meta`、`image`。后端接入时需同步接入新闻和课程详情的数据源,确保返回的 ID 可查询详情。
@@ -84,3 +84,15 @@ $projectPath = (Get-Location).Path
- 仅某个分包使用的图片放在该包 `static/`;首页也使用的插图及通用图标保留在根 `static/`,避免分包资源被主包引用。 - 仅某个分包使用的图片放在该包 `static/`;首页也使用的插图及通用图标保留在根 `static/`,避免分包资源被主包引用。
参考:[uni-app x 页面与分包配置](https://doc.dcloud.net.cn/uni-app-x/collocation/pagesjson.html#subpackages)。 参考:[uni-app x 页面与分包配置](https://doc.dcloud.net.cn/uni-app-x/collocation/pagesjson.html#subpackages)。
## 2026-09-22 金额与评估存储约定
`services/money.uts` 集中处理万元与元的精确转换;确认页与草稿均为万元,`submitAssessment` 仅在提交边界转换。后端每次评估对应一条 `fa_user_info`,其报告和产品快照保存在唯一关联的 `fa_ai_chat` 中。后端数据库及生命周期服务已准备,移动端已切换真实本地接口,未完成的评估、AI、上传及授权接口不会模拟成功,需按开发进度继续接入。
## 2026-09-22 开发启动状态
- 登录默认使用密码模式;需已有手机号账户。尚未配置短信服务商,发送验证码会展示后端503提示,不启动倒计时或生成demo Token。
- 会话包含userId/有效期,旧无身份的模拟会话作废。草稿、临时评估状态、昵称头像、课程本机进度、体验签到和消息已读统一经 `services/storage.uts` 按userId隔离。旧设备级草稿保留但不自动分配给登录用户;显式导入和敏感缓存生命周期后续补齐。
- 首批真实接口不等于25页已业务化。首页/资讯/课程等部分展示仍为原型;评估提交、产品匹配和AI助手接口尚未实现。各模块状态以 [开发进度](docs/开发进度.md)为准。
- 默认开发环境不代表正式部署;发布前需核验目标环境、API地址、HTTPS/CORS及所有必做依赖,禁止把本机地址作为生产配置。
+780
View File
@@ -0,0 +1,780 @@
# AI 融资平台开发文档
> 版本:v1.0 · 基线日期:2026-09-22 · 状态:M0与账户基础已启动,真实短信/AI/产品依赖未就绪
> 配套台账:[开发进度](开发进度.md)。本文定义范围、接口和验收口径,台账记录实际执行与证据。
> 文档初始规划后已开始开发;以下更新记录优先于原始基线。除明确列为已实现的部分外,计划接口和增量数据仍待开发。
## 0. 2026-09-22 实施更新
- 用户最新决定:删除fa_user_token,会员Token仅存Redis;已备份并删除本地表,当前25张表。fa_user和各业务关系保持不变。运行配置不再依赖MySQL Token驱动。
- 已接通Bearer、FastAdmin响应解包、业务错误/401处理;新增v1密码登录、本人资料读取、退出、原密码改密。短信接口返回明确未配置状态。
- 前端开发环境指向本地真实接口,按稳定userId隔离本机缓存;测试/生产地址、匿名旧草稿显式导入和敏感缓存清理仍待完成。内容等尚未开发模块仍存在原型数据,不能判一期已完成。
- Redis驱动使用独立前缀、摘要Token键、TTL和Lua原子索引/撤销。登录和改密按用户行锁串行,Redis故障不降级到MySQL。
- 短信服务商、DeepSeek测试权限、首批真实产品资料目前均未具备;不创建模拟凭证、不伪造AI结果或导入演示产品充当真实资料。
- 开发任务编号及原69–117人日估算基线保留,尚未填写实际耗时,不凭实施条目推算剩余工期。最新状态与证据见进度文档第0章。
## 1. 项目目标与现状
### 1.1 已确认范围
一期将现有 **25 个页面全部接入真实业务,H5 优先首发**。包含手机号/密码/微信/一键登录、个人与企业评估、私有资料、正式授权、外部征信或数据查询、产品匹配、DeepSeek 报告、历史档案、AI 助手、新闻、图文课堂、搜索、消息、积分邀请、帮助反馈及人工客服。相关运营后台一并验收。
按 **1 人全栈+AI 辅助**排期。AI 服务商确定为 **DeepSeek**;具体模型、预算和测试权限待核验。短信、号码认证、外部数据、客服、存储等渠道待选型。微信内网页与普通 H5 的登录方式分别核实平台能力。一期必做渠道未接通时保持未验收,不以模拟成功替代。
微信小程序、Android、iOS 的平台适配、真机、签名和发布列入后续里程碑,不计入本期 69–117 人日。保留现有跨端代码结构。贷款功能止于申请准备和资料引导;当前范围不包含金融机构受理、授信、放款、支付或资金交易。课堂保持图文形式。
### 1.2 代码与数据依据
| 项目 | 当前事实 | 规划用途 |
| --- | --- | --- |
| 前端 | uni-app x / Vue 3 / UTS,25 页,无独立 npm 构建入口 | 沿用 HBuilderX 和现有分包,不重新搭建前端 |
| 后端 | FastAdmin / ThinkPHP 5.0.28 / PHP 7.4 | 复用认证、后台、模型与队列依赖,新增业务 API/服务 |
| 数据库 | MySQL 8.0.44,删除空 Token 表后实际 25 张 InnoDB 表 | 已核对本地库并更新结构快照;恢复点见进度第0节 |
| 当前前端模式 | 开发环境已连接本地 v1 API,模拟请求开关关闭 | 密码账户流程已接入,内容与报告等页面仍有原型数据,不按页面数计算完成度 |
| 当前后端能力 | 评估生命周期、加密、密码兼容、产品后台等基础已存在 | 内部服务不等于已暴露并联调的业务 API |
| 第三方接入 | 尚未验证真实短信、DeepSeek、外部查询、客服闭环 | 各渠道独立记录测试环境与验收证据 |
源代码位置:
- 前端:[README](../README.md)、[页面注册](../pages.json)、[共享请求](../services/api.uts)、[金额转换](../services/money.uts)。
- 后端:[数据库约定](D:/AAdesk/202609/airz/database/README.md)、[当前结构](D:/AAdesk/202609/airz/database/schema.sql)、[评估生命周期](D:/AAdesk/202609/airz/application/common/library/Assessment.php)、[API 基类](D:/AAdesk/202609/airz/application/common/controller/Api.php)。
- 原根目录 airz.sql 是历史导出;不能作为当前库结构依据。双方已有未提交改动属于现状基线,不在文档任务中覆盖或提交。
### 1.3 已有基础与首要缺口
已有基础:万元与元精确转换、个人/企业草稿交互、产品范围字段和版本、评估提交/完成/失败/重试内部服务、敏感字段加密、旧密码兼容、Token Redis切换(早期曾恢复表,现已删除)、日志脱敏及基础验证。
首要缺口:
1. Bearer、FastAdmin响应解析和401处理已接通;全量字段字典、requestId日志关联、生产环境配置仍待完成。
2. 确认页未承接评估编号,分析页计时跳转,报告使用固定分数;必须打通 assessmentId 全链路。
3. 本机草稿、学习进度、签到和消息状态已按userId隔离;旧匿名草稿显式导入、退出后敏感缓存清理与云端持久化仍待完成。
4. 产品准入、业务 API、评估队列、DeepSeek 请求与真实报告均待开发。
5. 通用公共上传不能直接存正式证件;授权勾选和插件钩子不代表第三方服务已接入。
## 2. 页面与功能覆盖
路由以 [pages.json](../pages.json) 为准。以下覆盖现有全部 25 页;反馈表单、历史列表等优先补入所属现有页面,若交互确需新增页面,在所属分包注册并同步台账。
| 序号 | 页面路由 | 功能职责 | 主要任务 |
| --- | --- | --- | --- |
| 1 | pages/home/home | 首页、融资入口、推荐、消息红点 | BASE-004、CONTENT-002、MSG-003 |
| 2 | packages/finance/entry/entry | 个人/企业融资入口 | ASSESS-004 |
| 3 | packages/finance/assessment/assessment | 分步填写、上传及授权 | ASSESS-001、ASSESS-002、FILE-004、DATA-004 |
| 4 | packages/finance/confirm/confirm | 确认资料、幂等提交 | ASSESS-003、ASSESS-004 |
| 5 | packages/finance/analyzing/analyzing | 真实分析状态、失败重试 | REPORT-002、REPORT-004 |
| 6 | packages/finance/report/report | 真实报告、维度与结论 | REPORT-003、REPORT-004 |
| 7 | packages/finance/matches/matches | 当前评估匹配、排序、无结果说明 | PRODUCT-004 |
| 8 | packages/finance/product/product | 产品范围、快照、当前可用性与申请准备 | PRODUCT-003、PRODUCT-004 |
| 9 | packages/finance/assistant/assistant | 持久会话、报告解读 | CHAT-001、CHAT-002、CHAT-003、CHAT-004 |
| 10 | packages/finance/archive/archive | 草稿、评估历史、复制新评估 | ASSESS-002、ASSESS-004、REPORT-004 |
| 11 | packages/account/index/index | 启动引导及登录回跳 | BASE-003、BASE-004 |
| 12 | packages/account/login/login | 手机号、密码、微信、一键登录 | AUTH-001、AUTH-002、LOGIN-001、LOGIN-002、LOGIN-003、LOGIN-004 |
| 13 | packages/account/profile/profile | 用户信息、档案、入口和退出 | AUTH-002、AUTH-003、ASSESS-004 |
| 14 | packages/account/edit-profile/edit-profile | 昵称与头像 | AUTH-003 |
| 15 | packages/account/security/security | 账户安全状态及入口 | AUTH-004 |
| 16 | packages/account/password/password | 原密码/验证码修改密码 | AUTH-004 |
| 17 | packages/account/change-phone/change-phone | 原新手机号验证与换绑 | AUTH-004 |
| 18 | packages/account/points/points | 签到、流水、任务及邀请 | POINT-001、POINT-002、POINT-003、POINT-004 |
| 19 | packages/account/messages/messages | 消息与未读管理 | MSG-001、MSG-002、MSG-003、MSG-004 |
| 20 | packages/content/search/search | 新闻和课程搜索 | CONTENT-004 |
| 21 | packages/content/help/help | FAQ、反馈、人工客服 | CONTENT-004、SUPPORT-001、SUPPORT-002、SUPPORT-003、SUPPORT-004 |
| 22 | packages/content/news/news | 资讯分类与列表 | CONTENT-002 |
| 23 | packages/content/article/article | 新闻、协议和说明内容 | CONTENT-001、CONTENT-002、FILE-003 |
| 24 | packages/content/classroom/classroom | 课程分类和推荐 | CONTENT-002 |
| 25 | packages/content/lesson/lesson | 图文章节、学习状态 | CONTENT-001、CONTENT-003 |
## 3. 数据与架构约定
### 3.1 评估、报告、会话
- fa_user_info **一行代表一次评估**;再次评估新建记录,可从本人既往输入复制。提交后的输入不可被前端或后台 CRUD 改写。
- fa_ai_chat 的 scene=assessment 必须关联唯一 assessment_id;report_data 保存报告对象,match_result 保存有序匹配数组。**不新增独立评估、报告或匹配结果表。**
- scene=assistant 时 assessment_id 必须为 NULL。助手要引用某次评估时,计划增加单独的 context_assessment_id,并校验同一用户;它不承担正式报告唯一关系,也不改变已有 scene 约束。
- fa_ai_chat_log 的角色为 system/user/assistant/tool;状态为 pending/streaming/completed/failed。会话内序号唯一;(chat_id,request_id,role) 保证消息级幂等。一期普通助手默认异步完整回复,不要求流式传输。
- 评估状态为 draft → processing → succeeded 或 failed;仅 failed 可重试并进入 processing。填写步骤 step=0–4 与状态分离。排队、数据查询、匹配、生成报告属于任务阶段,不擅自加入现有 status 枚举。
- requestId 使用 8–64 位字母、数字、下划线或横线,作为用户内提交幂等键。taskId 为服务端每次尝试生成的标识;重试更换,旧任务禁止覆盖新任务。
- 当前 submit/complete/fail/retry 已有内部实现;submit重复调用现返回当前taskId,尚不能代表稳定的原尝试响应。计划在评估运行元数据task_requests中持久保存每个提交/重试键、规范化参数摘要、原taskId与接受响应;重复旧键只返回原响应,不启动新任务。写映射与状态变更同一事务锁定评估行,最新状态另查status接口。草稿并发版本、任务阶段与可靠派发仍需补充。
- result_version 是结果 JSON 结构版本,rule_version 是规则版本字符串,product.version 是产品整数版本。当前 complete 尚未显式写入 result_version,REPORT-003 补齐。
- 外部查询结果不回写已提交表单。使用单独查询流水保存来源、授权和标准化结果,任务在生成报告前冻结所用结果集合/截止时间;最终报告保存引用、来源及必要摘要。后到结果不得悄悄重写历史报告。
### 3.2 金额、产品与数据字典
| 数据 | 客户端/API/数据库口径 |
| --- | --- |
| 业务金额 | 客户端万元;API 元的两位小数字符串;DB DECIMAL(11,2),非负范围 0–999999999.99 |
| 精度 | 0.800001 万元 → 8000.01 元;万元最多6位小数,上限99999.999999;不使用二进制浮点乘除形成入库金额 |
| 未填写 | API使用null,适配器将表单空字符串转null;不把未知金额/计数当0 |
| 利率 | 年化百分数,3.85表示3.85%;API建议十进制字符串,禁止误作0.0385 |
| 期限 | 整数月;产品 term_min_months/term_max_months |
| 评估类型 | API personal/enterprise → DB type=1/2 |
| 婚姻 | API已定义枚举 → DB 1已婚、2未婚、3离异、4丧偶;未知NULL |
| 房/车 | API布尔值或null → DB house/vehicle=1/0/NULL;旧“有/无”在适配层转换 |
| 产品对象 | DB object 为 SET,可同时适用于个人和企业;不是单一类型值 |
| 产品状态 | switch=0草稿、1上架、2下架;还需检查有效期 |
| 地区 | 使用统一版本的地区代码;regions=[]才表示全国,NULL视为未配置并阻止上架 |
| 经营时间 | 用户明确填写经营月数;旧operatingYears文字无法确定时要求补填,不猜测 |
| 日期时间 | API事件时间ISO 8601带时区;有效期为YYYY-MM-DD;业务签到时区Asia/Shanghai;数据库现有int/datetime由DTO转换 |
产品额度上下限、期限上下限、年化利率上下限、发布状态、有效期、版本及稳定准入字段保持独立列。regions 为地区代码数组;eligibility_rules 为带版本的补充规则对象。现有 condition/grade/information/recommendation/deprecated/other 保留文本用途;公共 recommendation 不保存用户个性化理由。
硬准入、未知值策略、评分权重、维度和样例在 PRODUCT-001 / DEC-MATCH 定稿。AI不决定产品是否满足硬条件,不编造审批概率或外部征信结论。无可靠依据时报告维度和总分可以为空并说明资料不足,不能继续展示固定85分。
### 3.3 前后端字段映射
以下为目标DTO主要映射,BASE-001和ASSESS-001补齐校验器与兼容适配;不直接把前端payload整包入库。
| 前端/目标API字段 | 数据库字段或目标处理 | 说明 |
| --- | --- | --- |
| type | fa_user_info.type | personal/enterprise转1/2 |
| name / idCard / phone | name / card_no / mobile | card_no加密;只向所属用户按必要范围回显 |
| city / regionCode | city / 拟增region_code | 保留展示名,匹配使用标准代码;不恢复fa_area |
| maritalStatus | marriage | 文字转稳定枚举,再映射数字 |
| occupation / company | profession / employer | 企业occupation表示行业 |
| monthlyIncome / annualRevenue | monthly_income / year_income | 元字符串;分别用于个人/企业 |
| socialSecurity / housingFund | social_security / housing_provident_fund | 统一枚举,区分无、未知与未填写 |
| house / car | house / vehicle | 布尔值或null |
| creditLoanCount / totalDebtBalance | credit_num / credit_total_money | 笔数整数,金额元 |
| monthlyLoanPayment | credit_month_still_money | 元 |
| creditCardCount / creditCardTotalLimit / creditCardUsedLimit | credit_card_num / credit_card_quota / credit_card_use_quota | 已用不得大于总额;未知不填0 |
| enterpriseName / businessCode | enterprise_name / business_code | 企业名称、统一社会信用代码 |
| operatingMonths | 拟增operating_months | 保留旧operating_years供兼容,稳定准入只用规范月数 |
| documents | documents JSON | 类型、attachmentId、元信息;不接受name/path临时文件作为持久资料 |
| consentIds | consent_data快照 | 服务端读取授权事件并形成快照,不相信客户端时间或协议正文 |
| savings / creditCard / otherAssets | 拟增supplementary_data JSON | 保存旧表单补充输入,标明来源和版本,不据此猜测精确金额或准入事实 |
| step / draftVersion | step / 拟增draft_version | step控制续填;draft_version为服务端乐观锁,区别本机格式draftVersion=2 |
本机格式标记 moneyUnit=wan、draftVersion=2 继续用于旧草稿规范化;服务器修订字段在API命名为 revision,避免混淆。提交与存储DTO只接受 moneyUnit=yuan。
### 3.4 增量数据设计工作
以下为待实现的最小数据方案,由对应任务提交增量迁移和回滚说明。本次未创建任何表或字段。所有新增关系都采用服务端所有者校验;必要唯一索引以数据库保证并发一致性。
| 需求 | 复用位置 | 计划增量与约束 | 负责任务 |
| --- | --- | --- | --- |
| 草稿、标准字段、补充输入 | fa_user_info | region_code、operating_months、supplementary_data、draft_version、create_request_id;用户+create_request_id唯一,对应clientDraftId;提交后输入冻结 | ASSESS-001、ASSESS-002 |
| 任务可靠执行 | fa_user_info及现有生命周期 | task_requests JSON持久保存每次提交/重试键到参数摘要、原taskId和接受响应的映射;补task_stage、attempt_count、lease_until,按task_id抢占与失联补偿;运行元数据不改已冻结输入 | ASSESS-003、REPORT-002 |
| 私有附件 | fa_attachment | 存储可见性、文件用途、存储键、生命周期状态;路径不作为权限依据 | FILE-001、FILE-002 |
| 协议与授权事件 | fa_acticle + fa_user_info.consent_data | 协议版本不可变修订;拟增fa_user_consent保存范围、版本、授权与撤销事件,再复制评估快照 | FILE-003 |
| 第三方身份 | fa_user + token | 拟增fa_user_identity,以provider+app_id+subject唯一;不能只靠单个openid列覆盖所有微信应用 | LOGIN-001 |
| 外部查询审计 | fa_user_info关联 | 拟增fa_data_query:用户/评估/授权、渠道、请求和渠道单号、状态、受保护结果引用、来源时间、错误/费用状态 | DATA-002、DATA-003 |
| 普通助手引用报告 | fa_ai_chat | context_assessment_id可空,仅同用户;assistant.assessment_id保持NULL | CHAT-001 |
| 课程内容与进度 | fa_finance | 课程章节JSON带稳定chapterId/版本;拟增fa_user_lesson_progress,唯一(user_id,lesson_id,chapter_id) | CONTENT-001、CONTENT-003 |
| FAQ与发布资料 | fa_acticle | 扩展明确定义的FAQ类型、展示分类/排序;增加标题之外所需摘要/封面字段 | CONTENT-001 |
| 消息去重 | fa_user_message | 新增事件键;按用户+事件键唯一,批量已读按请求时的最大ID水位更新 | MSG-001、MSG-002 |
| 签到与邀请 | fa_user、fa_user_score_log | 流水写入business_type/business_id;share_code去重后加唯一索引;拟增fa_user_invitation,受邀用户仅绑定一次,禁止自邀 | POINT-002、POINT-003 |
| 反馈处理与客服 | fa_feedback | 处理状态、回复、处理人/时间;客服历史优先使用供应商能力,只保存必要关联标识 | SUPPORT-002、SUPPORT-003 |
变更只追加有明确业务目的的字段或关系。新增这些支撑表不改变“不新增独立评估、报告、匹配表”的约定。详细DDL在对应数据任务内提交审阅;已有数据需先预检、备份、回填验证,再收紧约束。不能直接重跑原空业务表限定的迁移脚本。
## 4. 模块开发任务
任务编号与[开发进度](开发进度.md)一一对应;每个模块4项,共60项。工时为剩余工程人日,含模块内开发者测试,不含外部审核等待。任务级验收是具体交付条件,模块整体还需通过M4的真实联调。
### 4.1 BASE|基础接口与环境
**目标:** 建立所有模块共用且可联调的网络、会话和环境基础。 **阶段:** M0。**剩余:** 3–5人日。
- **现状:** 页面、导航、安全区已有;共享请求已接本地API,处理Bearer、业务失败与401;全量契约、requestId及生产配置仍待完成。
- **前端:** 统一请求与账户专用请求,处理认证、业务错误、重复点击、加载/空态、401回跳;首页聚合公共内容与登录后状态。
- **后端:** 新路由显式绑定控制器和方法;解析JSON并逐字段校验,不能对结构化输入机械套用HTML转义;兼容旧Token入口但新客户端只发送Bearer。
- **数据:** 以userId建立缓存命名空间。旧匿名草稿由用户明确导入;不自动认领其他本机数据。
- **接口:** GET /home、GET /dictionaries,以及所有接口共用协议。 具体方法和字段见第5章;均按计划新增/改造处理。
- **异常与边界:** 生产缺API地址直接报配置错误;禁止静默降级成demo。断网不删除服务端事实。
| 任务编号 | 任务与交付范围 | 优先级 | 前置任务/外部条件 | 验收条件 | 人日 |
| --- | --- | --- | --- | --- | ---: |
| BASE-001 | 接口契约与字段字典:定义版本路由、响应、错误、分页、ID、金额、时间及接口状态;确认前后端字段映射。 | P0 | 无 | 接口样例可同时被 PHP 和 UTS 解析;区分占位接口与后端现有实现。 | 0.5–1 |
| BASE-002 | 统一请求与认证适配:统一共享/账户请求;后端识别 Bearer;解析业务失败和401;限定 CORS;记录 requestId。 | P0 | BASE-001 | HTTP 200 中业务失败不判成功;过期 Token 清会话;普通业务按所有者鉴权。 | 1–1.5 |
| BASE-003 | 环境与用户缓存隔离:开发/测试/生产配置;生产禁用 mock;会话和草稿等缓存按不可变 userId 隔离。 | P0 | BASE-002 | 切换账户无串数据;匿名旧草稿仅在用户明确选择导入后绑定;退出清理敏感缓存。 | 1–1.5 |
| BASE-004 | 首页和联调骨架:首页接入聚合数据、加载/空态/失败重试及登录回跳;建立接口冒烟入口。 | P1 | BASE-002、BASE-003 | 深链接与刷新正确恢复;首页无硬编码账户数据;真实未读数后续由 MSG 接入。 | 0.5–1 |
### 4.2 AUTH|账户与短信安全
**目标:** 让账户身份、个人资料和安全操作形成真实服务端闭环。 **阶段:** M1。**剩余:** 5–8人日。
- **现状:** 密码登录、本人资料读取、退出和原密码改密已接Redis会话;短信注册/登录、换绑和资料更新仍待完成,本机资料仅作回显缓存。
- **前端:** 接通短信/密码登录,读取profile;头像独立上传;安全操作成功后重新登录。
- **后端:** 验证码用途、用户和手机号绑定;换绑核验当前及新号码;修复旧免登录改绑声明;修改密码/手机号撤销全部会话。
- **数据:** 复用fa_user/fa_sms;会话改用Redis;手机号冲突由唯一约束兜底;昵称不覆盖实名。
- **接口:** /auth/sms-code、/auth/sms-login、/auth/password-login、/auth/logout、/account/profile、/account/avatar、/account/password、/account/phone。已实现与计划范围见第5章;短信仅返回未配置状态,不代表渠道已实现。
- **异常与边界:** 验证码重复/过期、账户禁用、号码占用、原手机号不可用分别返回可操作提示;失去原号需进入人工核验流程,不能跳过身份证明。
| 任务编号 | 任务与交付范围 | 优先级 | 前置任务/外部条件 | 验收条件 | 人日 |
| --- | --- | --- | --- | --- | ---: |
| AUTH-001 | 短信接入与验证码服务:供应商适配、签名模板、验证码用途/有效期/频控/核销、测试发送。 | P0 | BASE-002、EXT-SMS | 发送成功才倒计时;验证码过期、复用、跨用途和频繁发送均拒绝。 | 1–2 |
| AUTH-002 | 真实登录与会话:短信登录/首次注册、密码登录、资料读取、登出、禁用账户、旧密码散列升级。 | P0 | AUTH-001、BASE-003 | 真实账户登录成功;登录失败无假 Token;登出撤销当前 Token;旧散列兼容。 | 1.5–2 |
| AUTH-003 | 资料与头像持久化:昵称、头像 API 和文件校验;以服务端 profile 为准,迁移本机回显逻辑。 | P1 | AUTH-002 | 不同设备资料一致;昵称不覆盖评估实名;头像不依赖临时路径。 | 1–2 |
| AUTH-004 | 改密与手机换绑:登录校验、原/新手机双挑战、号码占用、事务及全部会话撤销;修复旧改绑免登录声明。 | P0 | AUTH-002 | 跨账户或单个验证码不能换绑;修改后旧 Token 全失效;新密码8–32位且含字母和数字。 | 1.5–2 |
### 4.3 LOGIN|微信与一键登录
**目标:** 使微信和运营商号码认证在获批H5场景真正可用。 **阶段:** M1。**剩余:** 5–10人日。
- **现状:** 两个入口均只弹出未开放提示;User::third仅有插件调用骨架。
- **前端:** 按微信内/普通浏览器/桌面浏览器展示受支持登录方式,处理跳出返回、取消和短信回退。
- **后端:** 服务端code/credential交换,state和回调防重放;已有账户绑定需账户证明,禁止按昵称自动合并。
- **数据:** 独立第三方身份关系以provider/appId/subject唯一;unionid仅在取得并验证适用范围后辅助绑定。
- **接口:** GET /auth/wechat/authorize、GET /auth/wechat/callback、POST /auth/oneclick、POST /auth/bind-phone。 具体方法和字段见第5章;均按计划新增/改造处理。
- **异常与边界:** 渠道授权失败不创建半登录会话;不支持的网络或浏览器回到短信;只有支持矩阵中场景通过才记完成。
| 任务编号 | 任务与交付范围 | 优先级 | 前置任务/外部条件 | 验收条件 | 人日 |
| --- | --- | --- | --- | --- | ---: |
| LOGIN-001 | 登录渠道能力与身份方案:确认微信内/普通H5方式、号码认证H5能力;定义渠道身份与手机号账户绑定方案。 | P0 | BASE-001、EXT-WX、EXT-ONE | 形成渠道支持矩阵、回调和身份唯一键方案;不以昵称/openid跨应用猜测同一账户。 | 1–2 |
| LOGIN-002 | 微信登录接入:授权发起、state校验、code服务端交换、首次手机号绑定与已有账户冲突处理。 | P0 | LOGIN-001、AUTH-002、EXT-WX | 授权取消和重放安全失败;绑定现有账户需原账户证明;获批H5场景真机通过。 | 1.5–3 |
| LOGIN-003 | 一键登录接入:号码认证SDK/网页流程、服务端验证、能力检测、账号绑定、短信回退。 | P0 | LOGIN-001、AUTH-002、EXT-ONE | 按渠道支持的网络/运营商/浏览器验收;客户端自报手机号不能登录。 | 1.5–3 |
| LOGIN-004 | 身份渠道联调:微信内、普通移动H5和桌面H5分别验证;解绑/冲突与失败恢复按已选渠道能力实现。 | P0 | LOGIN-002、LOGIN-003 | 身份关联不误合并账户;重放无效;不支持场景有清晰短信回退。 | 1–2 |
### 4.4 FILE|资料与授权
**目标:** 资料可安全持久化,授权可追溯且可以撤销。 **阶段:** M1。**剩余:** 4–7人日。
- **现状:** 前端仅本机预览;documents和consent_data已预留;现有上传默认公共用途。
- **前端:** 资料类型、上传进度/失败重试、已上传列表、协议全文、授权状态和撤销入口。
- **后端:** 私有文件服务、归属/MIME/内容/大小校验、鉴权下载、协议版本和授权事件;评估提交时由服务端形成快照。
- **数据:** 复用附件表与评估JSON;协议修订和独立授权事件保留历史;文件保留/删除期限在DEC-PRIVACY定稿。
- **接口:** POST /documents、GET/DELETE /documents/:id、GET /agreements/:code、GET/POST /consents、POST /consents/:id/revoke。 具体方法和字段见第5章;均按计划新增/改造处理。
- **异常与边界:** 临时路径、他人附件、无效授权全部拒绝;可选资料不强迫提交;撤销后停止新查询,历史留存按已确认规则处理。
| 任务编号 | 任务与交付范围 | 优先级 | 前置任务/外部条件 | 验收条件 | 人日 |
| --- | --- | --- | --- | --- | ---: |
| FILE-001 | 私有资料存储方案:区分公开头像与私有证件;确定文件白名单、大小限制、存储密钥、附件元数据与保留策略。 | P0 | BASE-001、EXT-STORAGE、DEC-PRIVACY | 私有文件不在公开web目录;形成文件用途/权限/生命周期配置清单。 | 1–1.5 |
| FILE-002 | 私有上传与下载:持久附件ID、归属校验、大小/MIME/内容校验、鉴权读取、失败重试和删除策略。 | P0 | FILE-001、AUTH-002 | 无登录/跨用户不能读取;客户端临时路径不能提交为附件;失效链接不可访问。 | 1–2 |
| FILE-003 | 协议与授权版本:协议全文展示、同意/撤销事件、范围与协议版本、历史快照及外部授权凭据边界。 | P0 | FILE-001、AUTH-002、DEC-PRIVACY | 提交可追溯所见协议版本;撤销阻止新查询;前端布尔勾选不冒充第三方授权。 | 1–2 |
| FILE-004 | 资料授权页面联调:替换选图预览和占位协议;展示上传/授权状态;拒绝、取消、断网恢复。 | P0 | FILE-002、FILE-003 | 仅已上传且有权访问的附件入评估;拒绝可选授权仍可自填,不显示已查询。 | 1–1.5 |
### 4.5 ASSESS|评估与融资档案
**目标:** 个人与企业评估可云端续填、重复评估、可靠提交并查看历史。 **阶段:** M2。**剩余:** 5–8人日。
- **现状:** 表单、金额规范化和内部生命周期已有;无草稿API和真实提交链路。
- **前端:** 草稿revision控制、字段映射、服务端校验错误定位;保留assessmentId在所有后续路由。
- **后端:** 认证用户创建草稿,保存时条件更新;完整校验后调用生命周期服务;提交后不可修改,修改需求转新评估。
- **数据:** 使用fa_user_info和唯一评估会话;前端本机版本与服务端revision区分;taskId用于任务尝试。
- **接口:** POST/GET /assessments、GET/PUT /assessments/:id、POST /assessments/:id/submit、GET /assessments/:id/status、POST /assessments/:id/retry。 具体方法和字段见第5章;均按计划新增/改造处理。
- **异常与边界:** 草稿版本冲突提示重新读取;重复提交返回同一任务;跨用户记录统一404;失败重试保持原输入。
| 任务编号 | 任务与交付范围 | 优先级 | 前置任务/外部条件 | 验收条件 | 人日 |
| --- | --- | --- | --- | --- | ---: |
| ASSESS-001 | 评估字段与增量模型:确定枚举、地区代码、经营月数、附件引用、授权快照和前后端完整字段映射。 | P0 | BASE-001 | 未知与0区分;个人/企业必填口径明确;新增字段列独立迁移,不重建结果表。 | 1–2 |
| ASSESS-002 | 云端草稿与档案:新建/保存/读取草稿、版本冲突、列表、复制既往资料新建评估及用户隔离。 | P0 | ASSESS-001、AUTH-002 | 同用户可有多次同类型评估;不同账号不能读写;历史输入不被编辑覆盖。 | 1–2 |
| ASSESS-003 | 提交与生命周期API:接入submit/owned/retry,服务端校验;持久保存每次提交/重试请求键、参数摘要与taskId,返回原接受响应。 | P0 | ASSESS-002、FILE-004 | 同requestId只提交一次;多次重试后回放旧键仍返回原taskId;旧任务不可回写,完成方法不对客户端开放。 | 1.5–2 |
| ASSESS-004 | 评估全页面联调:评估/确认/分析/档案传递assessmentId;处理金额、退出续填、并发及历史跳转。 | P0 | ASSESS-003 | 同一评估ID贯穿流程;0.800001万元精确提交为8000.01元;处理中不显示假报告。 | 1.5–2 |
### 4.6 DATA|外部征信或数据查询
**目标:** 把已授权的真实外部数据纳入评估,且来源与查询过程可追溯。 **阶段:** M2。**剩余:** 6–12人日。
- **现状:** 只有授权意向和预留JSON;尚无真实数据服务。
- **前端:** 展示实际可用查询种类、授权状态、查询中/成功/失败/拒绝/撤销,不把选择框当查询完成。
- **后端:** 服务商适配、签名、异步状态、回调验签/去重、对账、费用状态;原始证件标识只在必要服务端流程使用。
- **数据:** 查询记录与评估/用户/授权绑定;必要结果加密或私有存储;输入冻结后只追加查询事件。
- **接口:** GET/POST /assessments/:id/data-queries、GET /data-queries/:id、POST /callbacks/data/:provider。 具体方法和字段见第5章;均按计划新增/改造处理。
- **异常与边界:** 超时收费不明先查渠道状态;未取得或已撤销授权不新调用;缺失数据标未知,不能伪装征信正常。
| 任务编号 | 任务与交付范围 | 优先级 | 前置任务/外部条件 | 验收条件 | 人日 |
| --- | --- | --- | --- | --- | ---: |
| DATA-001 | 查询渠道与范围定稿:确认实际可开通数据产品、个人/企业范围、授权凭证、测试样例、计费与返回解释。 | P0 | BASE-001、EXT-DATA、DEC-QUERY | 每种首发授权都有明确数据来源和支持范围;缺失渠道记阻塞并更新估算。 | 1–2 |
| DATA-002 | 授权查询适配与记录:服务端发起、请求唯一键、渠道凭证隔离、查询事件持久化及assessmentId绑定。 | P0 | DATA-001、FILE-003、ASSESS-003 | 无有效授权不调用;重复发起不重复建本地请求;不得将他人结果用于本次评估。 | 2–4 |
| DATA-003 | 回调与结果标准化:验签、防重放、回调幂等、主动查询对账、结果字段/来源/时间映射与受限留存。 | P0 | DATA-002 | 伪造/过期回调拒绝;迟到结果不覆盖冻结输入;查询失败不转换成零风险。 | 2–4 |
| DATA-004 | 异常与渠道端到端验收:超时、撤销、拒绝、余额不足、重复回调、重查和H5往返恢复。 | P0 | DATA-003 | 成功和失败均可追踪;超时收费不明先对账;各已承诺首发场景有真实渠道证据。 | 1–2 |
### 4.7 PRODUCT|产品库与匹配规则
**目标:** 可运营的产品库与可解释、可复现的个性化匹配。 **阶段:** M2。**剩余:** 6–9人日。
- **现状:** 范围字段、版本、上架校验等基础已有;补充JSON尚无执行器,前端是demo产品。
- **前端:** 拆Product与MatchResult;显示范围而非单点额度/利率;保持历史快照并提示当前产品状态。
- **后端:** 确定性规则、白名单操作符、类型校验、版本化排序;只推荐状态/有效期/地区及硬条件满足的产品。
- **数据:** 产品独立字段+regions/eligibility_rules;个性化分数和理由放match_result,快照含产品版本。
- **接口:** GET /products、GET /products/:id、GET /assessments/:id/matches;后台沿用产品CRUD并补运营校验。 具体方法和字段见第5章;均按计划新增/改造处理。
- **异常与边界:** 暂无匹配是合法完成结果;规则未知不执行任意表达式;产品下架不删除历史快照。
| 任务编号 | 任务与交付范围 | 优先级 | 前置任务/外部条件 | 验收条件 | 人日 |
| --- | --- | --- | --- | --- | ---: |
| PRODUCT-001 | 规则字典与评分口径:统一硬准入、未知值处理、补充规则白名单、匹配评分和理由;准备人工确认样例。 | P0 | ASSESS-001、DEC-MATCH | 版本化规则与样例可审阅;地区/经营时间可确定比较;规则不执行任意表达式。 | 1.5–2 |
| PRODUCT-002 | 准入与排序执行器:筛选上架/有效期/地区/对象及硬条件;执行补充规则、排序并输出可解释理由。 | P0 | PRODUCT-001 | 下架/过期/硬条件不符不推荐;缺失条件不当已满足;同数据同版本结果稳定。 | 2–3 |
| PRODUCT-003 | 产品后台与公共查询:补齐规则编辑校验、发布预检、产品列表/详情与范围字段;录入真实测试产品。 | P0 | PRODUCT-001、BASE-002、DEC-PRODUCT | 上架完整校验;版本递增;公共详情无用户分数;真实产品有运营确认依据。 | 1.5–2 |
| PRODUCT-004 | 匹配页面与快照:独立MatchResult类型、额度/期限/利率范围、匹配列表排序、产品分享与申请准备。 | P0 | PRODUCT-002、PRODUCT-003、ASSESS-004 | 按assessmentId显示;参数历史快照稳定;当前不可用产品清晰提示;无匹配有说明。 | 1–2 |
### 4.8 REPORT|AI任务、报告与历史
**目标:** 把真实输入、查询依据和产品匹配生成可追溯的DeepSeek报告。 **阶段:** M2。**剩余:** 8–12人日。
- **现状:** report_data/match_result及complete服务已有;队列配置存在但没有业务Job,页面为计时和静态分数。
- **前端:** 轮询真实状态、刷新恢复、失败重试;渲染数据驱动维度、来源时间和版本;查看历史。
- **后端:** DeepSeek适配、结构校验、任务抢占/超时/漏投补偿、输入最小化、用量记录;在事务中同时落报告、匹配和成功状态。
- **数据:** result_version/rule_version/model与产品快照分别记录;模型输出不能覆盖金额、硬准入或数据来源。
- **接口:** GET /assessments/:id/status、GET /assessments/:id/report;内部worker调用complete/fail,不公开完成接口。 具体方法和字段见第5章;均按计划新增/改造处理。
- **异常与边界:** 模型输出错误/配额不足/队列故障进入可追踪失败;重试不允许旧taskId回写;供应商超时可能计费,不承诺外部零重复收费。
| 任务编号 | 任务与交付范围 | 优先级 | 前置任务/外部条件 | 验收条件 | 人日 |
| --- | --- | --- | --- | --- | ---: |
| REPORT-001 | DeepSeek适配与配额:配置模型标识、服务端密钥读取、超时、结构化输出校验、配额预留和消耗记录。 | P0 | BASE-002、EXT-AI、DEC-AI | 模型可配置;密钥不下发;失败与使用量可追踪;数值事实不被模型改写。 | 1.5–2 |
| REPORT-002 | 异步任务与补偿:think-queue/Redis业务Job、提交后派发、漏投扫描、租约/超时、重试和任务隔离。 | P0 | ASSESS-003、REPORT-001 | 派发失败/worker重启可恢复;旧taskId不能落结果;重复消费不重复完成。 | 2.5–4 |
| REPORT-003 | 报告生成与持久化:冻结已授权查询结果和产品匹配快照,调用AI解释,原子写report_data/match_result及状态。 | P0 | REPORT-002、DATA-004、PRODUCT-004、DEC-MATCH | 结构无效不得记成功;评分/证据来源明确;产品改动不改历史;部分资料缺失有标注。 | 2–3 |
| REPORT-004 | 分析报告历史联调:状态轮询、刷新恢复、失败重试、真实雷达维度、历史详情和模型版本展示。 | P0 | REPORT-003、ASSESS-004 | 个人/企业各完成一条真实闭环;分析失败不会跳成功报告;再次评估生成独立记录。 | 2–3 |
### 4.9 CHAT|AI助手
**目标:** 可恢复、隔离且能基于本人评估解释的AI会话。 **阶段:** M3。**剩余:** 4–7人日。
- **现状:** 页面消息仅内存,回复固定;消息结构、序号和用量字段已预留。
- **前端:** 创建/切换/恢复会话、分页历史、发送状态、异步回复轮询与重试;保留requestId。
- **后端:** DeepSeek对话、上下文裁剪、所有者校验、消息和本地额度幂等;用户输入不可触发任意管理操作。
- **数据:** 普通scene的assessment_id保持NULL;context_assessment_id只引用本人可访问评估;工具结果记录来源。
- **接口:** POST/GET /assistant/chats、GET/POST /assistant/chats/:id/messages、GET /assistant/messages/:id、POST /assistant/messages/:id/retry。 具体方法和字段见第5章;均按计划新增/改造处理。
- **异常与边界:** 失败消息保留错误和可重试状态;切换账号不带上旧会话;渠道取消不能保证停止已发生计费,一期不要求生成中强制取消。
| 任务编号 | 任务与交付范围 | 优先级 | 前置任务/外部条件 | 验收条件 | 人日 |
| --- | --- | --- | --- | --- | ---: |
| CHAT-001 | 会话与消息API:普通会话创建/列表/消息分页/归属;明确可引用评估上下文且不占用正式assessment_id。 | P1 | AUTH-002、BASE-001 | 用户只能读自身会话;评估会话与普通会话分离;重进恢复历史。 | 1–1.5 |
| CHAT-002 | DeepSeek对话调用:上下文裁剪、敏感信息最小化、消息幂等、异步回复与本地配额记录。 | P1 | CHAT-001、REPORT-001、REPORT-002 | 同请求不重复建消息或记本地消耗;错误回复不标成功;不会发送无关证件资料。 | 1–2 |
| CHAT-003 | 助手页面接入:chatId/requestId、历史加载、发送状态、失败重试、由报告进入解释上下文。 | P1 | CHAT-002、REPORT-004 | 切换会话不串上下文;超时保留可重试消息;重进页面能继续。 | 1–2 |
| CHAT-004 | 对话异常与额度验收:并发消息、重复点击、额度耗尽、供应商超时及失效会话。 | P1 | CHAT-003 | 配额并发不超扣;供应商计费不明可对账;仅真实成功回复显示完成。 | 1–1.5 |
### 4.10 CONTENT|内容、课堂、搜索与FAQ
**目标:** 后台发布的内容、图文课程和FAQ可被前端检索阅读,进度跨设备一致。 **阶段:** M3。**剩余:** 4–6人日。
- **现状:** 前端本地资讯/课程/FAQ,课程进度为设备级键;后台存在表和CRUD但发布流程不完整。
- **前端:** 新闻、文章、课堂、课程页接真实分页/详情;处理404/下架,搜索竞态和章节完成状态。
- **后端:** 发布/下架、富文本清洗、分类与首页推荐、搜索和FAQ;稳定章节ID与进度幂等。
- **数据:** 复用fa_acticle/fa_finance;章节数据结构化;用户章节完成关系独立存储;不引入视频和付费课程。
- **接口:** /content/articles、/content/lessons、/content/lessons/:id/progress、/content/search、/help/faqs。 具体方法和字段见第5章;均按计划新增/改造处理。
- **异常与边界:** 未发布内容不可访问;新增/重排章节不沿用索引作为进度ID;搜索慢响应不得覆盖新查询。
| 任务编号 | 任务与交付范围 | 优先级 | 前置任务/外部条件 | 验收条件 | 人日 |
| --- | --- | --- | --- | --- | ---: |
| CONTENT-001 | 内容模型与发布后台:补齐文章/课程结构、稳定章节ID、分类、封面/摘要、发布状态、FAQ类型及协议入口。 | P1 | BASE-001 | 后台可发布/下架;章节重排不换ID;草稿不可公开;FAQ可维护。 | 1–1.5 |
| CONTENT-002 | 列表详情与首页推荐:新闻/文章/课堂/详情真实API、分页、富文本清洗、缺失内容与首页推荐接入。 | P1 | CONTENT-001、BASE-004 | 首页/列表/详情ID一致;无模拟兜底内容;下架详情有明确不可用状态。 | 1–1.5 |
| CONTENT-003 | 云端课程进度:按用户/课程/章节记录完成,章节版本兼容、跨设备恢复和本机缓存策略。 | P1 | CONTENT-002、AUTH-002 | 用户隔离;重复完成幂等;章节排序变化不串进度;跨设备一致。 | 1–1.5 |
| CONTENT-004 | 搜索与FAQ联调:真实全文字段检索、分类/分页、请求竞态、FAQ读取、无结果和断网恢复。 | P1 | CONTENT-002 | 旧搜索响应不覆盖新关键词;返回ID可开详情;FAQ与实际业务行为一致。 | 1–1.5 |
### 4.11 MSG|消息中心
**目标:** 将真实业务通知、阅读状态与首页红点保持一致。 **阶段:** M3。**剩余:** 2–4人日。
- **现状:** 前端静态通知和本机已读;表含read_at与业务引用,没有用户端业务API。
- **前端:** 分类分页、未读数、已读/全部已读、与正确评估记录跳转。
- **后端:** 在业务事务完成后可靠生成通知,事件唯一键去重;读取和更新均限定当前用户。
- **数据:** 复用fa_user_message,补事件唯一键;is_read和read_at同步;全部已读采用最大ID水位。
- **接口:** GET /messages、GET /messages/unread-count、POST /messages/:id/read、POST /messages/read-all。 具体方法和字段见第5章;均按计划新增/改造处理。
- **异常与边界:** 队列重试不重复通知;全部已读期间新增消息仍未读;本期站内消息不等同App推送。
| 任务编号 | 任务与交付范围 | 优先级 | 前置任务/外部条件 | 验收条件 | 人日 |
| --- | --- | --- | --- | --- | ---: |
| MSG-001 | 通知事件与去重:定义评估结果等事件、通知去重键及写入时机,补齐唯一性保障。 | P1 | BASE-001、REPORT-004 | 重复任务不会重复通知;成功/失败通知关联正确assessmentId。 | 0.5–1 |
| MSG-002 | 消息读写API:当前用户分页/分类/未读数/单条已读/全部已读,read_at与is_read一致。 | P1 | MSG-001、AUTH-002 | 跨用户读写拒绝;已读重复操作幂等;列表/计数一致。 | 0.5–1 |
| MSG-003 | 消息页面与首页红点:替换静态通知与固定红点,处理业务跳转、空态、刷新及批量已读。 | P1 | MSG-002、BASE-004 | 换设备状态一致;打开通知到对应历史;关联记录不可用有提示。 | 0.5–1 |
| MSG-004 | 事件消息验收:事件重复、失败重试、分页期间新通知和并发全部已读测试。 | P1 | MSG-003 | 全部已读不误吞操作后新增消息;通知去重和未读数通过数据库核对。 | 0.5–1 |
### 4.12 POINT|积分、任务与邀请
**目标:** 积分与真实任务事件一致,邀请可归因且奖励不会重复发放。 **阶段:** M3。**剩余:** 4–7人日。
- **现状:** 本机签到+10,固定体验邀请码;底层score方法未使用业务唯一键。
- **前端:** 真实积分、流水、服务端签到状态、邀请码和任务进度,显示已发布规则版本。
- **后端:** 积分事务和并发锁、日期与任务幂等、防自邀、可信事件奖励;错误需向业务层传播。
- **数据:** 复用score及score_log,明确business_type/business_id;邀请码唯一、受邀用户一次绑定。
- **接口:** GET /points、GET /points/ledger、POST /points/check-in、GET /points/tasks、GET /invitations、POST /invitations/bind。 具体方法和字段见第5章;均按计划新增/改造处理。
- **异常与边界:** 默认保留每日签到10分作为待业务确认建议;其他奖励金额不编造。复制文案或客户端上报完成不直接发积分。
| 任务编号 | 任务与交付范围 | 优先级 | 前置任务/外部条件 | 验收条件 | 人日 |
| --- | --- | --- | --- | --- | ---: |
| POINT-001 | 积分规则定稿:确认签到、资料完成、邀请奖励的条件/次数/额度、业务时区和权益范围。 | P1 | BASE-001、DEC-POINT | 形成版本化活动规则;复制/分享不等于奖励;未确认奖励规则不得上线。 | 0.5–1 |
| POINT-002 | 积分账户与签到:事务更新score与流水、业务唯一键、服务端日期签到、余额/流水查询。 | P1 | POINT-001、AUTH-002 | 同用户同日并发只加一次;流水与积分一致;错误不静默吞掉。 | 1.5–2 |
| POINT-003 | 邀请码与邀请关系:生成唯一邀请码、首次有效绑定、防自邀、可信奖励事件与发放幂等。 | P1 | POINT-002 | 客户端不能自报完成奖励;重复/自邀拒绝;已有关系不随分享覆盖。 | 1–2 |
| POINT-004 | 任务积分页面联调:真实余额、流水、签到状态、个人邀请码、任务状态和规则说明。 | P1 | POINT-003、ASSESS-004 | 真实到账后才提示成功;跨账户/跨设备一致;邀请复制不立即发奖励。 | 1–2 |
### 4.13 SUPPORT|反馈与人工客服
**目标:** 帮助页既能提交可追踪反馈,也能真实到达人工服务。 **阶段:** M3。**剩余:** 3–6人日。
- **现状:** FAQ可展开,客服仅未开放提示,反馈表有基础CRUD。
- **前端:** 反馈表单/附件/记录/回复;客服在线/排队/离线状态及失败回退。
- **后端:** 反馈归属与后台处理、服务端访客签名、客服会话关联;外部系统消息遵循渠道身份规则。
- **数据:** 复用fa_feedback补处理结果;不把反馈图片当公共文件;客服历史优先由供应商维护。
- **接口:** POST/GET /feedback、GET /feedback/:id、POST /support/session。 具体方法和字段见第5章;均按计划新增/改造处理。
- **异常与边界:** 无坐席能留言且后台有责任人;SDK失败提示可使用站内反馈;退出切换账号清理客服身份。
| 任务编号 | 任务与交付范围 | 优先级 | 前置任务/外部条件 | 验收条件 | 人日 |
| --- | --- | --- | --- | --- | ---: |
| SUPPORT-001 | 客服方案与运营准备:选定H5客服、身份签名、坐席/工作时间、离线策略、反馈状态及责任人。 | P0 | BASE-001、EXT-CS | 具有可测试坐席和离线处理渠道;确认SDK/隐私及首发支持范围。 | 0.5–1 |
| SUPPORT-002 | 反馈提交与处理闭环:帮助页增加反馈表单/记录;附件权限、后台状态/回复、用户查看处理结果。 | P1 | AUTH-002、FILE-002 | 反馈可提交到后台并回复;仅当前用户可查;附件无越权。 | 1–2 |
| SUPPORT-003 | H5人工客服接入:服务端签发访客身份、前端打开客服、在线/排队/离线、失败回退。 | P0 | SUPPORT-001、AUTH-002、EXT-CS | 真实到达人工坐席;不由前端伪造用户身份;离线可留言并有处理路径。 | 1–2 |
| SUPPORT-004 | 客服反馈联调:普通/微信内H5、账户切换、关闭重开、无坐席、SDK失败和消息记录。 | P0 | SUPPORT-002、SUPPORT-003、CONTENT-004 | 会话无跨账户泄漏;客户可获回复;供应商不支持项准确提示。 | 0.5–1 |
### 4.14 ADMIN|后台运营与排障
**目标:** 让运营人员能管理业务、定位故障并执行受控操作。 **阶段:** M3。**剩余:** 3–5人日。
- **现状:** 通用管理员权限和业务CRUD基础已有;不能据此认为完整运营流程已完成。
- **前端:** FastAdmin后台增加任务、模型额度、查询状态和运营入口,非移动端新增主导航。
- **后端:** 角色授权、敏感字段掩码、任务/查询定位、受控重试和审计;不允许手改已提交评估输入及正式结果。
- **数据:** 复用管理员fa_auth_*;保留被删除会员组/规则表的删除状态;不恢复历史无关业务表。
- **接口:** 沿用后台路由与权限体系;不将管理查询、complete/fail或密钥管理暴露到用户API。 具体方法和字段见第5章;均按计划新增/改造处理。
- **异常与边界:** 权限不足不可越权;重试需要有效状态和当前taskId;日志不得输出证件或模型密钥。
| 任务编号 | 任务与交付范围 | 优先级 | 前置任务/外部条件 | 验收条件 | 人日 |
| --- | --- | --- | --- | --- | ---: |
| ADMIN-001 | 菜单与角色权限:将新增运营能力纳入现有管理员权限;明确运营/客服/技术可见字段。 | P0 | BASE-001 | 管理员权限最小化;无需恢复已删除会员组/规则;敏感字段继续掩码。 | 0.5–1 |
| ADMIN-002 | 任务与查询排障:按assessmentId/taskId/requestId查进度/错误、人工重试/对账入口及操作审计。 | P1 | REPORT-004、DATA-004、ADMIN-001 | 可定位失败原因;重试仍走生命周期和幂等;不允许后台手改正式结果。 | 1–1.5 |
| ADMIN-003 | 模型额度与服务检查:DeepSeek配置、密钥状态、用户额度、调用消耗、队列/存储/短信状态检查。 | P1 | REPORT-001、CHAT-004、ADMIN-001 | 密钥不回显;配置失败可定位;本地消耗与渠道对账有入口。 | 1–1.5 |
| ADMIN-004 | 运营交接验收:真实产品、内容、课程、积分、消息、反馈及客服操作演练,形成操作说明。 | P1 | ADMIN-002、ADMIN-003、CONTENT-004、MSG-004、POINT-004、SUPPORT-004 | 运营可独立发布/下架/处理反馈及定位异常;业务字段无占位数据。 | 0.5–1 |
### 4.15 RELEASE|联调、测试与发布
**目标:** 使一期全量范围在真实H5和实际服务渠道完成可复验发布。 **阶段:** M4。**剩余:** 7–11人日。
- **现状:** 此前基础测试与Web/Android编译通过,仅证明对应基础范围;未有全业务上线验收。
- **前端:** 普通桌面/手机/微信内H5、上传/键盘/回跳/弱网、深链接和版本更新回归。
- **后端:** 全链路、并发、权限、真实渠道、迁移和恢复;部署队列守护及错误/费用监控。
- **数据:** 使用脱敏测试数据与独立环境;新增迁移先预检/备份/演练;禁止用旧备份覆盖新业务数据。
- **接口:** 对本章所有一期API及渠道执行自动与手动验收,记录版本、环境和证据。 具体方法和字段见第5章;均按计划新增/改造处理。
- **异常与边界:** 未完成任一必做能力不能标首发完成;生产配置不允许模拟Token、固定报告和占位协议。
| 任务编号 | 任务与交付范围 | 优先级 | 前置任务/外部条件 | 验收条件 | 人日 |
| --- | --- | --- | --- | --- | ---: |
| RELEASE-001 | 核心与权限回归:完整个人/企业流程、跨用户攻击、金额边界、幂等、产品变更及并发用例。 | P0 | REPORT-004、LOGIN-004、AUTH-004、ADMIN-004 | 关键用例通过且有证据;无阻断或高危未解决缺陷;所有一期模块进入验收。 | 2–3 |
| RELEASE-002 | H5与第三方真实验收:普通手机/桌面/微信内浏览器,键盘/上传/回跳/断网/弱网及全部渠道成功失败验证。 | P0 | RELEASE-001 | 真实渠道与目标浏览器矩阵通过;不是仅编译通过或mock成功。 | 2–3 |
| RELEASE-003 | 部署与恢复演练:分环境配置、增量迁移预检、HTTPS、队列守护/告警、备份恢复、发布回滚手册。 | P0 | RELEASE-001、ADMIN-003、EXT-DEPLOY | 干净测试环境可部署;有数据环境升级可恢复;不重跑旧空表限定迁移。 | 1.5–2.5 |
| RELEASE-004 | 首发验收与发布记录:核对范围/外部依赖/验收证据,正式发布检查,发布后冒烟及回退条件。 | P0 | RELEASE-002、RELEASE-003 | 所有一期必做项已验收;生产无demo数据/占位协议/模拟Token;记录实际版本和时间。 | 1.5–2.5 |
## 5. 目标 API 契约
### 5.1 实现状态与通用规则
**本章为目标契约;已实现子集为密码登录、本人资料读取、退出、原密码改密,短信仅实现503未配置响应,其余仍待开发。** 已有FastAdmin通用控制器,现新增api/controller/v1/Base.php和Session.php;未列为已实现的前端同名方法仍是接口占位。AUTH可复用通用服务实现,其他模块需新增业务控制器及路由。旧接口保留兼容,新H5统一走版本前缀;管理后台保持原路由。
| 方法与路径(省略/api/v1) | 当前实现状态 | 本轮边界 |
| --- | --- | --- |
| POST /auth/password-login | 已实现并本地联调 | 既有手机号账户、密码验证、旧散列升级、Redis会话;沿用账户失败次数限制,IP级频控待补 |
| GET /account/profile | 已实现并HTTP验证 | 返回userId/nickname/avatarUrl/phoneMasked;完整security字段与资料修改待补 |
| POST /auth/logout | 已实现并本地联调 | 撤销当前Token;成功或已401清本机会话,网络失败保留以便重试撤销 |
| PUT /account/password | 原密码方式已实现并验证 | verification=password;成功撤销全部会话;sms方式返回503 |
| POST /auth/sms-code、/auth/sms-login | 仅未配置响应 | HTTP 503、data.errorCode=SMS_NOT_CONFIGURED,无短信发送或注册 |
| 其余本章接口 | 计划新增 | 当前未配置的v1路由返回JSON 404,不判业务成功 |
| 项目 | 约定 |
| --- | --- |
| API根地址 | 环境配置为https://业务域名/api/v1;前端服务函数只传后缀路径 |
| 鉴权 | Authorization: Bearer会话令牌;用户ID来自认证上下文;管理端继续使用管理员权限 |
| 请求体 | 普通写请求application/json;文件multipart/form-data;回调按供应商要求读取原始体后验签 |
| ID | API统一字符串;DB仍用原主键类型;客户端不对ID做数值运算 |
| 成功包装 | {code:1,msg:string,time:Unix秒,data:对象或数组};HTTP 200/201/202与实际行为一致 |
| 失败包装 | code非1;data包含errorCode、可选fieldErrors、requestId;前端不把HTTP 200业务失败当成功 |
| HTTP错误 | 400格式错误,401登录失效,403操作无权限,404记录不存在/不属于本人,409状态/版本冲突,422字段错误,429频控,503依赖不可用 |
| 错误实现 | FastAdmin默认状态推导不足时显式传statuscode;兼容旧接口错误体但新接口使用稳定errorCode |
| 分页 | page默认1,pageSize默认20、最大50;items/total/page/pageSize;稳定排序含唯一ID |
| 列表范围 | 公开列表只取已发布有效内容;用户列表查询始终带user_id |
| 金额与比例 | 元字符串两位小数;利率为百分数;月数整数;展示时通过统一金额服务换算 |
| 幂等 | 表格注明业务键;同键不同业务参数返回409;成功重复调用返回同一业务结果;前端显式重试复用原键 |
| 日志 | 可关联requestId/assessmentId/taskId;禁止输出令牌、密码、身份证、证件路径和模型密钥 |
写接口不采用“遇到任何超时自动重发”。只有明确具有幂等键或条件更新保障的操作可以安全重试;短信、授权交换和收费查询需确认服务端/供应商状态。
成功示例(模拟标识仅用于说明字段,不是实际测试数据):
```json
{
"code": 1,
"msg": "评估已提交",
"time": 1790000000,
"data": {
"assessmentId": "123",
"taskId": "0123456789abcdef0123456789abcdef",
"status": "processing",
"stage": "queued"
}
}
```
失败示例:
```json
{
"code": 0,
"msg": "请检查填写内容",
"time": 1790000000,
"data": {
"errorCode": "VALIDATION_ERROR",
"fieldErrors": {"monthlyIncome": "金额必须大于0,单位为元"},
"requestId": "req_example_0001"
}
}
```
统一错误至少覆盖:VALIDATION_ERROR、AUTH_REQUIRED、RESOURCE_NOT_FOUND、STATE_CONFLICT、REVISION_CONFLICT、IDEMPOTENCY_CONFLICT、CONSENT_REQUIRED、ATTACHMENT_FORBIDDEN、RATE_LIMITED、QUOTA_EXCEEDED、PROVIDER_UNAVAILABLE、PROVIDER_RESULT_INVALID。面向用户的message使用中文;内部异常堆栈不直接返回。
### 5.2 主要数据类型
| 类型 | 字段与约束 |
| --- | --- |
| Session | token:string、userId:string、expiresAt:ISO时间、profile:Profile;令牌只在认证响应出现 |
| Profile | userId、nickname:string(2–20字符)、avatarUrl:string或null、phoneMasked、security状态;手机号完整值仅在必要且认证的操作中使用 |
| AssessmentDraft | assessmentId、type:personal/enterprise、revision:int、step:0–4、moneyUnit:yuan、第3.3节表单字段、documents数组、consentIds数组;未填字段null |
| AssessmentStatus | assessmentId、taskId、status:draft/processing/succeeded/failed、stage:string或null、submittedAt/completedAt、errorCode、可显示错误、canRetry |
| Document | attachmentId、type、name、mime、sizeBytes、status、createdAt;无永久公开证件URL |
| Consent | consentId、scope、agreementCode、agreementVersion、grantedAt、revokedAt;第三方授权完成状态独立于本地意向 |
| Product | id、name、bankName、objects数组、amountMinYuan/amountMaxYuan:string、termMinMonths/termMaxMonths:int、annualRateMin/annualRateMax:string、regions、version、说明字段、当前可用状态 |
| MatchResult | productId、rank:int、score:number、reasons:string[]、productSnapshot:ProductSnapshot;score范围与计算由DEC-MATCH定稿 |
| ProductSnapshot | 产品ID/名称/版本、额度元字符串、期限月、年化百分数及必要准入说明;不可用当前产品字段覆盖 |
| Report | resultVersion:int、ruleVersion:string、assessmentId、generatedAt、modelCode、overallScore:number或null、dimensions数组、summary、suggestions、dataGaps、sourceRefs |
| ReportDimension | code、label、score:number或null、explanation、evidenceRefs;数据不足明确null,区分用户自填和渠道核验 |
| Chat | chatId、title、scene:assistant、contextAssessmentId:string或null、updatedAt |
| ChatMessage | messageId、chatId、sequence:int、role、status、text、requestId、errorCode、createdAt;模型用量按权限返回摘要 |
| Article/Lesson | id、title、summary、imageUrl、category、publishedAt、version;课程chapters包含稳定chapterId、title、body |
| Message | id、type、title、content、isRead、readAt、createdAt、target:{type,id}或null |
| PointsLedger | id、delta:int、before:int、after:int、businessType、businessId、memo、createdAt |
| Feedback | id、content、attachmentIds、status、reply、createdAt、handledAt;用户端不暴露无关管理信息 |
数据库JSON采用已有snake_case字段时,由DTO统一转换为camelCase;文档中的API对象不是直接暴露数据库行。例如持久match_result仍含product_id/rank/score/reasons/product_snapshot,API转换为productId/productSnapshot。
### 5.3 基础、账户与第三方登录接口
以下表格均省略 /api/v1 前缀。公开表示可匿名,其他接口须登录,回调须通过指定渠道校验。
| 方法与路径 | 权限 | 入参 | data响应 | 错误/幂等要点 |
| --- | --- | --- | --- | --- |
| GET /home | 公开,登录后附本人摘要 | 无 | banners、recommendations、assessmentSummary、unreadCount | 未登录的个人字段null;内部聚合失败不伪造数据 |
| GET /dictionaries | 公开 | version可选 | 字典版本、地区、枚举和表单选项 | 支持版本缓存;不能读取已删除fa_area |
| POST /auth/sms-code | 公开;绑定用途需有效bindingTicket | phone:string、purpose:login/bind-phone、bindingTicket仅绑定时必填、requestId | challengeId、retryAfterSeconds | 频控;同requestId不重复发送;绑定挑战关联bindingTicket与目标手机号,登录验证码不可用于绑定 |
| POST /auth/sms-login | 公开 | phone、challengeId、code:string | Session | 验证码核销;手机号唯一;错误不暴露账户细节 |
| POST /auth/password-login | 公开 | phone、password:string | Session | 限流;旧密码登录遵循原验证,成功后升级散列 |
| POST /auth/logout | 登录 | 无 | 空对象 | 撤销当前Token;成功或已401清本机会话,网络失败保留并提示重试 |
| GET /account/profile | 登录 | 无 | Profile | 只能当前账户 |
| PUT /account/profile | 登录 | nickname、avatarId可选 | Profile | 头像需归属当前用户;不再传任意data URL直接落资料字段 |
| POST /account/avatar | 登录 | file | attachmentId、avatarUrl | 仅支持白名单图片,按现有500KB约束起步并服务端复核 |
| POST /account/security/sms-code | 登录 | purpose:password/phone-current/phone-new、newPhone可选、requestId | challengeId、retryAfterSeconds | 当前手机号从账户取;新号码验证绑定当前用户与目标号码 |
| PUT /account/password | 登录 | verification:password/sms、oldPassword或challengeId+code、newPassword | reLoginRequired:true | 新密码8–32位可打印ASCII、含字母和数字、无空格;撤销全部Token |
| PUT /account/phone | 登录 | newPhone、currentChallengeId/currentCode、newChallengeId/newCode | reLoginRequired:true | 两组挑战同事务核销;唯一约束冲突409;不能仅验新号码 |
| GET /auth/wechat/authorize | 公开 | scene、returnPath白名单路径 | authorizeUrl、flowId | state服务端生成绑定流程;禁止开放跳转 |
| GET /auth/wechat/callback | 渠道回调 | code、state | 受控重定向至前端的一次性ticket | 核销state;不把Token放URL |
| POST /auth/wechat/exchange | 公开 | flowId、ticket | Session或bindingRequired+bindingTicket | ticket一次性使用,过期拒绝 |
| POST /auth/oneclick/init | 公开 | scene、requestId | flowId、expiresAt、必要的短期SDK初始化配置 | 独立生成并绑定当前浏览器流程,不复用微信flowId;不返回渠道私钥;同键复用未过期流程 |
| POST /auth/oneclick | 公开 | flowId、providerCredential | Session或bindingRequired+bindingTicket | 验证独立流程未过期并核销;服务端向供应商验证号码,不接受自报手机号;重复交换不得创建多个绑定 |
| POST /auth/bind-phone | 公开短期绑定凭证 | bindingTicket、phone、challengeId、code | Session | 验证bind-phone用途及凭证/手机号绑定,同事务核销;现有账户必须证明所有权,冲突禁止自动合并 |
### 5.4 资料、评估、查询与报告接口
| 方法与路径 | 权限 | 入参 | data响应 | 错误/幂等要点 |
| --- | --- | --- | --- | --- |
| POST /documents | 登录 | file、type、requestId | Document | 用户+requestId复用已成功附件;MIME/大小/内容/用途校验 |
| GET /documents/:id | 所有者 | 无 | Document及短期访问凭证或鉴权文件响应 | 所有者检查;凭证过期;不提供公开证件URL |
| DELETE /documents/:id | 所有者 | 无 | 删除状态 | 重复删除幂等;已被冻结评估引用的资料按留存规则处理 |
| GET /agreements/:code | 公开 | version可选 | code、version、title、content、effectiveAt | 请求指定版本必须得到同一正文 |
| GET /consents | 登录 | scope可选 | Consent列表 | 仅当前用户 |
| POST /consents | 登录 | scope、agreementCode、agreementVersion、requestId | Consent | 服务端记录时间;协议版本与展示一致;业务键幂等 |
| POST /consents/:id/revoke | 所有者 | requestId | Consent | 重复撤销返回既有时间;停止新查询,保留历史证据 |
| POST /assessments | 登录 | type、clientDraftId、sourceAssessmentId可选 | AssessmentDraft | 用户+clientDraftId唯一;仅可复制本人历史,复制时不继承旧授权 |
| GET /assessments | 登录 | type/status可选、page/pageSize | 草稿和历史分页 | 只本人且未软删除;按create_time/id稳定倒序 |
| GET /assessments/:id | 所有者 | 无 | AssessmentDraft及状态 | 非draft为只读;敏感字段按用途返回 |
| PUT /assessments/:id | 所有者 | revision、step、form、documents、consentIds、moneyUnit:yuan | 更新后的AssessmentDraft | 仅draft;revision条件更新,冲突409;逐字段白名单 |
| POST /assessments/:id/submit | 所有者 | requestId、revision | assessmentId、taskId、status、stage | 全量校验后冻结;同键同结果;返回HTTP202 |
| GET /assessments/:id/status | 所有者 | 无 | AssessmentStatus | 前端依据状态,不将动画作为进度事实 |
| POST /assessments/:id/retry | 所有者 | requestId | assessmentId、taskId、status | 仅failed可创建新尝试;同重试键返回同一尝试;不覆盖输入 |
| GET /assessments/:id/data-queries | 所有者 | 无 | 查询列表及来源摘要 | 原始敏感结果不直接下发 |
| POST /assessments/:id/data-queries | 所有者 | scope、consentId、requestId | queryId、status | 服务端按状态与渠道限制发起;有效授权+业务键;HTTP202 |
| GET /data-queries/:id | 所有者 | 无 | queryId、status、source、sourceAt、errorCode、可显示摘要 | 费用/权限错误有明确状态;不返回渠道凭据 |
| POST /callbacks/data/:provider | 渠道验签 | 供应商原始格式 | 供应商要求的ACK | 原始体验签、防重放、单号映射、幂等;不适用用户Bearer |
| GET /assessments/:id/report | 所有者 | 无 | Report | 未成功返回409及状态;不生成临时假报告 |
| GET /assessments/:id/matches | 所有者 | 排序选项可选 | resultVersion、items:MatchResult[] | 只读取已完成快照;排序不修改历史rank |
| GET /products | 公开 | type/regionCode可选、page/pageSize | Product分页 | 仅当前有效上架产品;不返回用户分数 |
| GET /products/:id | 公开 | 无 | Product | 下架/过期返回不可用语义;历史快照从本人匹配接口读取 |
评估主流程由服务端任务编排:提交后检查授权 → 获取选定的外部结果 → 冻结查询引用 → 规则匹配 → DeepSeek解释 → 原子保存正式结果。未选择可选外部授权时使用自填资料并标记来源;已选择查询但失败时是否允许降级到自填,需要DEC-QUERY形成明确规则。该规则未定稿前,相应错误按失败处理,不静默忽略。
分析页建议2秒起轮询,逐步退避到5秒,离开页面停止前端轮询但不取消服务端任务;重新进入按assessmentId恢复。供应商超时和任务租约由配置控制并在REPORT-002用实际接口时限确定,不按前端10秒请求超时推断AI执行失败。
### 5.5 助手、内容、消息、积分与客服接口
| 方法与路径 | 权限 | 入参 | data响应 | 错误/幂等要点 |
| --- | --- | --- | --- | --- |
| POST /assistant/chats | 登录 | title可选、contextAssessmentId可选、requestId | Chat | 创建键幂等;引用评估须本人;scene由服务端固定 |
| GET /assistant/chats | 登录 | page/pageSize | Chat分页 | 当前用户普通会话 |
| GET /assistant/chats/:id/messages | 所有者 | beforeSequence可选、pageSize | items、nextBeforeSequence | 序号稳定;不泄露内部system提示词和密钥 |
| POST /assistant/chats/:id/messages | 所有者 | message:string、requestId | userMessageId、assistantMessageId、status:pending | HTTP202;会话+请求+角色唯一;文本长度限制在CHAT-001冻结 |
| GET /assistant/messages/:id | 所有者 | 无 | ChatMessage | 用于异步回复轮询 |
| POST /assistant/messages/:id/retry | 所有者 | requestId | assistantMessageId、status | 仅失败回复;保留历史尝试信息,成功消息不重复调用模型 |
| GET /content/articles | 公开 | category、page/pageSize | Article分页 | 仅已发布;分类白名单 |
| GET /content/articles/:id | 公开 | 无 | Article及正文 | 下架/不存在返回404 |
| GET /content/lessons | 公开 | category、page/pageSize | Lesson分页 | 图文课程 |
| GET /content/lessons/:id | 公开 | 无 | Lesson及chapters | 稳定章节ID |
| GET /content/lessons/:id/progress | 登录 | 无 | completedChapterIds、lessonVersion | 只当前用户 |
| PUT /content/lessons/:id/progress | 登录 | chapterId、lessonVersion、completed:true | 最新进度 | 重复完成幂等;不存在章节拒绝 |
| GET /content/search | 公开 | keyword、type:all/news/lesson、page/pageSize | items及分页 | 仅已发布;多词AND沿用现有体验;长度/频率限制 |
| GET /help/faqs | 公开 | category可选 | items:{id,category,question,answer}[] | 后台可维护,实际行为变更时同步文案 |
| GET /messages | 登录 | type、page/pageSize | Message分页 | 当前用户;排序稳定 |
| GET /messages/unread-count | 登录 | 无 | count:int | 与列表读状态一致 |
| POST /messages/:id/read | 所有者 | 无 | Message | 幂等,不重复改首次readAt |
| POST /messages/read-all | 登录 | upToId | affected、unreadCount | 仅更新水位及之前消息,防误读并发新增通知 |
| GET /points | 登录 | 无 | balance:int、signedToday、ruleVersion | 服务端账本为准 |
| GET /points/ledger | 登录 | page/pageSize | PointsLedger分页 | 只本人 |
| POST /points/check-in | 登录 | requestId | delta、balance、signedDate | 唯一业务键=用户+签到日期;服务端时区 |
| GET /points/tasks | 登录 | 无 | 任务规则、状态、奖励条件 | 状态来源真实业务事件 |
| GET /invitations | 登录 | 无 | personalCode、shareUrl、统计摘要 | 真实唯一邀请码,隐去受邀人敏感信息 |
| POST /invitations/bind | 登录 | inviteCode、requestId | bindStatus | 首次绑定、禁止自邀、已有关系不覆盖 |
| POST /feedback | 登录 | content、attachmentIds、requestId | Feedback | 非空且当前表内容最长255字符;附件归属;幂等 |
| GET /feedback | 登录 | page/pageSize | Feedback分页 | 只本人 |
| GET /feedback/:id | 所有者 | 无 | Feedback | 处理回复可读 |
| POST /support/session | 登录 | scene、returnPath可选 | 客服渠道、短期会话配置、online/offline状态 | 服务端身份签名;不下发供应商私钥;会话复用策略按供应商核验 |
### 5.6 对现有占位接口的迁移
- 原POST /assessments“整包提交”拆成创建/更新草稿与submit;确认页必须保存返回assessmentId。
- 原GET /matches替换为GET /assessments/:id/matches,避免读取不明确的“最近结果”。
- 原POST /assistant/messages的message→reply同步接口,替换为会话消息异步接口,避免前端超时导致重复请求模型。
- 原FinanceProduct同时承载公共信息和score的结构,拆成Product与MatchResult;旧amount万元兼容仅在过渡适配器,正式API始终返回amountMinYuan/amountMaxYuan。
- 账户修改资料从data URL改为上传得到avatarId;安全短信增加challengeId并绑定用途/目标,旧客户端占位字段同步修改。
- news/lesson/search必须同时替换数据源,防止搜索返回真实ID但详情仍查本机数组。
- 模拟与真实模式在开发环境显式区分;生产构建禁止缺配置时回退模拟。
## 6. 工时、里程碑与实施顺序
### 6.1 剩余工程量
| 模块 | 功能板块 | 阶段 | 剩余人日 |
| --- | --- | --- | ---: |
| BASE | 基础接口与环境 | M0 | 3–5 |
| AUTH | 账户与短信安全 | M1 | 5–8 |
| LOGIN | 微信与一键登录 | M1 | 5–10 |
| FILE | 资料与授权 | M1 | 4–7 |
| ASSESS | 评估与融资档案 | M2 | 5–8 |
| DATA | 外部征信或数据查询 | M2 | 6–12 |
| PRODUCT | 产品库与匹配规则 | M2 | 6–9 |
| REPORT | AI任务、报告与历史 | M2 | 8–12 |
| CHAT | AI助手 | M3 | 4–7 |
| CONTENT | 内容、课堂、搜索与FAQ | M3 | 4–6 |
| MSG | 消息中心 | M3 | 2–4 |
| POINT | 积分、任务与邀请 | M3 | 4–7 |
| SUPPORT | 反馈与人工客服 | M3 | 3–6 |
| ADMIN | 后台运营与排障 | M3 | 3–5 |
| RELEASE | 联调、测试与发布 | M4 | 7–11 |
| 合计 | 15模块、60任务 | M0–M4 | **69–117** |
每周按5个工程工作日计算,约14–24个工作周。AI辅助效率已经纳入估算,不再乘额外提速系数。该区间不是承诺发布日期,未包括供应商商务、资质、短信模板、域名、审核和运营资料等待。
每类渠道暂按一个服务商、一次首发H5方案计。若个人/企业数据需要多个供应商、更多独立授权或首次方案不支持目标浏览器,必须据实调整DATA/LOGIN相关任务与总量。模块工时包含自身开发者测试,RELEASE为跨模块真实联调和发布演练;历史数据库优化不重复计量。
### 6.2 阶段门槛
| 阶段 | 交付目标 | 工程人日 | 累计人日 | 退出门槛 |
| --- | --- | ---: | ---: | --- |
| M0 契约与接入准备 | BASE;同时启动各供应商与业务规则准备 | 3–5 | 3–5 | 统一契约可解析,环境与用户隔离方案可联调;依赖清单有责任人 |
| M1 账户与资料 | AUTH、LOGIN、FILE | 14–25 | 17–30 | 真实账户、支持场景的第三方登录、私有资料和正式授权通过 |
| M2 融资核心 | ASSESS、DATA、PRODUCT、REPORT | 25–41 | 42–71 | 个人/企业真实评估→查询→匹配→DeepSeek报告→历史各完成闭环 |
| M3 全部页面业务化 | CHAT、CONTENT、MSG、POINT、SUPPORT、ADMIN | 20–35 | 62–106 | 所有功能有真实数据与后台操作,助手/人工客服可用 |
| M4 H5首发 | RELEASE | 7–11 | 69–117 | 所有一期必做项验收、恢复演练、发布后冒烟与证据完整 |
M0启动其他模块的渠道申请不重复计算其工程工时;设计和等待可提前,实施工时仍记对应任务。单人不能按多人并行把独立模块时长直接减半。受阻时优先穿插CONTENT/产品规则/后台等具备前置条件的工作,不把一期项目降为二期。
```mermaid
flowchart LR
A["M0 接口、环境与渠道准备"] --> B["M1 账户、身份与私有资料"]
B --> C["M2 评估与授权查询"]
P["产品资料与版本化规则"] --> D["匹配与 DeepSeek 报告"]
C --> D
D --> E["M3 助手、消息与运营"]
F["内容、课堂、积分、客服"] --> E
E --> G["M4 H5 全量验收"]
T["所有第三方真实接入证据"] --> G
```
后续多端单独开里程碑:微信小程序的AppID/合法域名/包体/平台授权与审核,Android签名与真机上传/登录能力,iOS的macOS/Xcode/签名与审核。继续保留跨端编译回归,但不把Windows生成iOS源码视为真机或IPA验证。
## 7. 外部依赖与必须定稿的规则
下表只记录准备要求,不收集密钥。具体状态、责任人、预计解除时间和证据维护在[开发进度](开发进度.md)第5章。未选供应商不影响文档交付,但会阻塞真实接入及全量上线。
| 编号 | 当前状态 | 需要取得的条件 | 对应任务 |
| --- | --- | --- | --- |
| EXT-SMS | 待选型 | 服务商、签名模板、用途、测试号码、频控及发送回执 | AUTH-001 |
| EXT-WX | 待确认产品和平台资源 | 公众号/开放平台类型、适用H5场景、回调域名、授权和身份字段能力 | LOGIN-001、LOGIN-002 |
| EXT-ONE | 待选型 | H5号码认证产品、运营商/网络/浏览器矩阵、SDK与服务端校验、测试环境 | LOGIN-001、LOGIN-003 |
| EXT-STORAGE | 待选型 | 私有存储方案、访问控制、文件限制、备份与生命周期 | FILE-001 |
| EXT-DATA | 待选型 | 实际可开通数据产品、个人/企业接口、授权要求、样例、回调/查询和费用状态 | DATA-001 |
| EXT-AI | DeepSeek已选定,接入待核验 | 具体模型标识、测试权限、额度、超时/结构化输出支持及实际用量字段;实施时核对官方接口文档 | REPORT-001 |
| EXT-CS | 待选型 | H5接入、访客身份、坐席、在线/离线、历史与附件能力、工作时间 | SUPPORT-001、SUPPORT-003 |
| EXT-DEPLOY | 待确认 | 测试/生产资源、域名/HTTPS、Redis/worker守护、备份与监控 | RELEASE-003 |
| 编号 | 需确认内容 | 定稿任务及产物 |
| --- | --- | --- |
| DEC-PRIVACY | 资料用途、可选项、正式协议正文与版本、保留期限、撤销后处置、第三方必要字段 | FILE-001、FILE-003:协议和数据生命周期清单 |
| DEC-QUERY | 首发数据查询种类与个人/企业范围、拒绝/失败后的自填路径、重查与费用归属 | DATA-001:页面授权到数据产品映射及失败策略 |
| DEC-MATCH | 硬准入、未知处理、分数/等级/排序、报告维度、同分规则及人工样例 | PRODUCT-001、REPORT-003:版本化规则与金标准样例 |
| DEC-PRODUCT | 首发真实产品及金额/期限/利率/地区/准入依据、有效期和运营责任人 | PRODUCT-003:可发布产品资料与运营确认记录 |
| DEC-AI | DeepSeek具体模型、单用户/评估额度、超时重试、成本预算与超额策略 | REPORT-001:配置及额度规则 |
| DEC-POINT | 签到/资料/邀请奖励数额、次数、可信达成条件、权益/有效期 | POINT-001:活动规则及正反样例 |
技术默认项:每类服务商首期一个;站内消息优先;课堂为图文;助手先异步完整回复;时间业务口径Asia/Shanghai;列表pageSize=20、上限50。业务分数、奖励数额、查询费用、模型名和数据保留期限不擅自定为上线规则。
DeepSeek只选定服务商,不能据此宣布模型可用或已通过真实调用。一键登录也不能依据“原生App有SDK”推断H5全场景可用;若渠道不支持已承诺场景,先形成能力差异和影响,再记录用户的范围决定,不能静默删项。
## 8. 验证、发布与文档维护
### 8.1 测试分层与验收条件
1. **纯逻辑/服务测试**:金额边界、字段映射、规则执行、版本快照、密码兼容、授权状态和评分人工样例。
2. **数据库/API集成**:两个以上账户进行越权读写;并发提交/重试/积分;鉴权、业务错误、附件归属、事务和幂等。
3. **故障与渠道测试**:短信失败、OAuth取消/重放、查询拒绝/超时/回调伪造、DeepSeek无效输出/配额不足、队列漏投和worker中断、客服离线。
4. **H5端到端**:个人和企业各完成真实流程;刷新恢复、历史报告、产品更新、助手、课程跨设备、消息、积分邀请、反馈与人工坐席。
5. **发布恢复**:干净环境初始化、有数据增量升级、备份恢复、密钥恢复、队列守护、回退后数据保全及发布冒烟。
必须覆盖的具体结果:
| 场景 | 必须满足的结果 | 归属 |
| --- | --- | --- |
| 金额换算 | 0.800001万元=8000.01元;最大值、超限、负数、空值、0和精度均校验;不重复换算 | ASSESS-004、PRODUCT-004 |
| 身份与越权 | 用户A不能读取/修改B的草稿、证件、报告、会话、消息和反馈;改密/换绑后旧Token失效 | AUTH-004、RELEASE-001 |
| 重复提交/历史重试回放 | 相同requestId只一条正式会话;多次重试后重放任一旧键仍返回其原taskId、不再次执行;同键参数冲突拒绝 | ASSESS-003 |
| 任务故障 | 派发失败、重复消费、worker退出、旧taskId回写均可恢复或安全拒绝 | REPORT-002 |
| 历史稳定 | 同类型多次评估分别保留;产品改价/下架不改变历史快照 | PRODUCT-004、REPORT-004 |
| 缺失事实 | 未知字段不当0,渠道失败不当无风险;AI不得创造不存在的额度/审批承诺 | PRODUCT-002、REPORT-003 |
| 私有资料 | 无登录/跨用户无法读取,撤销后不新增查询;授权版本可追溯 | FILE-004、DATA-004 |
| 课程进度 | 章节重排不串记录,重复完成幂等,换设备一致 | CONTENT-003 |
| 积分并发 | 同日并发只奖励一次,流水与余额一致;复制/分享不视为实际邀请成功 | POINT-004 |
| 消息一致 | 重复事件只一条通知,全部已读不吞之后的新通知 | MSG-004 |
| 第三方登录/客服 | 真实支持场景成功与失败都验证;真人接待/离线留言有证据 | LOGIN-004、SUPPORT-004 |
| 上线完整性 | 25页均有真实数据来源;无模拟Token、固定报告或占位协议;必做渠道全验收 | RELEASE-004 |
完整执行台账见[开发进度](开发进度.md)第6章。每条实际记录需包含日期、环境/构建版本、前置测试数据、操作、预期/实际、脱敏证据、缺陷和验收人。文档中的“待执行”不能自动转为通过。
### 8.2 已有验证与后续命令
前序任务记录:2026-09-22,41项基础测试通过、Web与Android本地编译通过、金额相关浏览器检查通过。它们不覆盖本章真实业务;初始文档交付未重跑;实施后的验证另见进度文档第0章。测试脚本存在可复核,前序浏览器/编译独立日志未归档,不能补写不存在的证据路径。
后续业务实施可使用下列现有命令。PHP基础测试会操作数据库并在外层事务回滚,应在核对后的测试环境执行;当前文档任务不执行它。HBuilderX命令是运行/本地编译,不是正式发布。
```powershell
# 后端基础回归:在后端目录,确认使用测试数据库后执行
Set-Location 'D:\AAdesk\202609\airz'
& 'D:\Develop\phpstudy_pro\Extensions\php\php7.4.3nts\php.exe' tests/finance.php
# 前端:在HBuilderX中运行及本地编译
Set-Location 'D:\WorkSpace\cyun\ai融资'
& 'D:\Develop\HBuilderX\cli.exe' open
& 'D:\Develop\HBuilderX\cli.exe' launch web --project 'D:\WorkSpace\cyun\ai融资' --compile false --continue-on-error false
& 'D:\Develop\HBuilderX\cli.exe' launch app-android --project 'D:\WorkSpace\cyun\ai融资' --compile true --continue-on-error false
```
目标H5矩阵包括桌面Chrome/Edge、Android主流浏览器、iOS Safari及微信内置浏览器。具体版本/设备在RELEASE-002记录;iOS Safari的H5验证与iOS原生App构建是两件事。共享UTS修改后保留Android编译回归,正式App发布另计。
### 8.3 部署、迁移与恢复
- 部署材料需说明前端静态资源/路由回退、后端入口、HTTPS/CORS、环境变量、私有文件存储和数据库版本。
- Redis与think-queue配置存在不代表worker已运行;配置守护、重启、积压/失败/超时告警及失联任务补偿。
- 新环境初始化需依赖顺序、管理员/配置等必要种子数据;现有schema.sql是结构快照,不能当完整安装包直接导入。
- 增量迁移记录前置版本、数据预检、备份、回填、约束验证和失败恢复;已有业务数据环境禁止盲目重跑旧空表限定迁移。
- 回滚需保留上线后新增业务数据;数据库DDL不能当作整批事务可回滚。密文恢复依赖对应加密密钥,备份恢复演练须包含密钥恢复且不将其写入文档。
- 监控至少记录任务积压/耗时/失败、短信和查询失败、DeepSeek消耗/预算、文件访问拒绝和接口错误,并通过业务ID关联排障。
- 发布前所有一期必做任务有验收证据;上线后完成登录、个人/企业评估、历史、内容及客服冒烟。实际发布是后续RELEASE任务;当前仅在本地实施与验证,未发布。
### 8.4 两份文档的维护方式
开发文档维护“要做什么、接口和验收是什么”;进度文档维护“做到哪里、依据是什么、谁来处理阻塞”。任务新增/拆分保留旧编号和变更理由,工时与依赖在两份文档同步。维护者每次任务结束更新状态、实际证据、日期和下一步。
初始文档交付验收清单:25页覆盖、60任务编号和字段一致、所有内部依赖存在且无环、模块与总工时相符、互链和源码链接有效、UTF-8无BOM、无凭据/个人数据。初次交付仅新增两份Markdown;后续代码与本地数据库变更见第0节及进度台账。文档完成不计为业务任务完成。
+569
View File
@@ -0,0 +1,569 @@
# AI 融资平台开发进度
基线日期:2026-09-22。文档版本:1.0。技术方案和接口说明见[开发文档](./开发文档.md)。本台账用于记录真实开发与验收状态,任务编号与开发文档保持一致。
## 0. 2026-09-22 首轮实施更新(优先于初始基线)
本轮已开始M0与无需第三方的账户功能;下文历史基础表保留规划时事实,当前会话存储以本节为准。
| 项目 | 实际结果 | 依据与未完成边界 |
| --- | --- | --- |
| Token存储 | 本地fa_user_token(迁移前0行)已备份删除,实际库25表;Token改Redis | 后端database/migrate_token_redis.py、database/schema.sql;不复制旧会话 |
| Redis驱动 | 摘要键、TTL、用户索引、单会话退出/全部撤销原子操作,故障不回退 | tests/token_redis.php:14项通过,隔离测试键已清理 |
| 后端回归 | 金额、加密、评估生命周期和账户基础回归通过 | tests/finance.php:41项通过,数据库回滚,Redis测试会话清理 |
| 真实HTTP | 密码登录、Bearer资料读取、退出、原密码改密和多个旧Token失效通过 | tests/api_session.php:16项通过,临时用户/会话清理;短信返回503 |
| 前端基础 | 统一请求和错误、默认密码登录、Redis退出、userId缓存隔离 | services/api.uts、config.uts、storage.uts;真实业务其他接口待开发 |
| 浏览器联调 | 真实密码登录→首页→“我的”→确认退出;A/B账户草稿隔离;短信503无倒计时 | 本地Chromium,390×844和1280×900;详见6.1;不含微信内置浏览器 |
| 编译 | Web编译通过;Android退出回调类型问题已修复后编译通过 | HBuilderX 5.26本地编译;非真机/正式发行验证 |
| 外部准备 | 短信服务商、DeepSeek测试权限、首批真实产品资料均未具备 | AUTH-001、REPORT-001、PRODUCT-003保持阻塞;不虚构成功 |
| 恢复点 | 已保存目标表结构与数据备份 | D:/AAdesk/202609/airz-backups/token-redis-20260922-165215-988442/user_token.sql |
仍待完成:全量接口字典和requestId日志关联、生产配置校验、旧匿名草稿显式导入、敏感缓存清理、服务端完整profile/头像、短信注册/换绑及其他业务模块。当前没有将任何整模块标记为已完成。
## 1. 项目状态与维护规则
当前已具备25个页面原型、部分本机交互、优化后的数据库结构和评估内部生命周期服务;密码账户基础已完成本地前后端联调,短信、评估、报告、匹配等完整业务仍待完成。AI供应商确定为DeepSeek,具体模型、预算、可用额度及测试权限待核验;其他服务商待定。
一期目标是 **25 个页面全部接入真实业务,H5 优先上线**。短信、微信登录、一键登录、外部征信或数据查询、人工客服均为一期必做项,不能因为服务商未定而视为已完成或从首发范围中静默移除。贷款申请仅包括现有申请准备和资料引导,不含银行受理、授信、放款与还款交易。
初次交付为开发管理文档;其后已启动第0节列出的代码、迁移与验证工作。下文历史基线不等同当前所有功能状态,任务表持续更新。
### 1.1 状态定义
| 状态 | 进入条件 | 离开条件 |
| --- | --- | --- |
| 未开始 | 尚未进入实际开发;存在内部前置任务时仍使用此状态 | 前置任务可用且开始实现后改为开发中 |
| 开发中 | 已有负责人和实际实现活动 | 单元/组件验证可用后进入待联调,或记录具体阻塞 |
| 待联调 | 本模块实现和必要自测已完成,等待跨模块/真实渠道连接 | 联调通过且证据完整后进入待验收 |
| 待验收 | 所有约定测试已执行,等待业务或发布验收 | 验收通过改为已完成;发现问题回到开发中 |
| 已完成 | 范围及验收条件全部满足,具备日期、环境和可复核证据 | 回归发现缺陷时重新打开并记录原因 |
| 阻塞 | 外部资源或业务决策前置未齐,明确列出阻塞编号 | 阻塞解除后按实际工作状态更新,不能直接跳到已完成 |
初始台账仅把**直接缺少外部资源或业务决定**的任务标为阻塞。它们的下游任务暂保留未开始,通过依赖关系表示尚不具备完整实施条件;这不表示下游可以跳过前置独立验收。可开展接口壳、模拟样例等准备工作,但真实渠道验收仍须等待解除阻塞。
P0 表示核心流程、安全、首发外部能力或发布门槛;P1 表示其余一期功能。P1 同样必须在一期上线前完成。负责人“全栈开发(待指定)”代表 1 人全栈+AI 辅助的排期基线;负责人、验收人和服务商对接人须在启动时落实到人。
### 1.2 工时口径与更新规则
- 剩余工程量为 **69–117 人日**,按每周 5 个工作日约 **14–24 周**;不等于承诺上线日期,不含供应商开通、资质、审核与商务等待。
- 每类第三方暂按 1 个服务商估算;能力不足、增加渠道或调整业务范围时,修改受影响任务估算并在更新日志说明。
- 不用页面数、SQL 表数、代码行数计算业务完成率。文档完成也不计为下面 60 个业务任务完成。
- 每次开发结束同步状态、实际证据、日期、依赖变化与下一步;保留原任务编号。任务跨模块时不得重复统计工时。
- 证据优先包含代码文件或版本、脱敏接口记录、测试报告、截图/录屏、第三方请求编号、验收人;不写 Token、验证码、密钥或证件原文。
- 接口与范围调整先同步开发文档,再同步本台账。新增需求单独变更评审,不能通过扩大既有任务标题掩盖新增成本。
## 2. 当前分层基线
“有基础”只说明存在结构或代码,不代表相应真实能力可用。“待开发/联调”说明还需要业务 API、渠道或验收工作。下表按 15 个模块分别记录,避免把原型和数据库迁移视为业务交付。
| 模块 | 页面/交互 | 数据库 | 内部服务/后台 | 业务 API | 第三方 | 真实验收 |
| --- | --- | --- | --- | --- | --- | --- |
| BASE 基础接口 | 请求已接本地API;首页仍为原型 | 通用框架表已有 | v1显式路由及环境配置已有 | Bearer、响应封装和401已接;requestId等待补 | 部署环境待定 | 本地账户基础通过,模块未验收 |
| AUTH 账户安全 | 密码登录/退出/原密码改密已接入;资料本机缓存 | 用户唯一约束已有;Token仅存Redis | 旧密码兼容、加密和撤销基础已有 | 密码登录、GET资料、退出、原密码改密已实现;其他待开发 | 短信待定 | 密码基础通过,完整模块未验收 |
| LOGIN 第三方登录 | 微信/一键入口或占位行为 | 身份关联增量设计待做 | 渠道适配待开发 | 待开发 | 微信/一键服务待定 | 未完成 |
| FILE 资料授权 | 选图本机预览、授权勾选与占位说明 | documents/consent_data 仅预留 | 通用公共上传不能代替私有证件服务 | 私有上传、下载、授权事件待开发 | 私有存储待定 | 未完成 |
| ASSESS 评估档案 | 个人/企业草稿及流程已有 | 一次评估一条记录及状态/幂等字段已有 | Assessment 内部服务已有 | 草稿、提交、状态、历史待接入 | 依赖上传/授权/数据渠道 | 内部历史测试已有,真实闭环未完成 |
| DATA 外部查询 | 查询选项为意向或占位 | 查询事件持久化待增量设计 | 渠道、回调和对账待开发 | 待开发 | 数据渠道待定 | 未完成 |
| PRODUCT 产品匹配 | 产品/匹配页使用模拟数据 | 范围字段、规则 JSON、版本/有效期已有 | 产品后台基础已有,规则执行器未完成 | 公共查询与评估匹配读取待开发 | 真实产品及规则待确认 | 结构/版本历史测试已有,业务匹配未验收 |
| REPORT AI 报告 | 分析/报告/雷达图为模拟结果 | report_data/match_result 和任务字段已有 | 生命周期有基础,AI Job/队列未接通 | 状态、报告读取和重试待开发 | DeepSeek 已选定,未接入 | 未完成 |
| CHAT AI 助手 | 模拟对话 | 会话/消息/使用量字段已有 | 调用、上下文、配额待开发 | 待开发 | 依赖 DeepSeek | 未完成 |
| CONTENT 内容课堂 | 新闻/课程/搜索/FAQ 本地数据,学习进度本机 | 文章/融资课堂表已有,课程进度待增量设计 | 内容后台基础需补齐 | 真实列表/详情/进度/搜索待开发 | 内容运营素材待准备 | 未完成 |
| MSG 消息中心 | 静态消息及本机已读 | read_at/关联业务字段已有 | 通知事件/去重需补齐 | 列表/计数/已读待开发 | 无独立外部渠道要求 | 未完成 |
| POINT 积分邀请 | 体验签到、本机积分/任务 | 积分流水及业务幂等基础已有,邀请关系待设计 | 可信奖励事件和并发账本待开发 | 待开发 | 奖励业务规则待定 | 未完成 |
| SUPPORT 客服反馈 | 帮助页与 FAQ;客服暂未开放 | feedback 基础表已有 | 反馈闭环、客服签名待开发 | 待开发 | 人工客服待定 | 未完成 |
| ADMIN 后台运营 | FastAdmin 管理界面已有 | 管理员权限保持,会员组保持删除 | 档案/产品/密钥表单已适配,运营排障待补齐 | 面向管理员的新增能力待开发 | 依赖各渠道状态 | 基础界面修改不等于运营验收 |
| RELEASE 发布验收 | Web/Android 曾完成本地编译 | 本机迁移已有,其他环境须单独设计升级 | 未建立完整首发部署/恢复链路 | 无全量真实联调证据 | 域名/HTTPS/队列部署等待定 | H5 首发、真机和多端发布均未完成 |
### 2.1 已完成基础及证据边界
以下为上一轮已实施的基础建设,不重复计入剩余任务工时。源码存在只能证明实现入口,历史通过记录不能代替之后业务改动的回归测试。
| 基础项 | 2026-09-22 基线记录 | 可复核入口 | 限制 |
| --- | --- | --- | --- |
| 当前数据库结构 | 本地25张InnoDB表;按用户最新决定删除空fa_user_token,改用Redis;此前26表为历史基线 | [数据库约定](D:/AAdesk/202609/airz/database/README.md)、[结构快照](D:/AAdesk/202609/airz/database/schema.sql) | 已核验本地库;根目录旧airz.sql不能作为现状;备份见第0节 |
| 金额与产品范围 | DECIMAL(11,2) 元;前端万元;产品独立额度/期限/利率/准入字段及补充 JSON | [金额转换](../services/money.uts)、数据库约定 | 真实 API 待联调;后台录入按元,移动端展示万元 |
| 评估生命周期 | submit/complete/fail/retry/owned;事务、幂等、失效任务拒绝及所有者过滤 | [Assessment.php](D:/AAdesk/202609/airz/application/common/library/Assessment.php) | 内部服务,不是已上线 API;队列与真实 AI 未接入 |
| 输入与正式结果保护 | 提交后输入冻结;报告与匹配历史快照;正式会话避免按普通聊天删除 | 数据库约定、Assessment 服务、后台现有模型 | 后续 API/后台扩展必须延续,不能绕过生命周期 |
| 安全基础 | 身份证与模型密钥加密、后台掩码、日志脱敏;密码散列兼容和登录升级,改密撤销 Token | [Secret.php](D:/AAdesk/202609/airz/application/common/library/Secret.php)、[Password.php](D:/AAdesk/202609/airz/application/common/library/Password.php) | 私有上传、正式授权、短信和身份渠道仍未完成 |
| 基础校验与测试 | 41项基础测试本轮已回归通过,包含金额、加密、幂等、越权、历史快照、重试、产品版本和会话撤销 | [tests/finance.php](D:/AAdesk/202609/airz/tests/finance.php) | 2026-09-22;不代表全系统或真实第三方通过 |
| 编译与页面金额检查 | 上一轮 Web 与 Android 编译通过;手机 390×844、桌面 1280×900 浏览器检查金额输入、超限、确认、模拟提交和产品显示 | [前端 README](../README.md)、[页面注册](../pages.json);前序任务会话验证记录 | 不是短信/AI/上传真实联调;无 iOS/Xcode 或 Android 真机上线结论;独立历史日志路径未留档,后续回归须新增证据 |
| 迁移与恢复基础 | 本机迁移及仓库外备份已完成 | [数据库迁移说明](D:/AAdesk/202609/airz/database/README.md) | 旧迁移限定业务空表,不得直接用于已有业务数据环境;未完成通用部署和恢复演练 |
### 2.2 全部 25 个页面的验收归属
页面路径以[pages.json](../pages.json)为准。下表记录页面真实业务的主要收口任务;其内部依赖仍需全部完成。当前 25 页均未通过一期真实业务验收。
| 页面 | 注册路径 | 主要任务 | 当前状态 |
| --- | --- | --- | --- |
| 首页 | pages/home/home | BASE-004、CONTENT-002、MSG-003 | 原型/本地数据 |
| 融资入口 | packages/finance/entry/entry | ASSESS-004 | 原型 |
| 评估填写 | packages/finance/assessment/assessment | ASSESS-004、FILE-004、DATA-004 | 本机草稿/图片预览 |
| 评估确认 | packages/finance/confirm/confirm | ASSESS-004 | 本机金额确认 |
| 分析中 | packages/finance/analyzing/analyzing | REPORT-004 | 模拟计时与跳转 |
| 评估报告 | packages/finance/report/report | REPORT-004 | 模拟报告 |
| 匹配结果 | packages/finance/matches/matches | PRODUCT-004、REPORT-004 | 模拟匹配 |
| 产品详情 | packages/finance/product/product | PRODUCT-004 | 模拟产品/申请准备 |
| AI 助手 | packages/finance/assistant/assistant | CHAT-004 | 模拟对话 |
| 融资档案 | packages/finance/archive/archive | ASSESS-004、REPORT-004 | 本机档案 |
| 启动引导 | packages/account/index/index | BASE-003、AUTH-002 | 原型/本机引导 |
| 登录 | packages/account/login/login | AUTH-002、LOGIN-004 | 未完成真实认证 |
| 个人中心 | packages/account/profile/profile | AUTH-003、POINT-004、MSG-003 | 本机资料/入口 |
| 修改资料 | packages/account/edit-profile/edit-profile | AUTH-003 | 昵称/头像本机保存 |
| 账户安全 | packages/account/security/security | AUTH-004 | 入口与客户端校验 |
| 修改密码 | packages/account/password/password | AUTH-004 | 真实服务未接通,不模拟成功 |
| 换绑手机 | packages/account/change-phone/change-phone | AUTH-004 | 真实服务未接通,不模拟成功 |
| 积分中心 | packages/account/points/points | POINT-004 | 体验签到与本机记录 |
| 消息中心 | packages/account/messages/messages | MSG-004 | 静态消息与本机已读 |
| 搜索 | packages/content/search/search | CONTENT-004 | 本地搜索 |
| 帮助与反馈 | packages/content/help/help | CONTENT-004、SUPPORT-004 | 本地 FAQ,客服未开放 |
| 新闻列表 | packages/content/news/news | CONTENT-002 | 本地数据 |
| 文章详情 | packages/content/article/article | CONTENT-002 | 本地数据 |
| 融资课堂 | packages/content/classroom/classroom | CONTENT-002、CONTENT-003 | 本地数据 |
| 课程详情 | packages/content/lesson/lesson | CONTENT-003 | 本机学习进度 |
## 3. 里程碑与依赖顺序
里程碑按验收结果推进,不预填实际日期。单人实施按依赖顺序排队;第三方选型、申请和材料准备在 M0 启动,其等待时间可穿插内部模块。各阶段人日仅合计归属模块,不再次累加前序工时。
| 里程碑 | 范围 | 工程人日 | 累计工程人日 | 进入/退出门槛 | 当前状态 | 实际开始/完成 |
| --- | --- | --- | --- | --- | --- | --- |
| M0 契约与接入准备 | BASE;同步发起全部外部资源和业务决策收集 | 3–5 | 3–5 | 契约、认证/响应、环境和缓存隔离可用;首页骨架具备联调入口;依赖有责任人与清单 | 未开始 | 待记录 / 待记录 |
| M1 账户与资料闭环 | AUTH、LOGIN、FILE | 14–25 | 17–30 | 真实账户、账户安全、微信/一键支持场景、私有上传与版本授权验收完成 | 未开始,存在直接外部阻塞 | 待记录 / 待记录 |
| M2 融资核心闭环 | ASSESS、DATA、PRODUCT、REPORT | 25–41 | 42–71 | 个人/企业各完成真实评估、授权查询、确定性匹配、DeepSeek 报告与历史;失败恢复可用 | 未开始,存在外部/业务决策阻塞 | 待记录 / 待记录 |
| M3 全部页面业务化 | CHAT、CONTENT、MSG、POINT、SUPPORT、ADMIN | 20–35 | 62–106 | 助手、内容、课堂、搜索、消息、积分邀请、反馈和人工客服及运营后台验收通过 | 未开始,存在外部/业务决策阻塞 | 待记录 / 待记录 |
| M4 H5 验收上线 | RELEASE | 7–11 | 69–117 | 全部一期任务验收;普通 H5/微信内 H5 与渠道支持矩阵通过;部署恢复演练和发布记录完整 | 未开始,部署资源待定 | 待记录 / 待记录 |
后续多端里程碑不计入上述 69–117 人日,也不属于一期 H5 验收:微信小程序需平台 AppID、隐私配置、授权/支付无关能力复核、包体与真机测试和提审;Android 需签名、真实设备与网络/权限适配、渠道材料;iOS 需 macOS/Xcode、签名、真实设备、隐私材料和审核。具体范围、顺序与工时在 H5 稳定后另行确认,不能拿历史本地编译代替发布。
## 4. 60 项任务台账
每模块分“计划与状态”和“验收记录”两张表,任务编号一一对应。依赖列只列直接前置任务;直接外部/业务阻塞在第 5 节统一维护。以下均为剩余业务工作,历史基础不重复生成任务。
### BASE|基础接口与环境
归属 M0,剩余 3–5 人日。
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| BASE-001 | 接口契约与字段字典 | 定义版本路由、响应、错误、分页、ID、金额、时间及接口状态;确认前后端字段映射。 | P0 | 无内部前置 | 0.5–1 | 开发中 |
| BASE-002 | 统一请求与认证适配 | 统一共享/账户请求;后端识别 Bearer;解析业务失败和401;限定 CORS;记录 requestId。 | P0 | BASE-001 | 1–1.5 | 开发中 |
| BASE-003 | 环境与用户缓存隔离 | 开发/测试/生产配置;生产禁用 mock;会话和草稿等缓存按不可变 userId 隔离。 | P0 | BASE-002 | 1–1.5 | 开发中 |
| BASE-004 | 首页和联调骨架 | 首页接入聚合数据、加载/空态/失败重试及登录回跳;建立接口冒烟入口。 | P1 | BASE-002、BASE-003 | 0.5–1 | 未开始 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- |
| BASE-001 | 全栈开发(待指定) | 接口样例可同时被 PHP 和 UTS 解析;区分占位接口与后端现有实现。 | 已实施子集和验证见第0节;完整任务未验收 | 2026-09-22 |
| BASE-002 | 全栈开发(待指定) | HTTP 200 中业务失败不判成功;过期 Token 清会话;普通业务按所有者鉴权。 | 已实施子集和验证见第0节;完整任务未验收 | 2026-09-22 |
| BASE-003 | 全栈开发(待指定) | 切换账户无串数据;匿名旧草稿仅在用户明确选择导入后绑定;退出清理敏感缓存。 | 已实施子集和验证见第0节;完整任务未验收 | 2026-09-22 |
| BASE-004 | 全栈开发(待指定) | 深链接与刷新正确恢复;首页无硬编码账户数据;真实未读数后续由 MSG 接入。 | 未验收;无真实业务验收证据 | 2026-09-22 |
直接外部/业务阻塞:无;仍须满足前置任务。
### AUTH|账户与短信安全
归属 M1,剩余 5–8 人日。
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| AUTH-001 | 短信接入与验证码服务 | 供应商适配、签名模板、验证码用途/有效期/频控/核销、测试发送。 | P0 | BASE-002 | 1–2 | 阻塞 |
| AUTH-002 | 真实登录与会话 | 短信登录/首次注册、密码登录、资料读取、登出、禁用账户、旧密码散列升级。 | P0 | AUTH-001、BASE-003 | 1.5–2 | 开发中 |
| AUTH-003 | 资料与头像持久化 | 昵称、头像 API 和文件校验;以服务端 profile 为准,迁移本机回显逻辑。 | P1 | AUTH-002 | 1–2 | 未开始 |
| AUTH-004 | 改密与手机换绑 | 登录校验、原/新手机双挑战、号码占用、事务及全部会话撤销;修复旧改绑免登录声明。 | P0 | AUTH-002 | 1.5–2 | 开发中 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- |
| AUTH-001 | 全栈开发(待指定) | 发送成功才倒计时;验证码过期、复用、跨用途和频繁发送均拒绝。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| AUTH-002 | 全栈开发(待指定) | 真实账户登录成功;登录失败无假 Token;登出撤销当前 Token;旧散列兼容。 | 已实施子集和验证见第0节;完整任务未验收 | 2026-09-22 |
| AUTH-003 | 全栈开发(待指定) | 不同设备资料一致;昵称不覆盖评估实名;头像不依赖临时路径。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| AUTH-004 | 全栈开发(待指定) | 跨账户或单个验证码不能换绑;修改后旧 Token 全失效;新密码8–32位且含字母和数字。 | 已实施子集和验证见第0节;完整任务未验收 | 2026-09-22 |
直接阻塞:AUTH-001 → EXT-SMS。
### LOGIN|微信与一键登录
归属 M1,剩余 5–10 人日。
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| LOGIN-001 | 登录渠道能力与身份方案 | 确认微信内/普通H5方式、号码认证H5能力;定义渠道身份与手机号账户绑定方案。 | P0 | BASE-001 | 1–2 | 阻塞 |
| LOGIN-002 | 微信登录接入 | 授权发起、state校验、code服务端交换、首次手机号绑定与已有账户冲突处理。 | P0 | LOGIN-001、AUTH-002 | 1.5–3 | 阻塞 |
| LOGIN-003 | 一键登录接入 | 号码认证SDK/网页流程、服务端验证、能力检测、账号绑定、短信回退。 | P0 | LOGIN-001、AUTH-002 | 1.5–3 | 阻塞 |
| LOGIN-004 | 身份渠道联调 | 微信内、普通移动H5和桌面H5分别验证;解绑/冲突与失败恢复按已选渠道能力实现。 | P0 | LOGIN-002、LOGIN-003 | 1–2 | 未开始 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- |
| LOGIN-001 | 全栈开发(待指定) | 形成渠道支持矩阵、回调和身份唯一键方案;不以昵称/openid跨应用猜测同一账户。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| LOGIN-002 | 全栈开发(待指定) | 授权取消和重放安全失败;绑定现有账户需原账户证明;获批H5场景真机通过。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| LOGIN-003 | 全栈开发(待指定) | 按渠道支持的网络/运营商/浏览器验收;客户端自报手机号不能登录。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| LOGIN-004 | 全栈开发(待指定) | 身份关联不误合并账户;重放无效;不支持场景有清晰短信回退。 | 未验收;无真实业务验收证据 | 2026-09-22 |
直接阻塞:LOGIN-001 → EXT-WX、EXT-ONE;LOGIN-002 → EXT-WX;LOGIN-003 → EXT-ONE。
### FILE|资料与授权
归属 M1,剩余 4–7 人日。
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| FILE-001 | 私有资料存储方案 | 区分公开头像与私有证件;确定文件白名单、大小限制、存储密钥、附件元数据与保留策略。 | P0 | BASE-001 | 1–1.5 | 阻塞 |
| FILE-002 | 私有上传与下载 | 持久附件ID、归属校验、大小/MIME/内容校验、鉴权读取、失败重试和删除策略。 | P0 | FILE-001、AUTH-002 | 1–2 | 未开始 |
| FILE-003 | 协议与授权版本 | 协议全文展示、同意/撤销事件、范围与协议版本、历史快照及外部授权凭据边界。 | P0 | FILE-001、AUTH-002 | 1–2 | 阻塞 |
| FILE-004 | 资料授权页面联调 | 替换选图预览和占位协议;展示上传/授权状态;拒绝、取消、断网恢复。 | P0 | FILE-002、FILE-003 | 1–1.5 | 未开始 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- |
| FILE-001 | 全栈开发(待指定) | 私有文件不在公开web目录;形成文件用途/权限/生命周期配置清单。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| FILE-002 | 全栈开发(待指定) | 无登录/跨用户不能读取;客户端临时路径不能提交为附件;失效链接不可访问。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| FILE-003 | 全栈开发(待指定) | 提交可追溯所见协议版本;撤销阻止新查询;前端布尔勾选不冒充第三方授权。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| FILE-004 | 全栈开发(待指定) | 仅已上传且有权访问的附件入评估;拒绝可选授权仍可自填,不显示已查询。 | 未验收;无真实业务验收证据 | 2026-09-22 |
直接阻塞:FILE-001 → EXT-STORAGE、DEC-PRIVACY;FILE-003 → DEC-PRIVACY。
### ASSESS|评估与融资档案
归属 M2,剩余 5–8 人日。
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| ASSESS-001 | 评估字段与增量模型 | 确定枚举、地区代码、经营月数、附件引用、授权快照和前后端完整字段映射。 | P0 | BASE-001 | 1–2 | 未开始 |
| ASSESS-002 | 云端草稿与档案 | 新建/保存/读取草稿、版本冲突、列表、复制既往资料新建评估及用户隔离。 | P0 | ASSESS-001、AUTH-002 | 1–2 | 未开始 |
| ASSESS-003 | 提交与生命周期API | 接入submit/owned/retry,服务端校验;持久保存每次提交/重试请求键、参数摘要与taskId,返回原接受响应。 | P0 | ASSESS-002、FILE-004 | 1.5–2 | 未开始 |
| ASSESS-004 | 评估全页面联调 | 评估/确认/分析/档案传递assessmentId;处理金额、退出续填、并发及历史跳转。 | P0 | ASSESS-003 | 1.5–2 | 未开始 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- |
| ASSESS-001 | 全栈开发(待指定) | 未知与0区分;个人/企业必填口径明确;新增字段列独立迁移,不重建结果表。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| ASSESS-002 | 全栈开发(待指定) | 同用户可有多次同类型评估;不同账号不能读写;历史输入不被编辑覆盖。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| ASSESS-003 | 全栈开发(待指定) | 同requestId只提交一次;多次重试后回放旧键仍返回原taskId;旧任务不可回写,完成方法不对客户端开放。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| ASSESS-004 | 全栈开发(待指定) | 同一评估ID贯穿流程;0.800001万元精确提交为8000.01元;处理中不显示假报告。 | 未验收;无真实业务验收证据 | 2026-09-22 |
直接外部/业务阻塞:无;仍须满足前置任务。
### DATA|外部征信或数据查询
归属 M2,剩余 6–12 人日。
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| DATA-001 | 查询渠道与范围定稿 | 确认实际可开通数据产品、个人/企业范围、授权凭证、测试样例、计费与返回解释。 | P0 | BASE-001 | 1–2 | 阻塞 |
| DATA-002 | 授权查询适配与记录 | 服务端发起、请求唯一键、渠道凭证隔离、查询事件持久化及assessmentId绑定。 | P0 | DATA-001、FILE-003、ASSESS-003 | 2–4 | 未开始 |
| DATA-003 | 回调与结果标准化 | 验签、防重放、回调幂等、主动查询对账、结果字段/来源/时间映射与受限留存。 | P0 | DATA-002 | 2–4 | 未开始 |
| DATA-004 | 异常与渠道端到端验收 | 超时、撤销、拒绝、余额不足、重复回调、重查和H5往返恢复。 | P0 | DATA-003 | 1–2 | 未开始 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- |
| DATA-001 | 全栈开发(待指定) | 每种首发授权都有明确数据来源和支持范围;缺失渠道记阻塞并更新估算。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| DATA-002 | 全栈开发(待指定) | 无有效授权不调用;重复发起不重复建本地请求;不得将他人结果用于本次评估。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| DATA-003 | 全栈开发(待指定) | 伪造/过期回调拒绝;迟到结果不覆盖冻结输入;查询失败不转换成零风险。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| DATA-004 | 全栈开发(待指定) | 成功和失败均可追踪;超时收费不明先对账;各已承诺首发场景有真实渠道证据。 | 未验收;无真实业务验收证据 | 2026-09-22 |
直接阻塞:DATA-001 → EXT-DATA、DEC-QUERY。
### PRODUCT|产品库与匹配规则
归属 M2,剩余 6–9 人日。
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| PRODUCT-001 | 规则字典与评分口径 | 统一硬准入、未知值处理、补充规则白名单、匹配评分和理由;准备人工确认样例。 | P0 | ASSESS-001 | 1.5–2 | 阻塞 |
| PRODUCT-002 | 准入与排序执行器 | 筛选上架/有效期/地区/对象及硬条件;执行补充规则、排序并输出可解释理由。 | P0 | PRODUCT-001 | 2–3 | 未开始 |
| PRODUCT-003 | 产品后台与公共查询 | 补齐规则编辑校验、发布预检、产品列表/详情与范围字段;录入真实测试产品。 | P0 | PRODUCT-001、BASE-002 | 1.5–2 | 阻塞 |
| PRODUCT-004 | 匹配页面与快照 | 独立MatchResult类型、额度/期限/利率范围、匹配列表排序、产品分享与申请准备。 | P0 | PRODUCT-002、PRODUCT-003、ASSESS-004 | 1–2 | 未开始 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- |
| PRODUCT-001 | 全栈开发(待指定) | 版本化规则与样例可审阅;地区/经营时间可确定比较;规则不执行任意表达式。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| PRODUCT-002 | 全栈开发(待指定) | 下架/过期/硬条件不符不推荐;缺失条件不当已满足;同数据同版本结果稳定。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| PRODUCT-003 | 全栈开发(待指定) | 上架完整校验;版本递增;公共详情无用户分数;真实产品有运营确认依据。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| PRODUCT-004 | 全栈开发(待指定) | 按assessmentId显示;参数历史快照稳定;当前不可用产品清晰提示;无匹配有说明。 | 未验收;无真实业务验收证据 | 2026-09-22 |
直接阻塞:PRODUCT-001 → DEC-MATCH;PRODUCT-003 → DEC-PRODUCT。
### REPORT|AI任务、报告与历史
归属 M2,剩余 8–12 人日。
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| REPORT-001 | DeepSeek适配与配额 | 配置模型标识、服务端密钥读取、超时、结构化输出校验、配额预留和消耗记录。 | P0 | BASE-002 | 1.5–2 | 阻塞 |
| REPORT-002 | 异步任务与补偿 | think-queue/Redis业务Job、提交后派发、漏投扫描、租约/超时、重试和任务隔离。 | P0 | ASSESS-003、REPORT-001 | 2.5–4 | 未开始 |
| REPORT-003 | 报告生成与持久化 | 冻结已授权查询结果和产品匹配快照,调用AI解释,原子写report_data/match_result及状态。 | P0 | REPORT-002、DATA-004、PRODUCT-004 | 2–3 | 阻塞 |
| REPORT-004 | 分析报告历史联调 | 状态轮询、刷新恢复、失败重试、真实雷达维度、历史详情和模型版本展示。 | P0 | REPORT-003、ASSESS-004 | 2–3 | 未开始 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- |
| REPORT-001 | 全栈开发(待指定) | 模型可配置;密钥不下发;失败与使用量可追踪;数值事实不被模型改写。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| REPORT-002 | 全栈开发(待指定) | 派发失败/worker重启可恢复;旧taskId不能落结果;重复消费不重复完成。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| REPORT-003 | 全栈开发(待指定) | 结构无效不得记成功;评分/证据来源明确;产品改动不改历史;部分资料缺失有标注。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| REPORT-004 | 全栈开发(待指定) | 个人/企业各完成一条真实闭环;分析失败不会跳成功报告;再次评估生成独立记录。 | 未验收;无真实业务验收证据 | 2026-09-22 |
直接阻塞:REPORT-001 → EXT-AI、DEC-AI;REPORT-003 → DEC-MATCH。
### CHAT|AI助手
归属 M3,剩余 4–7 人日。
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| CHAT-001 | 会话与消息API | 普通会话创建/列表/消息分页/归属;明确可引用评估上下文且不占用正式assessment_id。 | P1 | AUTH-002、BASE-001 | 1–1.5 | 未开始 |
| CHAT-002 | DeepSeek对话调用 | 上下文裁剪、敏感信息最小化、消息幂等、异步回复与本地配额记录。 | P1 | CHAT-001、REPORT-001、REPORT-002 | 1–2 | 未开始 |
| CHAT-003 | 助手页面接入 | chatId/requestId、历史加载、发送状态、失败重试、由报告进入解释上下文。 | P1 | CHAT-002、REPORT-004 | 1–2 | 未开始 |
| CHAT-004 | 对话异常与额度验收 | 并发消息、重复点击、额度耗尽、供应商超时及失效会话。 | P1 | CHAT-003 | 1–1.5 | 未开始 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- |
| CHAT-001 | 全栈开发(待指定) | 用户只能读自身会话;评估会话与普通会话分离;重进恢复历史。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| CHAT-002 | 全栈开发(待指定) | 同请求不重复建消息或记本地消耗;错误回复不标成功;不会发送无关证件资料。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| CHAT-003 | 全栈开发(待指定) | 切换会话不串上下文;超时保留可重试消息;重进页面能继续。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| CHAT-004 | 全栈开发(待指定) | 配额并发不超扣;供应商计费不明可对账;仅真实成功回复显示完成。 | 未验收;无真实业务验收证据 | 2026-09-22 |
直接外部/业务阻塞:无;仍须满足前置任务。
### CONTENT|内容、课堂、搜索与FAQ
归属 M3,剩余 4–6 人日。
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| CONTENT-001 | 内容模型与发布后台 | 补齐文章/课程结构、稳定章节ID、分类、封面/摘要、发布状态、FAQ类型及协议入口。 | P1 | BASE-001 | 1–1.5 | 未开始 |
| CONTENT-002 | 列表详情与首页推荐 | 新闻/文章/课堂/详情真实API、分页、富文本清洗、缺失内容与首页推荐接入。 | P1 | CONTENT-001、BASE-004 | 1–1.5 | 未开始 |
| CONTENT-003 | 云端课程进度 | 按用户/课程/章节记录完成,章节版本兼容、跨设备恢复和本机缓存策略。 | P1 | CONTENT-002、AUTH-002 | 1–1.5 | 未开始 |
| CONTENT-004 | 搜索与FAQ联调 | 真实全文字段检索、分类/分页、请求竞态、FAQ读取、无结果和断网恢复。 | P1 | CONTENT-002 | 1–1.5 | 未开始 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- |
| CONTENT-001 | 全栈开发(待指定) | 后台可发布/下架;章节重排不换ID;草稿不可公开;FAQ可维护。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| CONTENT-002 | 全栈开发(待指定) | 首页/列表/详情ID一致;无模拟兜底内容;下架详情有明确不可用状态。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| CONTENT-003 | 全栈开发(待指定) | 用户隔离;重复完成幂等;章节排序变化不串进度;跨设备一致。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| CONTENT-004 | 全栈开发(待指定) | 旧搜索响应不覆盖新关键词;返回ID可开详情;FAQ与实际业务行为一致。 | 未验收;无真实业务验收证据 | 2026-09-22 |
直接外部/业务阻塞:无;仍须满足前置任务。
### MSG|消息中心
归属 M3,剩余 2–4 人日。
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| MSG-001 | 通知事件与去重 | 定义评估结果等事件、通知去重键及写入时机,补齐唯一性保障。 | P1 | BASE-001、REPORT-004 | 0.5–1 | 未开始 |
| MSG-002 | 消息读写API | 当前用户分页/分类/未读数/单条已读/全部已读,read_at与is_read一致。 | P1 | MSG-001、AUTH-002 | 0.5–1 | 未开始 |
| MSG-003 | 消息页面与首页红点 | 替换静态通知与固定红点,处理业务跳转、空态、刷新及批量已读。 | P1 | MSG-002、BASE-004 | 0.5–1 | 未开始 |
| MSG-004 | 事件消息验收 | 事件重复、失败重试、分页期间新通知和并发全部已读测试。 | P1 | MSG-003 | 0.5–1 | 未开始 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- |
| MSG-001 | 全栈开发(待指定) | 重复任务不会重复通知;成功/失败通知关联正确assessmentId。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| MSG-002 | 全栈开发(待指定) | 跨用户读写拒绝;已读重复操作幂等;列表/计数一致。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| MSG-003 | 全栈开发(待指定) | 换设备状态一致;打开通知到对应历史;关联记录不可用有提示。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| MSG-004 | 全栈开发(待指定) | 全部已读不误吞操作后新增消息;通知去重和未读数通过数据库核对。 | 未验收;无真实业务验收证据 | 2026-09-22 |
直接外部/业务阻塞:无;仍须满足前置任务。
### POINT|积分、任务与邀请
归属 M3,剩余 4–7 人日。
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| POINT-001 | 积分规则定稿 | 确认签到、资料完成、邀请奖励的条件/次数/额度、业务时区和权益范围。 | P1 | BASE-001 | 0.5–1 | 阻塞 |
| POINT-002 | 积分账户与签到 | 事务更新score与流水、业务唯一键、服务端日期签到、余额/流水查询。 | P1 | POINT-001、AUTH-002 | 1.5–2 | 未开始 |
| POINT-003 | 邀请码与邀请关系 | 生成唯一邀请码、首次有效绑定、防自邀、可信奖励事件与发放幂等。 | P1 | POINT-002 | 1–2 | 未开始 |
| POINT-004 | 任务积分页面联调 | 真实余额、流水、签到状态、个人邀请码、任务状态和规则说明。 | P1 | POINT-003、ASSESS-004 | 1–2 | 未开始 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- |
| POINT-001 | 全栈开发(待指定) | 形成版本化活动规则;复制/分享不等于奖励;未确认奖励规则不得上线。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| POINT-002 | 全栈开发(待指定) | 同用户同日并发只加一次;流水与积分一致;错误不静默吞掉。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| POINT-003 | 全栈开发(待指定) | 客户端不能自报完成奖励;重复/自邀拒绝;已有关系不随分享覆盖。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| POINT-004 | 全栈开发(待指定) | 真实到账后才提示成功;跨账户/跨设备一致;邀请复制不立即发奖励。 | 未验收;无真实业务验收证据 | 2026-09-22 |
直接阻塞:POINT-001 → DEC-POINT。
### SUPPORT|反馈与人工客服
归属 M3,剩余 3–6 人日。
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| SUPPORT-001 | 客服方案与运营准备 | 选定H5客服、身份签名、坐席/工作时间、离线策略、反馈状态及责任人。 | P0 | BASE-001 | 0.5–1 | 阻塞 |
| SUPPORT-002 | 反馈提交与处理闭环 | 帮助页增加反馈表单/记录;附件权限、后台状态/回复、用户查看处理结果。 | P1 | AUTH-002、FILE-002 | 1–2 | 未开始 |
| SUPPORT-003 | H5人工客服接入 | 服务端签发访客身份、前端打开客服、在线/排队/离线、失败回退。 | P0 | SUPPORT-001、AUTH-002 | 1–2 | 阻塞 |
| SUPPORT-004 | 客服反馈联调 | 普通/微信内H5、账户切换、关闭重开、无坐席、SDK失败和消息记录。 | P0 | SUPPORT-002、SUPPORT-003、CONTENT-004 | 0.5–1 | 未开始 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- |
| SUPPORT-001 | 全栈开发(待指定) | 具有可测试坐席和离线处理渠道;确认SDK/隐私及首发支持范围。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| SUPPORT-002 | 全栈开发(待指定) | 反馈可提交到后台并回复;仅当前用户可查;附件无越权。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| SUPPORT-003 | 全栈开发(待指定) | 真实到达人工坐席;不由前端伪造用户身份;离线可留言并有处理路径。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| SUPPORT-004 | 全栈开发(待指定) | 会话无跨账户泄漏;客户可获回复;供应商不支持项准确提示。 | 未验收;无真实业务验收证据 | 2026-09-22 |
直接阻塞:SUPPORT-001 → EXT-CS;SUPPORT-003 → EXT-CS。
### ADMIN|后台运营与排障
归属 M3,剩余 3–5 人日。
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| ADMIN-001 | 菜单与角色权限 | 将新增运营能力纳入现有管理员权限;明确运营/客服/技术可见字段。 | P0 | BASE-001 | 0.5–1 | 未开始 |
| ADMIN-002 | 任务与查询排障 | 按assessmentId/taskId/requestId查进度/错误、人工重试/对账入口及操作审计。 | P1 | REPORT-004、DATA-004、ADMIN-001 | 1–1.5 | 未开始 |
| ADMIN-003 | 模型额度与服务检查 | DeepSeek配置、密钥状态、用户额度、调用消耗、队列/存储/短信状态检查。 | P1 | REPORT-001、CHAT-004、ADMIN-001 | 1–1.5 | 未开始 |
| ADMIN-004 | 运营交接验收 | 真实产品、内容、课程、积分、消息、反馈及客服操作演练,形成操作说明。 | P1 | ADMIN-002、ADMIN-003、CONTENT-004、MSG-004、POINT-004、SUPPORT-004 | 0.5–1 | 未开始 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- |
| ADMIN-001 | 全栈开发(待指定) | 管理员权限最小化;无需恢复已删除会员组/规则;敏感字段继续掩码。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| ADMIN-002 | 全栈开发(待指定) | 可定位失败原因;重试仍走生命周期和幂等;不允许后台手改正式结果。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| ADMIN-003 | 全栈开发(待指定) | 密钥不回显;配置失败可定位;本地消耗与渠道对账有入口。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| ADMIN-004 | 全栈开发(待指定) | 运营可独立发布/下架/处理反馈及定位异常;业务字段无占位数据。 | 未验收;无真实业务验收证据 | 2026-09-22 |
直接外部/业务阻塞:无;仍须满足前置任务。
### RELEASE|联调、测试与发布
归属 M4,剩余 7–11 人日。
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| RELEASE-001 | 核心与权限回归 | 完整个人/企业流程、跨用户攻击、金额边界、幂等、产品变更及并发用例。 | P0 | REPORT-004、LOGIN-004、AUTH-004、ADMIN-004 | 2–3 | 未开始 |
| RELEASE-002 | H5与第三方真实验收 | 普通手机/桌面/微信内浏览器,键盘/上传/回跳/断网/弱网及全部渠道成功失败验证。 | P0 | RELEASE-001 | 2–3 | 未开始 |
| RELEASE-003 | 部署与恢复演练 | 分环境配置、增量迁移预检、HTTPS、队列守护/告警、备份恢复、发布回滚手册。 | P0 | RELEASE-001、ADMIN-003 | 1.5–2.5 | 阻塞 |
| RELEASE-004 | 首发验收与发布记录 | 核对范围/外部依赖/验收证据,正式发布检查,发布后冒烟及回退条件。 | P0 | RELEASE-002、RELEASE-003 | 1.5–2.5 | 未开始 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- |
| RELEASE-001 | 全栈开发(待指定) | 关键用例通过且有证据;无阻断或高危未解决缺陷;所有一期模块进入验收。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| RELEASE-002 | 全栈开发(待指定) | 真实渠道与目标浏览器矩阵通过;不是仅编译通过或mock成功。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| RELEASE-003 | 全栈开发(待指定) | 干净测试环境可部署;有数据环境升级可恢复;不重跑旧空表限定迁移。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| RELEASE-004 | 全栈开发(待指定) | 所有一期必做项已验收;生产无demo数据/占位协议/模拟Token;记录实际版本和时间。 | 未验收;无真实业务验收证据 | 2026-09-22 |
直接阻塞:RELEASE-003 → EXT-DEPLOY。
任务状态计数(仅用于核对台账):未开始 40 项;开发中 5 项;待联调 0 项;待验收 0 项;已完成 0 项;阻塞 15 项。这些计数不换算为项目完成率。
## 5. 外部依赖、业务决策与阻塞传播
所有条目初始更新时间均为 2026-09-22。对接负责人和业务拍板人尚未落实,启动时必须补齐。只记录准备状态与非敏感标识;密钥通过环境配置交接,不粘贴进文档。
### 5.1 外部依赖
| 编号 | 当前状态 | 解除条件/需准备的材料 | 直接影响任务 | 责任分工与目标节点 |
| --- | --- | --- | --- | --- |
| EXT-SMS | 服务商待定 | 服务商、可测试账户、签名/模板、回执与限流能力、环境配置交付可用;测试号码发送成功 | AUTH-001 | 用户/业务负责选型与开通,开发负责验证;M0 发起,M1 验收前解除 |
| EXT-WX | 服务类型和账号准备待定 | 确认微信内 H5/普通 H5 登录方式、主体资质、应用/公众号权限、域名回调与测试能力 | LOGIN-001、LOGIN-002 | 业务负责账号权限,开发形成能力矩阵;M0 发起,M1 前解除 |
| EXT-ONE | 服务商及 H5 能力待定 | 明确浏览器/运营商/网络限制、合法的服务端验证接口、测试环境、SDK 和降级策略 | LOGIN-001、LOGIN-003 | 业务与开发联合选型;M1 接入前解除 |
| EXT-STORAGE | 私有存储服务待定 | 私有桶或等价私有存储、权限策略、服务端访问配置、签名链接/鉴权读取能力、容量和生命周期方案 | FILE-001 | 开发提出方案,业务确认预算与保留边界;M1 开发前解除 |
| EXT-DATA | 数据渠道待定 | 可开通数据产品、个人/企业覆盖、正式授权流程、样例与字段字典、验签/回调/对账、计费及测试权限 | DATA-001 | 业务确认可获得的产品及资质,开发验证接口;M0 发起,M2 前解除 |
| EXT-AI | DeepSeek 已选定;具体模型/额度/测试权限待核验 | 确认可用模型标识、服务端测试配置、实际额度、返回格式、超时和费用核对入口;完成最小真实调用 | REPORT-001 | 开发验证能力,业务确认预算;M2 接入前解除 |
| EXT-CS | 客服服务商和坐席待定 | 支持普通/微信内 H5 的客服方案、用户身份签名、测试坐席、工作时间与离线接待责任人 | SUPPORT-001、SUPPORT-003 | 业务负责供应商与坐席,开发负责接入;M3 前解除 |
| EXT-DEPLOY | 部署目标及域名资源待定 | 测试/生产资源、域名/HTTPS、数据库/Redis、队列进程守护、日志监控、备份位置及恢复权限 | RELEASE-003 | 业务确认资源,开发落地运维;M0 收集,M4 演练前解除 |
“DeepSeek 已选定”只表示供应商选择完成,不代表 EXT-AI 已解除。其他服务商未定不是本轮文档交付的阻塞,但确实阻塞对应真实接入任务及整体一期验收。
### 5.2 业务规则决策
| 编号 | 当前状态 | 必须定稿的内容 | 决策产物与直接影响任务 | 目标节点 |
| --- | --- | --- | --- | --- |
| DEC-PRIVACY | 待确认 | 资料用途、可选/必选项、协议正式全文与版本、保存期限、撤销后处理及对外字段范围 | 协议与资料生命周期清单;FILE-001、FILE-003 | M1 私有资料/授权实现前 |
| DEC-QUERY | 待确认 | 首发查询项目、个人/企业适用条件、查询失败/拒绝后的自填路径、重新查询触发和费用归属 | 数据产品与页面授权逐项映射;DATA-001 | M2 渠道实现前 |
| DEC-MATCH | 待确认 | 硬准入、未知值、评分维度/权重、等级阈值、排序同分规则、报告雷达维度和人工金标准样例 | 版本化规则及人工确认样例;PRODUCT-001、REPORT-003 | M2 匹配引擎和正式报告实现前 |
| DEC-PRODUCT | 待确认 | 首发真实产品、地区与对象、额度/期限/利率/准入条件、有效期、发布责任人、申请准备说明 | 可录入的真实产品资料及运营确认记录;PRODUCT-003 | 产品发布/匹配真实验收前 |
| DEC-AI | 待确认 | 具体模型、单用户/单评估额度、重试上限、超时、输入最小化、月预算及超额处理 | AI 配置和额度规则;REPORT-001 | DeepSeek 接入和压测前 |
| DEC-POINT | 待确认 | 签到/资料/邀请奖励数额、次数、可信达成条件、业务时区、有效期与权益范围 | 版本化奖励规则及反例;POINT-001 | M3 积分实现前 |
上述决定没有在本台账中擅自填入分数、积分数额或第三方费用。它们本身属于对应已有任务的工作,不新增第 61 项开发任务。涉及新增表或字段时,先在相应任务记录增量方案、回填与恢复步骤,再实施迁移。
### 5.3 依赖传播与解除记录
- AUTH-001 未解除会影响真实账户、上传、评估、用户内容和其他登录渠道的完整联调。可先明确公共契约和已有密码登录适配,但不能标记整个 AUTH-002 完成。
- FILE-001/FILE-003 及 DATA-001 未解除会使正式资料授权、外部查询、报告生成和发布验收无法闭环。不能用本机勾选或模拟返回充当渠道验收。
- PRODUCT-001/PRODUCT-003、REPORT-001/REPORT-003 未解除时,允许准备确定性规则骨架和报告结构,不能将模拟评分、产品或 AI 文本判为真实报告验收通过。
- LOGIN、POINT 和 SUPPORT 的直接阻塞不妨碍其他独立模块开发,但它们仍在 M4 一期验收门槛内。
- EXT-DEPLOY 不阻止本地实现,但 RELEASE-003/RELEASE-004 不能完成。任何外部项长期不可获得,都须由用户明确调整范围或顺序并更新两份文档,不能自动删项。
阻塞解除时在下面追加记录,并同时更新任务状态、更新时间及依赖模块;不要删除历史原因。
| 日期 | 阻塞编号 | 原因/当前进展 | 责任人 | 预计解除日 | 解除依据/证据 | 受影响任务与计划调整 |
| --- | --- | --- | --- | --- | --- | --- |
| 2026-09-22 | EXT-AI | 已选 DeepSeek,模型、预算、额度与测试接入待核验 | 待指定 | 待确认 | 用户已确认供应商;无真实调用证据 | REPORT-001 保持阻塞,其他依赖按台账推进 |
| 2026-09-22 | 其余 EXT / 全部 DEC | 服务资源或正式业务口径待补齐 | 待指定 | 待确认 | 本轮计划基线,无已解除证据 | 逐项维护,不合并视为可跳过 |
## 6. 测试与验收台账
测试状态单独使用“历史通过(待回归)”“未执行”“通过”“失败”,不替代任务状态。每次记录必须包含日期、环境/构建版本、数据来源、结果、证据链接和验收人;没有真实外部证据不得填写“真实渠道通过”。
### 6.1 已有验证记录
| 验证项目 | 执行日期 | 环境 | 状态 | 证据与限制 | 后续归属 |
| --- | --- | --- | --- | --- | --- |
| 41项基础测试 | 2026-09-22,本轮回归 | 本地PHP 7.4 / MySQL 8.0.44,外层事务回滚 | 通过 | tests/finance.php;Redis测试会话清理,不含全部一期真实接口 | RELEASE-001 |
| 14项Redis测试 | 2026-09-22 | 本地Redis、随机隔离前缀 | 通过 | tests/token_redis.php;TTL、索引、撤销、用户隔离、Redis不可用不降级 | BASE-002、AUTH-002 |
| 16项HTTP会话测试 | 2026-09-22 | 本地Nginx/PHP/Redis | 通过 | tests/api_session.php;含特殊字符密码、非法Bearer、单会话登出、改密撤销两个有效Token、短信503 | AUTH-002、AUTH-004 |
| Web / Android本地编译 | 2026-09-22,本轮 | Windows / HBuilderX 5.26 | 通过 | Web原有layout.uts等类型提示;Android回调类型已修复;未验证真机或正式包 | RELEASE-002 |
| 账户H5联调 | 2026-09-22 | http://localhost:5174/;Chromium 390×844、1280×900 | 已测范围通过 | 密码登录、底栏进入个人中心、确认退出、A/B草稿隔离;短信503提示且无倒计时 | BASE-003、AUTH-002、RELEASE-002 |
| 金额相关浏览器检查 | 2026-09-22,前序任务 | 本地 Web;390×844 和 1280×900 | 历史通过(待回归) | 金额输入、超限、确认、模拟提交、产品展示,最终控制台无错误/警告;未留独立可链接归档 | ASSESS-004、PRODUCT-004、RELEASE-002 |
### 6.2 一期验收矩阵
本轮浏览器证据与复核范围:使用Playwright CLI(`Browser plugin not available`),目标为真实密码登录→首页→底栏“我的”→确认退出→登录页,另验证A/B草稿隔离及短信未接入提示。页面标题“AI融资”与路由正确、非空、无框架错误遮罩;桌面及手机视口无本轮新增遮挡。正常登录/退出控制台无错误或警告;短信场景唯一错误为预期的HTTP 503资源日志。早期脚本直接改hash使弹框检查失效,改用实际底栏导航后完整退出流程通过,未修改框架。
截图:[登录后个人中心](<C:/Users/JUN/AppData/Local/Temp/airz-redis-qa/profile-mobile.png>)、[切换账户后的空白草稿](<C:/Users/JUN/AppData/Local/Temp/airz-redis-qa/account-isolation-mobile.png>)、[短信未配置手机提示](<C:/Users/JUN/AppData/Local/Temp/airz-redis-qa/sms-unavailable-mobile.png>)、[桌面提示](<C:/Users/JUN/AppData/Local/Temp/airz-redis-qa/sms-unavailable-desktop.png>)。位于本机临时目录,清理后可能失效,正式验收应另行归档。两条临时合成账户及其Redis会话已清理;复核数据库25表、Token表不存在。未覆盖微信内置浏览器、Safari、真机、正式发行、真实短信/AI或产品链路。接口复核命令为`php tests/api_session.php`,驱动为`php tests/token_redis.php`;前端编译命令见开发文档8.2。
以下为一期完整验收条件。第6.1节已测子集不代表整行完成;尚未完整覆盖的场景保留未执行。执行时用实际日期、环境和证据替换占位单元格;同一场景多端验证可追加多行。
| 场景 | 关联任务 | 必须验证的结果 | 日期 / 环境 | 状态 | 证据 / 验收人 |
| --- | --- | --- | --- | --- | --- |
| 请求与 Token | BASE-002、AUTH-002 | HTTP 200 业务错误不能被当成功;过期/撤销 Token 拒绝;错误可定位 requestId | 待记录 / 测试 API + H5 | 未执行 | 待补充 / 待指定 |
| 账户隔离 | BASE-003、ASSESS-002、CHAT-001、MSG-002 | A/B 用户切换不串草稿、消息、聊天、资料和历史;跨用户 ID 读写失败 | 待记录 / 至少两个测试账户 | 未执行 | 待补充 / 待指定 |
| 短信与登录 | AUTH-001、AUTH-002 | 真短信成功/失败、用途隔离、频控、过期和核销;无假 Token | 待记录 / 真实短信测试权限 | 未执行 | 待补充 / 待指定 |
| 改密与换绑 | AUTH-004 | 原/新手机双挑战,号码占用拒绝,事务一致,修改后全部旧会话失效 | 待记录 / 测试 API + 双账户 | 未执行 | 待补充 / 待指定 |
| 微信授权 | LOGIN-002、LOGIN-004 | 获批 H5 场景真实成功;取消/state 错误/code 重放/已有账号冲突安全处理 | 待记录 / 微信内与支持的普通 H5 | 未执行 | 待补充 / 待指定 |
| 一键登录 | LOGIN-003、LOGIN-004 | 独立流程初始化、过期/重复交换;服务端验凭证;支持矩阵真实通过;绑手机专用挑战不跨用途复用;不支持时短信回退 | 待记录 / 渠道支持设备与网络 | 未执行 | 待补充 / 待指定 |
| 私有资料权限 | FILE-002、FILE-004 | 跨用户/未登录下载失败;大小/MIME/内容校验;临时路径不可提交;过期链接失效 | 待记录 / 私有测试存储 | 未执行 | 待补充 / 待指定 |
| 授权版本与撤销 | FILE-003、DATA-002 | 可追溯所见协议版本;撤销后不发新查询;用户拒绝可选授权有可用自填路径 | 待记录 / 授权服务 + H5 | 未执行 | 待补充 / 待指定 |
| 金额精度与边界 | ASSESS-004、PRODUCT-004 | 0.800001 万元提交为 8000.01 元;0/上限/超限/空值区分;万元展示无重复换算 | 待记录 / API + 数据库 + H5 | 未执行 | 待补充 / 待指定 |
| 多次评估与历史 | ASSESS-002、REPORT-004 | 同用户同类型可多次评估;已提交输入不可覆盖;再次评估创建独立记录 | 待记录 / 个人、企业各至少两次 | 未执行 | 待补充 / 待指定 |
| 提交与重试幂等 | ASSESS-003、REPORT-002 | 重复 requestId 只提交一次;多次重试后回放旧键仍返回原 taskId;旧任务不能回写;重复消费无重复完成 | 待记录 / API + worker 并发 | 未执行 | 待补充 / 待指定 |
| 外部查询与收费不明 | DATA-003、DATA-004 | 真结果与字段来源可核对;伪造/重放回调拒绝;重复回调幂等;超时先对账;失败不转成零风险 | 待记录 / 真实数据测试渠道 | 未执行 | 待补充 / 待指定 |
| 确定性匹配 | PRODUCT-002、PRODUCT-004 | 下架、过期、地区/硬条件不符不推荐;未知不当已满足;人工样例与版本结果一致 | 待记录 / 真实产品 + 人工样例 | 未执行 | 待补充 / 待指定 |
| 产品历史快照 | PRODUCT-004、REPORT-003 | 更改产品额度/利率/版本后历史报告仍保留原快照;当前下架有提示 | 待记录 / 产品版本变更用例 | 未执行 | 待补充 / 待指定 |
| DeepSeek 报告 | REPORT-001、REPORT-003、REPORT-004 | 真实模型调用;结构化输出校验;评分/事实来源明确;个人/企业各完成一条全链路 | 待记录 / DeepSeek 测试配置 + H5 | 未执行 | 待补充 / 待指定 |
| 队列故障恢复 | REPORT-002、ADMIN-002 | 派发失败、worker 崩溃、超时/迟到结果、漏投扫描均可恢复且不写错任务 | 待记录 / 可控测试 worker/Redis | 未执行 | 待补充 / 待指定 |
| AI 助手与额度 | CHAT-004、ADMIN-003 | 消息幂等、上下文隔离、历史分页、并发配额、额度耗尽、供应商超时及对账可用 | 待记录 / 测试会话 + DeepSeek | 未执行 | 待补充 / 待指定 |
| 内容/课程/搜索 | CONTENT-002、CONTENT-003、CONTENT-004 | ID 可到真实详情;下架不公开;章节重排不串进度;跨设备一致;搜索旧响应不覆盖新关键词 | 待记录 / 内容后台 + 两个浏览器 | 未执行 | 待补充 / 待指定 |
| 消息事件与已读 | MSG-004 | 重复任务只发一条通知;关联正确历史;全部已读不误吞操作后新消息 | 待记录 / 并发事件 + 数据库核对 | 未执行 | 待补充 / 待指定 |
| 积分与邀请并发 | POINT-002、POINT-003、POINT-004 | 同日并发签到一次;流水与余额一致;禁止自邀/重复关系;客户端不能自报奖励成功 | 待记录 / 多请求、多账户 | 未执行 | 待补充 / 待指定 |
| 反馈与人工客服 | SUPPORT-004 | 反馈到后台并回复;真人坐席可接待;离线可留言;SDK 失败回退;账户切换无会话泄漏 | 待记录 / 真实客服坐席 + H5 | 未执行 | 待补充 / 待指定 |
| 后台运营与权限 | ADMIN-004 | 运营可发布/下架和处理反馈;客服无技术密钥权限;任务重试走统一幂等入口;密钥不回显 | 待记录 / 运营、客服、技术角色 | 未执行 | 待补充 / 待指定 |
| H5 浏览器与弱网 | RELEASE-002 | 普通移动 H5、桌面 H5、微信内 H5 分别检查键盘、上传、回跳、刷新、横屏、安全区、断网恢复 | 待记录 / 实际浏览器版本与设备 | 未执行 | 待补充 / 待指定 |
| 部署/迁移/恢复 | RELEASE-003 | 干净环境部署、有数据增量迁移、密钥备份、队列守护、告警和恢复演练;不盲目重跑旧迁移 | 待记录 / 独立测试部署环境 | 未执行 | 待补充 / 待指定 |
| 首发完整性 | RELEASE-004 | 全部一期必做项已验收,25 页有真实数据来源;无模拟 Token、demo 数据、占位协议;发布后冒烟和回退依据完整 | 待记录 / 候选版本及正式环境 | 未执行 | 待补充 / 待指定 |
### 6.3 缺陷与发布记录模板
| 发现日期 | 关联任务 | 环境/版本 | 问题与复现步骤 | 严重程度 | 负责人 | 修复依据 | 回归结果/日期 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 2026-09-22 | AUTH-002 | HBuilderX 5.26 / Android编译 | 退出确认的success回调不能使用async返回Promise | 编译阻断 | 全栈开发 | profile.uvue使用同步回调内then/catch | 修复后编译通过;H5退出通过 |
发布前存在阻断或高危未解决缺陷、必做渠道无真实验收、资料越权、金额错误、重复积分或任务污染时,不通过 RELEASE-004。其他缺陷须明确影响、处理方案和验收决定,不以“页面可打开”代替业务验收。
| 候选版本/提交 | 计划日期 | 实际日期 | 配置/迁移版本 | 验收人 | 发布与冒烟证据 | 回退条件/恢复点 | 结果 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 待生成 | 待里程碑就绪后确定 | 未发布 | 待记录 | 待指定 | 未执行 | 按 RELEASE-003 演练结果补齐 | 未开始 |
## 7. 更新日志与下一步
| 日期 | 更新内容 | 任务状态变化 | 验证范围 | 下一步 |
| --- | --- | --- | --- | --- |
| 2026-09-22 | Redis切换与M0/账户基础启动;Token表备份删除,真实密码会话与缓存隔离 | BASE-001/002/003、AUTH-002/004改开发中,外部依赖保留阻塞 | 41项基础+14项Redis+16项HTTP,Web/Android编译,H5退出/隔离/短信失败 | 继续完善BASE-001/003及资料API,供应商未就绪不阻塞内部开发 |
| 2026-09-22 | 建立开发文档和进度基线;登记 15 模块、60 任务、25 页映射及外部/业务阻塞 | 业务任务按基线记录;本次没有把文档编写计为业务完成 | 文档任务编号、估算、页面范围与相互引用静态核对;无业务测试或迁移 | 从 BASE-001 开始,M0 同步推进选型、资源和业务规则准备 |
后续推进顺序(已启动项继续收口):
1. BASE-001:冻结路由、响应、Token、ID、金额、时间和字段映射样例;同步指定实际开发和验收负责人。
2. BASE-002、BASE-003:完成统一请求与环境/缓存隔离;满足依赖后推进 BASE-004。生产环境不得继续以空 API 地址进入模拟流程。
3. 在 M0 同步收集第 5 节外部资源与业务决定。DeepSeek 重点核验模型、测试权限和预算;短信、微信/一键、查询、客服及私有存储从选型与能力清单开始,不要求把密钥填写在文档。
4. BASE-001 完成后,可穿插 ASSESS-001、CONTENT-001、ADMIN-001 等不依赖真实渠道的工作;保持它们原有任务工时和阶段归属,避免重复计时。
5. 外部前置就绪后推进 AUTH/LOGIN/FILE,再完成 ASSESS→DATA/PRODUCT→REPORT 核心闭环,随后按 M3、M4 验收全部首发功能。
每次完成开发活动,追加一行更新日志并同步相应任务的证据、状态和日期。需要调整一期范围或工时必须在两份文档中一起记录;第三方未接通时保留阻塞,不以模拟替代验收。
+3 -3
View File
@@ -24,12 +24,12 @@
<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, saveSession } from '@/packages/account/services/auth.uts' import { API_BASE_URL, requestSmsCode, loginWithSms, loginWithPassword } from '@/packages/account/services/auth.uts'
import { showAgreementNotice } from '@/services/assessment.uts' import { showAgreementNotice } from '@/services/assessment.uts'
import { consumeInitialLaunch } from '@/services/launch.uts' import { consumeInitialLaunch } from '@/services/launch.uts'
onLoad(() => { consumeInitialLaunch() }) onLoad(() => { consumeInitialLaunch() })
const { statusStyle, contentStyle, pageStyle } = usePageLayout() const { statusStyle, contentStyle, pageStyle } = usePageLayout()
const mode = ref('phone') const mode = ref('password')
const phone = ref('') const phone = ref('')
const password = ref('') const password = ref('')
const smsCode = ref('') const smsCode = ref('')
@@ -58,7 +58,7 @@
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 finishLogin = (token: string) => { saveSession(token, phone.value); uni.redirectTo({ url: '/pages/home/home' }) } const finishLogin = (_token: string) => { uni.redirectTo({ url: '/pages/home/home' }) }
const resendSms = async () => { const resendSms = async () => {
if (loading.value || countdown.value > 0 || !validPhone()) return if (loading.value || countdown.value > 0 || !validPhone()) return
if (!agreed.value) { errorMessage.value = '请先阅读并勾选用户协议与隐私政策'; return } if (!agreed.value) { errorMessage.value = '请先阅读并勾选用户协议与隐私政策'; return }
+4 -2
View File
@@ -19,6 +19,8 @@
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { getUserStorage, setUserStorage } from '@/services/storage.uts'
import { usePageLayout } from '@/services/layout.uts' import { usePageLayout } from '@/services/layout.uts'
import AppHeader from '@/components/AppHeader.uvue' import AppHeader from '@/components/AppHeader.uvue'
import BottomNav from '@/components/BottomNav.uvue' import BottomNav from '@/components/BottomNav.uvue'
@@ -36,14 +38,14 @@
const unreadCount = computed((): number => notices.value.filter((item: Notice): boolean => item.unread).length) const unreadCount = computed((): number => notices.value.filter((item: Notice): boolean => item.unread).length)
onShow(() => { onShow(() => {
notices.value.forEach((item: Notice): void => { notices.value.forEach((item: Notice): void => {
if (uni.getStorageSync('finance.notice.read.' + item.id) == true) item.unread = false if (getUserStorage('finance.notice.read.' + item.id) == true) item.unread = false
}) })
}) })
const markRead = (id: string): void => { const markRead = (id: string): void => {
const item = notices.value.find((value: Notice): boolean => value.id == id) const item = notices.value.find((value: Notice): boolean => value.id == id)
if (item == null) return if (item == null) return
item.unread = false item.unread = false
uni.setStorageSync('finance.notice.read.' + id, true) setUserStorage('finance.notice.read.' + id, true)
} }
const openNotice = (item: Notice): void => { const openNotice = (item: Notice): void => {
markRead(item.id) markRead(item.id)
+4 -2
View File
@@ -32,6 +32,8 @@
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { getUserStorage, setUserStorage } from '@/services/storage.uts'
import { usePageLayout } from '@/services/layout.uts' import { usePageLayout } from '@/services/layout.uts'
import AppHeader from '@/components/AppHeader.uvue' import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue' import AppIcon from '@/components/AppIcon.uvue'
@@ -58,7 +60,7 @@
} }
const restore = (): void => { const restore = (): void => {
today.value = localDate() today.value = localDate()
const stored = uni.getStorageSync('finance.points.checkin.days') const stored = getUserStorage('finance.points.checkin.days')
if (typeof stored != 'string' || stored == '') { signDays.value = []; return } if (typeof stored != 'string' || stored == '') { signDays.value = []; return }
const restored = (stored as string).split(',') const restored = (stored as string).split(',')
const unique: string[] = [] const unique: string[] = []
@@ -71,7 +73,7 @@
if (signedToday.value) { uni.showToast({ title: '今天已签到,明天再来', icon: 'none' }); return } if (signedToday.value) { uni.showToast({ title: '今天已签到,明天再来', icon: 'none' }); return }
const next = signDays.value.slice() const next = signDays.value.slice()
next.unshift(today.value) next.unshift(today.value)
uni.setStorageSync('finance.points.checkin.days', next.join(',')) setUserStorage('finance.points.checkin.days', next.join(','))
signDays.value = next signDays.value = next
recordsExpanded.value = true recordsExpanded.value = true
uni.showToast({ title: '签到完成,体验积分 +10', icon: 'none' }) uni.showToast({ title: '签到完成,体验积分 +10', icon: 'none' })
+10 -4
View File
@@ -15,11 +15,14 @@
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { getUserStorage, setUserStorage } from '@/services/storage.uts'
import { usePageLayout } from '@/services/layout.uts' import { usePageLayout } from '@/services/layout.uts'
import AppHeader from '@/components/AppHeader.uvue' import AppHeader from '@/components/AppHeader.uvue'
import BottomNav from '@/components/BottomNav.uvue' import BottomNav from '@/components/BottomNav.uvue'
import AppIcon from '@/components/AppIcon.uvue' import AppIcon from '@/components/AppIcon.uvue'
import { clearSession, getSession } from '@/services/api.uts' import { getSession } from '@/services/api.uts'
import { logoutAccount } from '@/packages/account/services/auth.uts'
import { activeAssessmentType, assessmentProgress, showAgreementNotice } from '@/services/assessment.uts' import { activeAssessmentType, assessmentProgress, showAgreementNotice } from '@/services/assessment.uts'
import { DEFAULT_AVATAR, getAccountProfile, maskPhone } from '@/packages/account/services/account.uts' import { DEFAULT_AVATAR, getAccountProfile, maskPhone } from '@/packages/account/services/account.uts'
const { contentStyle, pageStyle } = usePageLayout() const { contentStyle, pageStyle } = usePageLayout()
@@ -39,7 +42,7 @@
const type = activeAssessmentType() const type = activeAssessmentType()
displayName.value = loggedIn.value ? profile.nickname : '登录 / 注册' displayName.value = loggedIn.value ? profile.nickname : '登录 / 注册'
completion.value = assessmentProgress(type).percent completion.value = assessmentProgress(type).percent
const latest = uni.getStorageSync('latestAssessment') const latest = getUserStorage('latestAssessment')
hasReport.value = latest != null && typeof latest == 'object' hasReport.value = latest != null && typeof latest == 'object'
}) })
const go = (url: string) => { uni.navigateTo({ url }) } const go = (url: string) => { uni.navigateTo({ url }) }
@@ -48,13 +51,16 @@
if (key == 'archive') go('/packages/finance/archive/archive') if (key == 'archive') go('/packages/finance/archive/archive')
else if (key == 'help') go('/packages/content/help/help') else if (key == 'help') go('/packages/content/help/help')
else if (key == 'security') go(loggedIn.value ? '/packages/account/security/security' : '/packages/account/login/login') else if (key == 'security') go(loggedIn.value ? '/packages/account/security/security' : '/packages/account/login/login')
else if (key == 'privacy') uni.showActionSheet({ itemList: ['查看隐私说明', '管理资料与授权'], success: (result) => { if (result.tapIndex == 0) showAgreementNotice('隐私说明'); else if (result.tapIndex == 1) { uni.setStorageSync('assessmentEditStep', 3); go('/packages/finance/assessment/assessment?type=' + activeAssessmentType()) } } }) else if (key == 'privacy') uni.showActionSheet({ itemList: ['查看隐私说明', '管理资料与授权'], success: (result) => { if (result.tapIndex == 0) showAgreementNotice('隐私说明'); else if (result.tapIndex == 1) { setUserStorage('assessmentEditStep', 3); go('/packages/finance/assessment/assessment?type=' + activeAssessmentType()) } } })
else if (key == 'about') uni.showModal({ title: '关于 AI融资', content: '版本 V1.0.0\n帮助您整理融资信息、了解融资知识与融资方案。评估结果仅供参考,不构成授信承诺。', showCancel: false, confirmText: '我知道了' }) else if (key == 'about') uni.showModal({ title: '关于 AI融资', content: '版本 V1.0.0\n帮助您整理融资信息、了解融资知识与融资方案。评估结果仅供参考,不构成授信承诺。', showCancel: false, confirmText: '我知道了' })
} }
const openAccount = () => { go(loggedIn.value ? '/packages/account/edit-profile/edit-profile' : '/packages/account/login/login') } const openAccount = () => { go(loggedIn.value ? '/packages/account/edit-profile/edit-profile' : '/packages/account/login/login') }
const logout = () => { const logout = () => {
if (!loggedIn.value) { go('/packages/account/login/login'); return } if (!loggedIn.value) { go('/packages/account/login/login'); return }
uni.showModal({ title: '退出登录', content: '退出后,本机保存的评估草稿仍会保留。', confirmText: '退出登录', success: (result) => { if (result.confirm) { clearSession(); uni.reLaunch({ url: '/packages/account/login/login' }) } } }) uni.showModal({ title: '退出登录', content: '退出后,本机保存的评估草稿仍会保留。', confirmText: '退出登录', success: (result) => { if (result.confirm) {
logoutAccount().then(() => { uni.reLaunch({ url: '/packages/account/login/login' }) })
.catch(() => { uni.showToast({ title: '退出失败,请检查网络后重试', icon: 'none' }) })
} }, fail: (error) => { console.error('logout confirmation failed', error); uni.showToast({ title: '暂时无法打开确认框,请重试', icon: 'none' }) } })
} }
</script> </script>
+7 -16
View File
@@ -1,4 +1,5 @@
import { API_BASE_URL, getSession } from '@/services/api.uts' import { getUserStorage, setUserStorage } from '@/services/storage.uts'
import { API_BASE_URL, getSession, requestJson } from '@/services/api.uts'
import { activeAssessmentType, loadAssessmentDraft, draftText } from '@/services/assessment.uts' import { activeAssessmentType, loadAssessmentDraft, draftText } from '@/services/assessment.uts'
export type AccountProfile = { nickname: string, avatar: string, phone: string } export type AccountProfile = { nickname: string, avatar: string, phone: string }
@@ -23,7 +24,7 @@ export function getAccountProfile(): AccountProfile {
const phone = session == null ? '' : draftText(session, 'phone') const phone = session == null ? '' : draftText(session, 'phone')
let nickname = '融资用户'; let avatar = '' let nickname = '融资用户'; let avatar = ''
if (phone.length > 0) { if (phone.length > 0) {
const saved = uni.getStorageSync(profileKey(phone)) const saved = getUserStorage(profileKey(phone))
if (saved != null && typeof saved == 'object') { if (saved != null && typeof saved == 'object') {
nickname = draftText(saved as UTSJSONObject, 'nickname'); avatar = draftText(saved as UTSJSONObject, 'avatar') nickname = draftText(saved as UTSJSONObject, 'nickname'); avatar = draftText(saved as UTSJSONObject, 'avatar')
} else { } else {
@@ -35,19 +36,9 @@ export function getAccountProfile(): AccountProfile {
} }
// 安全接口必须经过服务端认证。未配置服务时不模拟验证或修改成功。 // 安全接口必须经过服务端认证。未配置服务时不模拟验证或修改成功。
function accountRequest(path: string, method: RequestMethod, payload: UTSJSONObject): Promise<UTSJSONObject> { async function accountRequest(path: string, method: RequestMethod, payload: UTSJSONObject): Promise<UTSJSONObject> {
return new Promise<UTSJSONObject>((resolve, reject) => { requireAccount()
const session = requireAccount() return await requestJson(path, method, payload)
if (API_BASE_URL.length == 0) { reject(new Error('账户服务暂未开放,请稍后再试')); return }
uni.request({ url: API_BASE_URL + path, method, data: payload, header: { Authorization: 'Bearer ' + draftText(session, 'token') }, timeout: 10000,
success: (response) => {
if (!sameAccount(session)) { reject(new Error('登录状态已变化,请重新进入页面')); return }
if (response.statusCode == 401) { reject(new Error('登录已失效,请重新登录')); return }
if (response.statusCode < 200 || response.statusCode >= 300) { reject(new Error('操作失败,请核对信息后重试')); return }
resolve(response.data == null ? new UTSJSONObject() : response.data as UTSJSONObject)
}, fail: () => reject(new Error('网络连接失败,请稍后重试'))
})
})
} }
export async function saveAccountProfile(nickname: string, avatar: string): Promise<void> { export async function saveAccountProfile(nickname: string, avatar: string): Promise<void> {
const session = requireAccount(); const phone = draftText(session, 'phone') const session = requireAccount(); const phone = draftText(session, 'phone')
@@ -55,7 +46,7 @@ export async function saveAccountProfile(nickname: string, avatar: string): Prom
if (name.length < 2 || name.length > 20) throw new Error('昵称请输入 2–20 个字符') if (name.length < 2 || name.length > 20) throw new Error('昵称请输入 2–20 个字符')
if (API_BASE_URL.length > 0) await accountRequest('/account/profile', 'PUT', { nickname: name, avatar }) if (API_BASE_URL.length > 0) await accountRequest('/account/profile', 'PUT', { nickname: name, avatar })
if (!sameAccount(session)) throw new Error('登录状态已变化,请重新进入页面') if (!sameAccount(session)) throw new Error('登录状态已变化,请重新进入页面')
uni.setStorageSync(profileKey(phone), { nickname: name, avatar }) setUserStorage(profileKey(phone), { nickname: name, avatar })
} }
export async function requestSecurityCode(purpose: string, newPhone: string): Promise<void> { export async function requestSecurityCode(purpose: string, newPhone: string): Promise<void> {
if (!['password', 'phone-current', 'phone-new'].includes(purpose)) throw new Error('验证码用途无效') if (!['password', 'phone-current', 'phone-new'].includes(purpose)) throw new Error('验证码用途无效')
+28 -23
View File
@@ -1,35 +1,40 @@
import { API_BASE_URL, wait, requestJson } from '@/services/api.uts' import { API_BASE_URL, requestJson, saveSession, clearSession, getSession } from '@/services/api.uts'
export { API_BASE_URL, saveSession } from '@/services/api.uts' export { API_BASE_URL } from '@/services/api.uts'
import { setUserStorage } from '@/services/storage.uts'
export async function requestSmsCode(phone: string): Promise<boolean> { export async function requestSmsCode(phone: string): Promise<boolean> {
if (API_BASE_URL.length == 0) { await requestJson('/auth/sms-code', 'POST', { phone, purpose: 'login' })
await wait(350)
return phone.length == 11
}
await requestJson('/auth/sms-code', 'POST', { phone })
return true return true
} }
export async function loginWithSms(phone: string, code: string): Promise<string> { function acceptSession(result: UTSJSONObject, phone: string): string {
if (API_BASE_URL.length == 0) {
await wait(300)
if (code.length != 6) throw new Error('验证码应为 6 位数字')
return 'demo-sms-token'
}
const result = await requestJson('/auth/sms-login', 'POST', { phone, code })
const token = result['token'] as string | null const token = result['token'] as string | null
if (token == null || token.length == 0) throw new Error('登录响应缺少 token') const userId = result['userId'] as string | null
const expiresAt = result['expiresAt'] as string | null
if (token == null || userId == null || expiresAt == null) throw new Error('登录响应缺少会话信息')
saveSession(token, phone, userId, expiresAt)
const profile = result['profile'] as UTSJSONObject | null
if (profile != null) setUserStorage('finance.account.profile:' + phone, { nickname: profile['nickname'], avatar: profile['avatarUrl'] ?? '' })
return token return token
} }
export async function loginWithSms(phone: string, code: string): Promise<string> {
return acceptSession(await requestJson('/auth/sms-login', 'POST', { phone, code }), phone)
}
export async function loginWithPassword(phone: string, password: string): Promise<string> { export async function loginWithPassword(phone: string, password: string): Promise<string> {
if (API_BASE_URL.length == 0) { return acceptSession(await requestJson('/auth/password-login', 'POST', { phone, password }), phone)
await wait(300)
return 'demo-password-token'
}
const result = await requestJson('/auth/password-login', 'POST', { phone, password })
const token = result['token'] as string | null
if (token == null || token.length == 0) throw new Error('登录响应缺少 token')
return token
} }
export async function logoutAccount(): Promise<void> {
const session = getSession()
if (session == null) { clearSession(); return }
try {
await requestJson('/auth/logout', 'POST', {})
} catch (error) {
// 401已清理会话,视为退出完成;网络失败保留会话以便重试撤销。
if (getSession() != null) throw error
}
const current = getSession()
if (current == null || current['token'] == session['token']) clearSession()
}
+4 -2
View File
@@ -29,6 +29,8 @@
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { getUserStorage, setUserStorage } from '@/services/storage.uts'
import { usePageLayout } from '@/services/layout.uts' import { usePageLayout } from '@/services/layout.uts'
import { financeLessons, findFinanceLesson, FinanceLesson } from '@/packages/content/services/content.uts' import { financeLessons, findFinanceLesson, FinanceLesson } from '@/packages/content/services/content.uts'
import AppHeader from '@/components/AppHeader.uvue' import AppHeader from '@/components/AppHeader.uvue'
@@ -41,7 +43,7 @@
const scrollTarget = ref('') const scrollTarget = ref('')
const storageKey = (index: number): string => 'finance.lesson.' + lesson.value.id + '.chapter.' + index const storageKey = (index: number): string => 'finance.lesson.' + lesson.value.id + '.chapter.' + index
const restoreProgress = (): void => { const restoreProgress = (): void => {
completed.value = lesson.value.chapters.map((_item, index): boolean => uni.getStorageSync(storageKey(index)) == true) completed.value = lesson.value.chapters.map((_item, index): boolean => getUserStorage(storageKey(index)) == true)
} }
const completedCount = computed((): number => completed.value.filter((value: boolean): boolean => value).length) const completedCount = computed((): number => completed.value.filter((value: boolean): boolean => value).length)
const progress = computed((): number => Math.round(completedCount.value * 100 / lesson.value.chapters.length)) const progress = computed((): number => Math.round(completedCount.value * 100 / lesson.value.chapters.length))
@@ -59,7 +61,7 @@
}) })
const toggleChapter = (index: number): void => { activeIndex.value = activeIndex.value == index ? -1 : index } const toggleChapter = (index: number): void => { activeIndex.value = activeIndex.value == index ? -1 : index }
const completeChapter = (index: number): void => { const completeChapter = (index: number): void => {
uni.setStorageSync(storageKey(index), true) setUserStorage(storageKey(index), true)
const next = completed.value.slice() const next = completed.value.slice()
next[index] = true next[index] = true
completed.value = next completed.value = next
+3 -3
View File
@@ -1,4 +1,4 @@
import { API_BASE_URL, requestJson } from '@/services/api.uts' import { API_DEMO_ENABLED, API_BASE_URL, requestJson } from '@/services/api.uts'
export type HelpQuestion = { id: string, category: string, question: string, answer: string } export type HelpQuestion = { id: string, category: string, question: string, answer: string }
export const helpCategories: string[] = ['账户使用', '融资评估', '资料与隐私'] export const helpCategories: string[] = ['账户使用', '融资评估', '资料与隐私']
@@ -8,13 +8,13 @@ const questions: HelpQuestion[] = [
{ id: 'phone', category: '账户使用', question: '如何更换绑定手机号?', answer: '进入“我的 → 账户安全 → 绑定手机号”,填写新手机号,并分别验证当前号码与新号码。换绑成功后使用新号码登录。如果原号码无法接收短信,需要通过官方客服核验身份。' }, { id: 'phone', category: '账户使用', question: '如何更换绑定手机号?', answer: '进入“我的 → 账户安全 → 绑定手机号”,填写新手机号,并分别验证当前号码与新号码。换绑成功后使用新号码登录。如果原号码无法接收短信,需要通过官方客服核验身份。' },
{ id: 'assessment', category: '融资评估', question: '如何开始融资评估?', answer: '从首页选择个人或企业融资入口,依次填写基本信息、收入或经营信息、资产与负债情况,再核对并确认。已有资料可在“我的融资档案”中继续完善。' }, { id: 'assessment', category: '融资评估', question: '如何开始融资评估?', answer: '从首页选择个人或企业融资入口,依次填写基本信息、收入或经营信息、资产与负债情况,再核对并确认。已有资料可在“我的融资档案”中继续完善。' },
{ id: 'result', category: '融资评估', question: '评估结果是否代表贷款已经获批?', answer: '不代表。评估结果用于帮助了解融资需求和方案,不是金融机构的授信或放款承诺。实际额度、利率、审批与放款结果,以相关机构的审核和合同为准。' }, { id: 'result', category: '融资评估', question: '评估结果是否代表贷款已经获批?', answer: '不代表。评估结果用于帮助了解融资需求和方案,不是金融机构的授信或放款承诺。实际额度、利率、审批与放款结果,以相关机构的审核和合同为准。' },
{ id: 'amount', category: '融资评估', question: '资产与负债信息应该怎么填写?', answer: '信用贷款笔数、信用卡张数填写非负整数,没有则填 0。欠款余额、月还款金额、信用卡额度与已使用额度以元填写,最多保留两位小数;房产、车辆按实际情况选择“有”或“无”。' }, { id: 'amount', category: '融资评估', question: '资产与负债信息应该怎么填写?', answer: '信用贷款笔数、信用卡张数填写非负整数,没有则填 0。欠款余额、月还款金额、信用卡额度与已使用额度统一以万元填写,最多保留六位小数,最高 99999.999999 万元;房产、车辆按实际情况选择“有”或“无”。' },
{ id: 'files', category: '资料与隐私', question: '选择资料或勾选授权会立即上传、查询吗?', answer: '当前选择的资料仅用于本机预览,勾选授权仅保存您的选择,不会自动上传资料或查询外部数据。使用相关服务前,请确认授权对象、用途和范围。' }, { id: 'files', category: '资料与隐私', question: '选择资料或勾选授权会立即上传、查询吗?', answer: '当前选择的资料仅用于本机预览,勾选授权仅保存您的选择,不会自动上传资料或查询外部数据。使用相关服务前,请确认授权对象、用途和范围。' },
{ id: 'draft', category: '资料与隐私', question: '退出登录后,填写的资料还在吗?', answer: '退出登录会结束当前会话,本机保存的评估草稿仍会保留。个人与企业评估分别保存。清理应用数据、更换设备或卸载应用可能使本地资料丢失,请勿将本机草稿视为云端备份。' } { id: 'draft', category: '资料与隐私', question: '退出登录后,填写的资料还在吗?', answer: '退出登录会结束当前会话,本机保存的评估草稿仍会保留。个人与企业评估分别保存。清理应用数据、更换设备或卸载应用可能使本地资料丢失,请勿将本机草稿视为云端备份。' }
] ]
export async function fetchHelpQuestions(): Promise<HelpQuestion[]> { export async function fetchHelpQuestions(): Promise<HelpQuestion[]> {
if (API_BASE_URL.length == 0) return questions.slice() if (API_DEMO_ENABLED) return questions.slice()
const response = await requestJson('/help/faqs', 'GET', {}) const response = await requestJson('/help/faqs', 'GET', {})
const items = response['items'] as UTSJSONObject[] | null const items = response['items'] as UTSJSONObject[] | null
if (items == null) throw new Error('帮助内容加载失败') if (items == null) throw new Error('帮助内容加载失败')
+2 -2
View File
@@ -1,4 +1,4 @@
import { API_BASE_URL, requestJson } from '@/services/api.uts' import { API_DEMO_ENABLED, API_BASE_URL, requestJson } from '@/services/api.uts'
import { newsArticles, financeLessons, ContentSection } from './content.uts' import { newsArticles, financeLessons, ContentSection } from './content.uts'
export type ContentSearchResult = { export type ContentSearchResult = {
@@ -14,7 +14,7 @@ export async function searchContent(keyword: string, type: string): Promise<Cont
const query = keyword.trim().replace(/\s+/g, ' ') const query = keyword.trim().replace(/\s+/g, ' ')
const results: ContentSearchResult[] = [] const results: ContentSearchResult[] = []
if (query.length == 0) return results if (query.length == 0) return results
if (API_BASE_URL.length > 0) { if (!API_DEMO_ENABLED) {
const response = await requestJson('/content/search', 'GET', { keyword: query, type }) const response = await requestJson('/content/search', 'GET', { keyword: query, type })
const items = response['items'] as UTSJSONObject[] | null const items = response['items'] as UTSJSONObject[] | null
if (items == null) throw new Error('搜索结果加载失败,请重试') if (items == null) throw new Error('搜索结果加载失败,请重试')
+4 -2
View File
@@ -12,6 +12,8 @@
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { setUserStorage } from '@/services/storage.uts'
import { usePageLayout } from '@/services/layout.uts' import { usePageLayout } from '@/services/layout.uts'
import AppHeader from '@/components/AppHeader.uvue' import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue' import AppIcon from '@/components/AppIcon.uvue'
@@ -28,8 +30,8 @@
]) ])
const refresh = () => { progress.value = assessmentProgress(type.value) } const refresh = () => { progress.value = assessmentProgress(type.value) }
onShow(() => { type.value = activeAssessmentType(); refresh() }) onShow(() => { type.value = activeAssessmentType(); refresh() })
const switchType = (next: string) => { type.value = next; uni.setStorageSync('assessmentActiveType', next); refresh() } const switchType = (next: string) => { type.value = next; setUserStorage('assessmentActiveType', next); refresh() }
const edit = (step: number) => { uni.setStorageSync('assessmentEditStep', step); uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + type.value }) } const edit = (step: number) => { setUserStorage('assessmentEditStep', step); uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + type.value }) }
const complete = () => { const complete = () => {
if (progress.value.percent == 100) uni.navigateTo({ url: '/packages/finance/confirm/confirm' }) if (progress.value.percent == 100) uni.navigateTo({ url: '/packages/finance/confirm/confirm' })
else if (!progress.value.basicDone) edit(0) else if (!progress.value.basicDone) edit(0)
+12 -9
View File
@@ -25,7 +25,7 @@
<template v-else> <template v-else>
<AssessmentSelect label="职业类型" v-model="form.occupation" :options="['企业职员', '事业单位', '公务员', '个体经营', '自由职业', '其他']" /> <AssessmentSelect label="职业类型" v-model="form.occupation" :options="['企业职员', '事业单位', '公务员', '个体经营', '自由职业', '其他']" />
<view class="form-row"><text class="field-label">工作单位</text><input v-model="form.company" class="field-input" placeholder="请输入工作单位" /></view> <view class="form-row"><text class="field-label">工作单位</text><input v-model="form.company" class="field-input" placeholder="请输入工作单位" /></view>
<view class="form-row"><text class="field-label">月收入(元)</text><input v-model="form.monthlyIncome" class="field-input" type="digit" placeholder="请输入月收入" /></view> <view class="form-row"><text class="field-label">月收入(万元)</text><input v-model="form.monthlyIncome" class="field-input" type="digit" placeholder="请输入月收入" /></view>
<AssessmentSelect label="社保缴纳" v-model="form.socialSecurity" :options="['连续缴纳 6 个月以上', '缴纳不足 6 个月', '未缴纳']" /> <AssessmentSelect label="社保缴纳" v-model="form.socialSecurity" :options="['连续缴纳 6 个月以上', '缴纳不足 6 个月', '未缴纳']" />
<AssessmentSelect label="公积金缴纳" v-model="form.housingFund" :options="['连续缴纳 6 个月以上', '缴纳不足 6 个月', '未缴纳']" /> <AssessmentSelect label="公积金缴纳" v-model="form.housingFund" :options="['连续缴纳 6 个月以上', '缴纳不足 6 个月', '未缴纳']" />
</template> </template>
@@ -33,12 +33,12 @@
<view v-else-if="step == 2" class="form-list"> <view v-else-if="step == 2" class="form-list">
<text class="asset-section-title">贷款信息</text> <text class="asset-section-title">贷款信息</text>
<view class="form-row"><text class="field-label">信用贷款笔数</text><input v-model="form.creditLoanCount" class="field-input" type="number" maxlength="4" placeholder="无则填 0" /><text class="field-unit">笔</text></view> <view class="form-row"><text class="field-label">信用贷款笔数</text><input v-model="form.creditLoanCount" class="field-input" type="number" maxlength="4" placeholder="无则填 0" /><text class="field-unit">笔</text></view>
<view class="form-row"><text class="field-label">欠款总余额</text><input v-model="form.totalDebtBalance" class="field-input" type="digit" maxlength="15" placeholder="请输入金额" /><text class="field-unit">元</text></view> <view class="form-row"><text class="field-label">欠款总余额</text><input v-model="form.totalDebtBalance" class="field-input" type="digit" maxlength="15" placeholder="请输入金额" /><text class="field-unit">万元</text></view>
<view class="form-row"><text class="field-label">贷款月还款金额</text><input v-model="form.monthlyLoanPayment" class="field-input" type="digit" maxlength="15" placeholder="请输入金额" /><text class="field-unit">元</text></view> <view class="form-row"><text class="field-label">贷款月还款金额</text><input v-model="form.monthlyLoanPayment" class="field-input" type="digit" maxlength="15" placeholder="请输入金额" /><text class="field-unit">万元</text></view>
<text class="asset-section-title">信用卡信息</text> <text class="asset-section-title">信用卡信息</text>
<view class="form-row"><text class="field-label">银行信用卡张数</text><input v-model="form.creditCardCount" class="field-input" type="number" maxlength="4" placeholder="无则填 0" /><text class="field-unit">张</text></view> <view class="form-row"><text class="field-label">银行信用卡张数</text><input v-model="form.creditCardCount" class="field-input" type="number" maxlength="4" placeholder="无则填 0" /><text class="field-unit">张</text></view>
<view class="form-row"><text class="field-label">总额度</text><input v-model="form.creditCardTotalLimit" class="field-input" type="digit" maxlength="15" placeholder="请输入金额" /><text class="field-unit">元</text></view> <view class="form-row"><text class="field-label">总额度</text><input v-model="form.creditCardTotalLimit" class="field-input" type="digit" maxlength="15" placeholder="请输入金额" /><text class="field-unit">万元</text></view>
<view class="form-row"><text class="field-label">已使用总额度</text><input v-model="form.creditCardUsedLimit" class="field-input" type="digit" maxlength="15" placeholder="请输入金额" /><text class="field-unit">元</text></view> <view class="form-row"><text class="field-label">已使用总额度</text><input v-model="form.creditCardUsedLimit" class="field-input" type="digit" maxlength="15" placeholder="请输入金额" /><text class="field-unit">万元</text></view>
<text class="asset-section-title">房车信息</text> <text class="asset-section-title">房车信息</text>
<AssessmentSelect label="是否有房" v-model="form.house" :options="['有', '无']" /> <AssessmentSelect label="是否有房" v-model="form.house" :options="['有', '无']" />
<AssessmentSelect label="是否有车" v-model="form.car" :options="['有', '无']" /> <AssessmentSelect label="是否有车" v-model="form.car" :options="['有', '无']" />
@@ -58,6 +58,9 @@
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { getUserStorage, removeUserStorage } from '@/services/storage.uts'
import { wanAmountValid } from '@/services/money.uts'
import { usePageLayout } from '@/services/layout.uts' import { usePageLayout } from '@/services/layout.uts'
import AppHeader from '@/components/AppHeader.uvue' import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue' import AppIcon from '@/components/AppIcon.uvue'
@@ -75,7 +78,7 @@
const documents = ref<SelectedDocument[]>([]) const documents = ref<SelectedDocument[]>([])
const authorizations = ref<DataAuthorization[]>([{ key: 'credit', name: '征信查询', selected: false }, { key: 'business', name: '经营数据', selected: false }, { key: 'fund', name: '公积金数据', selected: false }]) const authorizations = ref<DataAuthorization[]>([{ key: 'credit', name: '征信查询', selected: false }, { key: 'business', name: '经营数据', selected: false }, { key: 'fund', name: '公积金数据', selected: false }])
const titles = ['基本信息', '经营 / 收入信息', '资产 / 负债信息', '资料上传 / 授权'] const titles = ['基本信息', '经营 / 收入信息', '资产 / 负债信息', '资料上传 / 授权']
const uploadNames = computed((): string[] => assessmentType.value == 'enterprise' ? ['营业执照', '银行流水', '经营证明'] : ['身份证正面', '银行卡', '收入证明']) const uploadNames = computed((): string[] => assessmentType.value == 'enterprise' ? ['营业执照', '银行流水', '经营证明'] : ['身份证正面', '身份证反面', '银行卡', '收入证明'])
let initialized = false let initialized = false
const saveDraft = () => { const saveDraft = () => {
if (!initialized) return if (!initialized) return
@@ -106,8 +109,8 @@
restore(type) restore(type)
}) })
onShow(() => { onShow(() => {
const requestedStep = uni.getStorageSync('assessmentEditStep') const requestedStep = getUserStorage('assessmentEditStep')
if (typeof requestedStep == 'number') { step.value = Math.max(0, Math.min(3, requestedStep as number)); uni.removeStorageSync('assessmentEditStep'); saveDraft() } if (typeof requestedStep == 'number') { step.value = Math.max(0, Math.min(3, requestedStep as number)); removeUserStorage('assessmentEditStep'); saveDraft() }
}) })
onHide(() => saveDraft()) onHide(() => saveDraft())
onUnload(() => saveDraft()) onUnload(() => saveDraft())
@@ -149,7 +152,7 @@
} }
if (step.value == 1) { if (step.value == 1) {
const amount = assessmentType.value == 'enterprise' ? form.value.annualRevenue : form.value.monthlyIncome const amount = assessmentType.value == 'enterprise' ? form.value.annualRevenue : form.value.monthlyIncome
if (!/^[0-9]+([.][0-9]{1,2})?$/.test(amount) || parseFloat(amount) <= 0) message = assessmentType.value == 'enterprise' ? '请输入有效的年营业收入' : '请输入有效的月收入' if (!wanAmountValid(amount) || parseFloat(amount) <= 0) message = assessmentType.value == 'enterprise' ? '请输入有效年营收(万元,最多六位小数)' : '请输入有效月收入(万元,最多六位小数)'
} }
if (step.value == 2) message = assetValidationMessage(form.value) if (step.value == 2) message = assetValidationMessage(form.value)
if (step.value == 3 && documents.value.length > 0 && !documentConsent.value) message = '请阅读并同意资料使用协议,或移除资料' if (step.value == 3 && documents.value.length > 0 && !documentConsent.value) message = '请阅读并同意资料使用协议,或移除资料'
+10 -7
View File
@@ -16,6 +16,9 @@
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { setUserStorage } from '@/services/storage.uts'
import { wanAmountValid } from '@/services/money.uts'
import { usePageLayout } from '@/services/layout.uts' import { usePageLayout } from '@/services/layout.uts'
import AppHeader from '@/components/AppHeader.uvue' import AppHeader from '@/components/AppHeader.uvue'
import { submitAssessment } from '@/packages/finance/services/api.uts' import { submitAssessment } from '@/packages/finance/services/api.uts'
@@ -53,7 +56,7 @@
const incomeRows: InfoRow[] = enterprise ? [ const incomeRows: InfoRow[] = enterprise ? [
{ label: '经营行业', value: displayValue(saved, 'occupation') }, { label: '经营年限', value: displayValue(saved, 'operatingYears') }, { label: '年营业收入', value: draftText(saved, 'annualRevenue').length > 0 ? draftText(saved, 'annualRevenue') + ' 万元' : '未填写' } { label: '经营行业', value: displayValue(saved, 'occupation') }, { label: '经营年限', value: displayValue(saved, 'operatingYears') }, { label: '年营业收入', value: draftText(saved, 'annualRevenue').length > 0 ? draftText(saved, 'annualRevenue') + ' 万元' : '未填写' }
] : [ ] : [
{ label: '职业类型', value: displayValue(saved, 'occupation') }, { label: '月收入', value: draftText(saved, 'monthlyIncome').length > 0 ? draftText(saved, 'monthlyIncome') + ' 元' : '未填写' }, { label: '工作单位', value: displayValue(saved, 'company') }, { label: '社保缴纳', value: displayValue(saved, 'socialSecurity') }, { label: '公积金缴纳', value: displayValue(saved, 'housingFund') } { label: '职业类型', value: displayValue(saved, 'occupation') }, { label: '月收入', value: draftText(saved, 'monthlyIncome').length > 0 ? draftText(saved, 'monthlyIncome') + ' 万元' : '未填写' }, { label: '工作单位', value: displayValue(saved, 'company') }, { label: '社保缴纳', value: displayValue(saved, 'socialSecurity') }, { label: '公积金缴纳', value: displayValue(saved, 'housingFund') }
] ]
const files = documentsFromDraft(saved) const files = documentsFromDraft(saved)
const fileNames = files.map((file) => file.name).join('、') const fileNames = files.map((file) => file.name).join('、')
@@ -65,11 +68,11 @@
{ title: enterprise ? '经营信息' : '职业与收入', step: 1, rows: incomeRows, expanded: false }, { title: enterprise ? '经营信息' : '职业与收入', step: 1, rows: incomeRows, expanded: false },
{ title: '资产与负债', step: 2, rows: [ { title: '资产与负债', step: 2, rows: [
{ label: '信用贷款笔数', value: valueWithUnit(saved, 'creditLoanCount', '笔') }, { label: '信用贷款笔数', value: valueWithUnit(saved, 'creditLoanCount', '笔') },
{ label: '欠款总余额', value: valueWithUnit(saved, 'totalDebtBalance', '元') }, { label: '欠款总余额', value: valueWithUnit(saved, 'totalDebtBalance', '万元') },
{ label: '贷款月还款金额', value: valueWithUnit(saved, 'monthlyLoanPayment', '元') }, { label: '贷款月还款金额', value: valueWithUnit(saved, 'monthlyLoanPayment', '万元') },
{ label: '银行信用卡张数', value: valueWithUnit(saved, 'creditCardCount', '张') }, { label: '银行信用卡张数', value: valueWithUnit(saved, 'creditCardCount', '张') },
{ label: '总额度', value: valueWithUnit(saved, 'creditCardTotalLimit', '元') }, { label: '总额度', value: valueWithUnit(saved, 'creditCardTotalLimit', '万元') },
{ label: '已使用总额度', value: valueWithUnit(saved, 'creditCardUsedLimit', '元') }, { label: '已使用总额度', value: valueWithUnit(saved, 'creditCardUsedLimit', '万元') },
{ label: '是否有房', value: ownershipFromDraft(saved, 'house').length > 0 ? ownershipFromDraft(saved, 'house') : '未填写' }, { label: '是否有房', value: ownershipFromDraft(saved, 'house').length > 0 ? ownershipFromDraft(saved, 'house') : '未填写' },
{ label: '是否有车', value: ownershipFromDraft(saved, 'car').length > 0 ? ownershipFromDraft(saved, 'car') : '未填写' } { label: '是否有车', value: ownershipFromDraft(saved, 'car').length > 0 ? ownershipFromDraft(saved, 'car') : '未填写' }
], expanded: false }, ], expanded: false },
@@ -79,7 +82,7 @@
} }
onShow(() => loadDraft()) onShow(() => loadDraft())
const edit = (step: number) => { const edit = (step: number) => {
uni.setStorageSync('assessmentEditStep', step) setUserStorage('assessmentEditStep', step)
const pages = getCurrentPages() const pages = getCurrentPages()
if (pages.length > 1 && pages[pages.length - 2].route == 'packages/finance/assessment/assessment') uni.navigateBack({ delta: 1 }) if (pages.length > 1 && pages[pages.length - 2].route == 'packages/finance/assessment/assessment') uni.navigateBack({ delta: 1 })
else uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + assessmentType.value }) else uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + assessmentType.value })
@@ -92,7 +95,7 @@
if (draftText(saved, 'name').length < 2 || !/^1[3-9][0-9]{9}$/.test(draftText(saved, 'phone')) || draftText(saved, 'city').length == 0) { uni.showToast({ title: '请先完善基本信息', icon: 'none' }); edit(0); return } if (draftText(saved, 'name').length < 2 || !/^1[3-9][0-9]{9}$/.test(draftText(saved, 'phone')) || draftText(saved, 'city').length == 0) { uni.showToast({ title: '请先完善基本信息', icon: 'none' }); edit(0); return }
if (assessmentType.value == 'personal' && !/(^[0-9]{15}$)|(^[0-9]{17}[0-9Xx]$)/.test(draftText(saved, 'idCard'))) { uni.showToast({ title: '请补全身份证信息', icon: 'none' }); edit(0); return } if (assessmentType.value == 'personal' && !/(^[0-9]{15}$)|(^[0-9]{17}[0-9Xx]$)/.test(draftText(saved, 'idCard'))) { uni.showToast({ title: '请补全身份证信息', icon: 'none' }); edit(0); return }
if (assessmentType.value == 'enterprise' && (draftText(saved, 'enterpriseName').length < 2 || !/^[0-9A-Z]{18}$/.test(draftText(saved, 'businessCode').toUpperCase()))) { uni.showToast({ title: '请补全企业信息', icon: 'none' }); edit(0); return } if (assessmentType.value == 'enterprise' && (draftText(saved, 'enterpriseName').length < 2 || !/^[0-9A-Z]{18}$/.test(draftText(saved, 'businessCode').toUpperCase()))) { uni.showToast({ title: '请补全企业信息', icon: 'none' }); edit(0); return }
if (amount.length == 0 || parseFloat(amount) <= 0 || isNaN(parseFloat(amount))) { uni.showToast({ title: '请先完善收入信息', icon: 'none' }); edit(1); return } if (!wanAmountValid(amount) || parseFloat(amount) <= 0) { uni.showToast({ title: '请先完善收入信息', icon: 'none' }); edit(1); return }
const grants = saved['authorizations'] as string[] | null const grants = saved['authorizations'] as string[] | null
if ((documentsFromDraft(saved).length > 0 && saved['documentConsent'] != true) || (grants != null && grants.length > 0 && saved['dataConsent'] != true)) { uni.showToast({ title: '请确认资料与授权选择', icon: 'none' }); edit(3); return } if ((documentsFromDraft(saved).length > 0 && saved['documentConsent'] != true) || (grants != null && grants.length > 0 && saved['dataConsent'] != true)) { uni.showToast({ title: '请确认资料与授权选择', icon: 'none' }); edit(3); return }
const assetError = assetValidationMessage(formFromDraft(saved)) const assetError = assetValidationMessage(formFromDraft(saved))
+1 -1
View File
@@ -11,7 +11,7 @@
<text class="result-summary">共 {{ matches.length }} 个方案 · 了解额度与申请条件</text> <text class="result-summary">共 {{ matches.length }} 个方案 · 了解额度与申请条件</text>
<view class="match-card" v-for="item in sortedMatches" :key="item.id" @click="openProduct(item.id)"> <view class="match-card" v-for="item in sortedMatches" :key="item.id" @click="openProduct(item.id)">
<view class="bank-row"><view class="bank-logo" :style="'background-color:' + item.color"><text class="bank-initial">{{ item.initials }}</text></view><view class="bank-copy"><text class="bank-name">{{ item.bank }}</text><text class="match-tag">个人信用融资</text></view><text class="match-score">{{ item.score }}%匹配</text></view> <view class="bank-row"><view class="bank-logo" :style="'background-color:' + item.color"><text class="bank-initial">{{ item.initials }}</text></view><view class="bank-copy"><text class="bank-name">{{ item.bank }}</text><text class="match-tag">个人信用融资</text></view><text class="match-score">{{ item.score }}%匹配</text></view>
<view class="metrics"><view class="metric"><text class="metric-value">{{ item.amount }}<text class="metric-unit"> 万</text></text><text class="metric-label">最高额度</text></view><view class="metric"><text class="metric-value rate-value">{{ item.rate.toFixed(2) }}%</text><text class="metric-label">年化利率(起)</text></view><view class="metric term-metric"><text class="term-value">{{ item.term }}</text><text class="metric-label">贷款期限</text></view></view> <view class="metrics"><view class="metric"><text class="metric-value">{{ item.amount }}<text class="metric-unit"> 万元</text></text><text class="metric-label">最高额度</text></view><view class="metric"><text class="metric-value rate-value">{{ item.rate.toFixed(2) }}%</text><text class="metric-label">年化利率(起)</text></view><view class="metric term-metric"><text class="term-value">{{ item.term }}</text><text class="metric-label">贷款期限</text></view></view>
<view class="card-footer"><text class="footer-hint">{{ item.advantages.join(' · ') }}</text><view class="detail-link"><text class="detail-label">查看详情</text><AppIcon name="chevron-right" :size="26" /></view></view> <view class="card-footer"><text class="footer-hint">{{ item.advantages.join(' · ') }}</text><view class="detail-link"><text class="detail-label">查看详情</text><AppIcon name="chevron-right" :size="26" /></view></view>
</view> </view>
</view> </view>
+1 -1
View File
@@ -14,7 +14,7 @@
<view class="product-actions"><text class="share" @click="shareProduct">分享产品</text></view> <view class="product-actions"><text class="share" @click="shareProduct">分享产品</text></view>
<!-- #endif --> <!-- #endif -->
<view class="bank-head"><view class="bank-mark" :style="'background-color:' + product.color"><text class="bank-initial">{{ product.initials }}</text></view><view class="bank-copy"><text class="product-name">{{ product.bank }}</text><text class="product-tags">个人信用融资 · 产品信息</text></view></view> <view class="bank-head"><view class="bank-mark" :style="'background-color:' + product.color"><text class="bank-initial">{{ product.initials }}</text></view><view class="bank-copy"><text class="product-name">{{ product.bank }}</text><text class="product-tags">个人信用融资 · 产品信息</text></view></view>
<view class="product-metrics"><view class="metric"><text class="metric-label">最高额度</text><text class="amount">{{ product.amount }}万</text></view><view class="metric"><text class="metric-label">年化利率(起)</text><text class="rate">{{ product.rate.toFixed(2) }}%</text></view><view class="metric last-metric"><text class="metric-label">贷款期限</text><text class="term">{{ product.term }}</text></view></view> <view class="product-metrics"><view class="metric"><text class="metric-label">最高额度</text><text class="amount">{{ product.amount }}万元</text></view><view class="metric"><text class="metric-label">年化利率(起)</text><text class="rate">{{ product.rate.toFixed(2) }}%</text></view><view class="metric last-metric"><text class="metric-label">贷款期限</text><text class="term">{{ product.term }}</text></view></view>
<view class="detail-section"><text class="section-title">产品优势</text><view class="advantage-grid"><view class="advantage" v-for="item in product.advantages" :key="item"><AppIcon name="check-circle" :size="30" /><text class="advantage-label">{{ item }}</text></view></view></view> <view class="detail-section"><text class="section-title">产品优势</text><view class="advantage-grid"><view class="advantage" v-for="item in product.advantages" :key="item"><AppIcon name="check-circle" :size="30" /><text class="advantage-label">{{ item }}</text></view></view></view>
<view class="detail-section"><text class="section-title">申请条件</text><view class="condition"><text class="condition-label">年龄要求</text><text class="condition-value">{{ product.age }}</text></view><view class="condition"><text class="condition-label">工作要求</text><text class="condition-value">{{ product.work }}</text></view><view class="condition"><text class="condition-label">信用要求</text><text class="condition-value">{{ product.credit }}</text></view></view> <view class="detail-section"><text class="section-title">申请条件</text><view class="condition"><text class="condition-label">年龄要求</text><text class="condition-value">{{ product.age }}</text></view><view class="condition"><text class="condition-label">工作要求</text><text class="condition-value">{{ product.work }}</text></view><view class="condition"><text class="condition-label">信用要求</text><text class="condition-value">{{ product.credit }}</text></view></view>
<view class="detail-section"><text class="section-title">所需材料</text><text class="material">{{ product.materials }}</text></view> <view class="detail-section"><text class="section-title">所需材料</text><text class="material">{{ product.materials }}</text></view>
+10 -8
View File
@@ -1,21 +1,24 @@
import { API_BASE_URL, wait, requestJson } from '@/services/api.uts' import { setUserStorage } from '@/services/storage.uts'
import { API_DEMO_ENABLED, API_BASE_URL, wait, requestJson } from '@/services/api.uts'
import { demoProducts, parseProduct, FinanceProduct } from './finance.uts' import { demoProducts, parseProduct, FinanceProduct } from './finance.uts'
import { assessmentApiPayload } from '@/services/assessment.uts'
export { API_BASE_URL } from '@/services/api.uts' export { API_BASE_URL } from '@/services/api.uts'
export async function submitAssessment(payload: UTSJSONObject): Promise<string> { export async function submitAssessment(payload: UTSJSONObject): Promise<string> {
if (API_BASE_URL.length == 0) { const apiPayload = assessmentApiPayload(payload)
if (API_DEMO_ENABLED) {
await wait(650) await wait(650)
uni.setStorageSync('latestAssessment', payload) setUserStorage('latestAssessment', apiPayload)
return 'ASSESSMENT-DEMO-001' return 'ASSESSMENT-DEMO-001'
} }
const result = await requestJson('/assessments', 'POST', payload) const result = await requestJson('/assessments', 'POST', apiPayload)
const id = result['id'] as string | null const id = result['id'] as string | null
if (id == null || id.length == 0) throw new Error('评估提交成功但未返回编号') if (id == null || id.length == 0) throw new Error('评估提交成功但未返回编号')
return id return id
} }
export async function fetchMatches(): Promise<FinanceProduct[]> { export async function fetchMatches(): Promise<FinanceProduct[]> {
if (API_BASE_URL.length == 0) { if (API_DEMO_ENABLED) {
await wait(320) await wait(320)
return demoProducts.slice() return demoProducts.slice()
} }
@@ -25,7 +28,7 @@ export async function fetchMatches(): Promise<FinanceProduct[]> {
} }
export async function fetchProduct(id: string): Promise<FinanceProduct | null> { export async function fetchProduct(id: string): Promise<FinanceProduct | null> {
if (API_BASE_URL.length == 0) { if (API_DEMO_ENABLED) {
await wait(180) await wait(180)
const products = demoProducts.filter((item: FinanceProduct): boolean => item.id == id) const products = demoProducts.filter((item: FinanceProduct): boolean => item.id == id)
return products.length > 0 ? products[0] : null return products.length > 0 ? products[0] : null
@@ -35,7 +38,7 @@ export async function fetchProduct(id: string): Promise<FinanceProduct | null> {
} }
export async function sendAssistantMessage(message: string): Promise<string> { export async function sendAssistantMessage(message: string): Promise<string> {
if (API_BASE_URL.length == 0) { if (API_DEMO_ENABLED) {
await wait(600) await wait(600)
return '结合您的评估结果,建议优先选择年化利率较低、可随借随还的产品,并重点确认提前还款规则。' return '结合您的评估结果,建议优先选择年化利率较低、可随借随还的产品,并重点确认提前还款规则。'
} }
@@ -44,4 +47,3 @@ export async function sendAssistantMessage(message: string): Promise<string> {
if (reply == null || reply.length == 0) throw new Error('助手暂时没有返回内容') if (reply == null || reply.length == 0) throw new Error('助手暂时没有返回内容')
return reply return reply
} }
+5 -1
View File
@@ -1,3 +1,5 @@
import { yuanToWan } from '@/services/money.uts'
export type FinanceProduct = { export type FinanceProduct = {
id: string, bank: string, initials: string, color: string, id: string, bank: string, initials: string, color: string,
amount: number, rate: number, term: string, score: number, amount: number, rate: number, term: string, score: number,
@@ -12,10 +14,12 @@ export const demoProducts: FinanceProduct[] = [
] ]
export function parseProduct(item: UTSJSONObject): FinanceProduct { export function parseProduct(item: UTSJSONObject): FinanceProduct {
// 接口额度使用元的十进制字符串,视图模型始终使用万元。
const amountYuan = item['amountYuan'] as string | null
return { return {
id: item['id'] as string ?? '', bank: item['bank'] as string ?? '融资产品', id: item['id'] as string ?? '', bank: item['bank'] as string ?? '融资产品',
initials: item['initials'] as string ?? '融', color: item['color'] as string ?? '#2869b7', initials: item['initials'] as string ?? '融', color: item['color'] as string ?? '#2869b7',
amount: item['amount'] as number ?? 0, rate: item['rate'] as number ?? 0, amount: amountYuan != null ? parseFloat(yuanToWan(amountYuan)) : (item['amount'] as number ?? 0), rate: item['rate'] as number ?? 0,
term: item['term'] as string ?? '以审批为准', score: item['score'] as number ?? 0, term: item['term'] as string ?? '以审批为准', score: item['score'] as number ?? 0,
advantages: item['advantages'] as string[] ?? [], age: item['age'] as string ?? '以机构要求为准', advantages: item['advantages'] as string[] ?? [], age: item['age'] as string ?? '以机构要求为准',
work: item['work'] as string ?? '以机构要求为准', credit: item['credit'] as string ?? '以机构要求为准', work: item['work'] as string ?? '以机构要求为准', credit: item['credit'] as string ?? '以机构要求为准',
+3 -1
View File
@@ -43,6 +43,8 @@
</template> </template>
<script setup lang="uts"> <script setup lang="uts">
import { getUserStorage } from '@/services/storage.uts'
import { usePageLayout } from '@/services/layout.uts' import { usePageLayout } from '@/services/layout.uts'
const { contentStyle, statusStyle, pageStyle, navigationStyle, navigationActionStyle, navigationBrandStyle } = usePageLayout() const { contentStyle, statusStyle, pageStyle, navigationStyle, navigationActionStyle, navigationBrandStyle } = usePageLayout()
import BrandLogo from '@/components/BrandLogo.uvue' import BrandLogo from '@/components/BrandLogo.uvue'
@@ -53,7 +55,7 @@
if (consumeInitialLaunch()) uni.reLaunch({ url: '/packages/account/index/index' }) if (consumeInitialLaunch()) uni.reLaunch({ url: '/packages/account/index/index' })
}) })
const hasUnread = ref(true) const hasUnread = ref(true)
onShow(() => { hasUnread.value = uni.getStorageSync('finance.notice.read.assessment-guide') != true }) onShow(() => { hasUnread.value = getUserStorage('finance.notice.read.assessment-guide') != true })
const go = (url: string) => { uni.navigateTo({ url: url }) } const go = (url: string) => { uni.navigateTo({ url: url }) }
const openEntry = (type: string) => { go('/packages/finance/entry/entry?type=' + type) } const openEntry = (type: string) => { go('/packages/finance/entry/entry?type=' + type) }
</script> </script>
+47 -26
View File
@@ -1,54 +1,75 @@
/** Shared request, configuration and session infrastructure. */ /** Shared request, configuration and session infrastructure. */
export const API_BASE_URL = '' import { API_BASE_URL, APP_ENVIRONMENT } from './config.uts'
export { API_BASE_URL, API_DEMO_ENABLED } from './config.uts'
const REQUEST_TIMEOUT = 10000 const REQUEST_TIMEOUT = 10000
const SESSION_KEY = 'aiFinanceSession' const SESSION_KEY = 'aiFinanceSession'
const wait = (duration: number) => new Promise<void>((resolve) => { const wait = (duration: number) => new Promise<void>((resolve) => { setTimeout(() => resolve(), duration) })
setTimeout(() => resolve(), duration)
})
export function saveSession(token: string, phone: string): void { export function saveSession(token: string, phone: string, userId: string, expiresAt: string): void {
uni.setStorageSync(SESSION_KEY, { token, phone, loggedInAt: Date.now() }) if (token.length == 0 || userId.length == 0 || expiresAt.length == 0) throw new Error('登录响应不完整')
const expiry = Date.parse(expiresAt)
if (isNaN(expiry) || expiry <= Date.now()) throw new Error('登录凭证已过期')
uni.setStorageSync(SESSION_KEY, { token, phone, userId, expiresAt, loggedInAt: Date.now() })
} }
export function getSession(): UTSJSONObject | null { export function getSession(): UTSJSONObject | null {
const value = uni.getStorageSync(SESSION_KEY) const value = uni.getStorageSync(SESSION_KEY)
if (value == null || typeof value != 'object') return null if (value == null || typeof value != 'object') return null
return value as UTSJSONObject const session = value as UTSJSONObject
} const token = session['token'] as string | null
const userId = session['userId'] as string | null
export function isLoggedIn(): boolean { const expiresAt = session['expiresAt'] as string | null
return getSession() != null if (token == null || userId == null || expiresAt == null || token.length == 0 || userId.length == 0 || token.startsWith('demo-')) {
}
export function clearSession(): void {
uni.removeStorageSync(SESSION_KEY) uni.removeStorageSync(SESSION_KEY)
return null
} }
const expiry = Date.parse(expiresAt)
if (isNaN(expiry) || expiry <= Date.now()) { uni.removeStorageSync(SESSION_KEY); return null }
return session
}
export function isLoggedIn(): boolean { return getSession() != null }
export function clearSession(): void { uni.removeStorageSync(SESSION_KEY) }
const currentToken = (): string => { const session = getSession(); return session == null ? '' : session['token'] as string }
const apiMessage = (error: any | null, fallback: string): string => { const apiMessage = (error: any | null, fallback: string): string => {
if (error == null) return fallback if (error == null) return fallback
if (error instanceof Error) return error.message
const text = error.toString() const text = error.toString()
return text.length > 0 ? text : fallback return text.length > 0 ? text : fallback
} }
const requestJson = (path: string, method: RequestMethod, payload: UTSJSONObject): Promise<UTSJSONObject> => { const requestJson = (path: string, method: RequestMethod, payload: UTSJSONObject): Promise<UTSJSONObject> => {
return new Promise<UTSJSONObject>((resolve, reject) => { return new Promise<UTSJSONObject>((resolve, reject) => {
if (API_BASE_URL.length == 0 || (APP_ENVIRONMENT == 'production' && !API_BASE_URL.startsWith('https://'))) {
reject(new Error('服务地址尚未配置')); return
}
const sentToken = currentToken()
const header: UTSJSONObject = { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
if (sentToken.length > 0) header['Authorization'] = 'Bearer ' + sentToken
uni.request({ uni.request({
url: API_BASE_URL + path, url: API_BASE_URL + path, method, data: payload, header, timeout: REQUEST_TIMEOUT,
method,
data: payload,
timeout: REQUEST_TIMEOUT,
success: (response) => { success: (response) => {
if (response.statusCode >= 200 && response.statusCode < 300) { // 旧请求不得覆盖刚登录的另一账户,也不能因旧401清除新会话。
if (response.data == null) resolve(new UTSJSONObject()) if (currentToken() != sentToken) { reject(new Error('登录状态已变化,请重新操作')); return }
else resolve(response.data as UTSJSONObject) if (response.statusCode == 401 && sentToken.length > 0) clearSession()
} else { if (response.data == null || typeof response.data != 'object') {
reject('请求失败(' + response.statusCode.toString() + ')') reject(new Error('服务响应格式错误')); return
} }
const body = response.data as UTSJSONObject
const code = body['code'] as number | null
const message = body['msg'] as string | null
if (response.statusCode < 200 || response.statusCode >= 300 || code != 1) {
if (code == 401 && sentToken.length > 0) clearSession()
reject(new Error(message != null && message.length > 0 ? message : '请求失败,请稍后重试')); return
}
const data = body['data']
if (data == null) resolve(new UTSJSONObject())
else if (typeof data == 'object') resolve(data as UTSJSONObject)
else reject(new Error('服务响应数据格式错误'))
}, },
fail: (error) => { fail: () => { reject(new Error('网络连接失败,请稍后重试')) }
reject(apiMessage(error, '网络请求失败,请稍后重试'))
}
}) })
}) })
} }
+33 -11
View File
@@ -1,3 +1,6 @@
import { getUserStorage, setUserStorage } from '@/services/storage.uts'
import { wanAmountValid, yuanToWan, wanToYuan } from './money.uts'
export type AssessmentForm = { export type AssessmentForm = {
name: string, idCard: string, phone: string, city: string, maritalStatus: string, name: string, idCard: string, phone: string, city: string, maritalStatus: string,
occupation: string, company: string, monthlyIncome: string, socialSecurity: string, occupation: string, company: string, monthlyIncome: string, socialSecurity: string,
@@ -17,22 +20,41 @@ export function draftText(saved: UTSJSONObject, key: string): string {
return typeof value == 'string' ? value as string : '' return typeof value == 'string' ? value as string : ''
} }
export function activeAssessmentType(): string { export function activeAssessmentType(): string {
const type = uni.getStorageSync('assessmentActiveType') const type = getUserStorage('assessmentActiveType')
if (type == 'enterprise' || type == 'personal') return type as string if (type == 'enterprise' || type == 'personal') return type as string
const legacy = uni.getStorageSync('assessmentDraft') const legacy = getUserStorage('assessmentDraft')
if (legacy != null && typeof legacy == 'object' && draftText(legacy as UTSJSONObject, 'type') == 'enterprise') return 'enterprise' if (legacy != null && typeof legacy == 'object' && draftText(legacy as UTSJSONObject, 'type') == 'enterprise') return 'enterprise'
return 'personal' return 'personal'
} }
export function loadAssessmentDraft(type: string): UTSJSONObject | null { export function loadAssessmentDraft(type: string): UTSJSONObject | null {
const scoped = uni.getStorageSync('assessmentDraft:' + type) const scoped = getUserStorage('assessmentDraft:' + type)
if (scoped != null && typeof scoped == 'object') return scoped as UTSJSONObject if (scoped != null && typeof scoped == 'object') return normalizeAssessmentDraft(scoped as UTSJSONObject)
const legacy = uni.getStorageSync('assessmentDraft') const legacy = getUserStorage('assessmentDraft')
if (legacy != null && typeof legacy == 'object') { if (legacy != null && typeof legacy == 'object') {
const saved = legacy as UTSJSONObject const saved = legacy as UTSJSONObject
if (draftText(saved, 'type') == type) return saved if (draftText(saved, 'type') == type) return normalizeAssessmentDraft(saved)
} }
return null return null
} }
export function normalizeAssessmentDraft(saved: UTSJSONObject): UTSJSONObject {
if (saved['moneyUnit'] == 'wan') return saved
const result: UTSJSONObject = { ...saved, moneyUnit: 'wan', draftVersion: 2 }
// 老草稿只有年营收以万元保存,其余金额均为元;只转换一次。
const keys = ['monthlyIncome', 'totalDebtBalance', 'monthlyLoanPayment', 'creditCardTotalLimit', 'creditCardUsedLimit']
if (saved['moneyUnit'] == 'yuan') keys.push('annualRevenue')
keys.forEach((key) => { result[key] = yuanToWan(draftText(saved, key)) })
return result
}
export function assessmentApiPayload(saved: UTSJSONObject): UTSJSONObject {
const normalized = normalizeAssessmentDraft(saved)
const result: UTSJSONObject = { ...normalized, moneyUnit: 'yuan' }
const keys = ['annualRevenue', 'monthlyIncome', 'totalDebtBalance', 'monthlyLoanPayment', 'creditCardTotalLimit', 'creditCardUsedLimit']
keys.forEach((key) => {
const value = draftText(normalized, key)
result[key] = value.length > 0 ? wanToYuan(value) : ''
})
return result
}
export function formFromDraft(saved: UTSJSONObject): AssessmentForm { export function formFromDraft(saved: UTSJSONObject): AssessmentForm {
return { return {
name: draftText(saved, 'name'), idCard: draftText(saved, 'idCard'), phone: draftText(saved, 'phone'), city: draftText(saved, 'city'), maritalStatus: draftText(saved, 'maritalStatus'), name: draftText(saved, 'name'), idCard: draftText(saved, 'idCard'), phone: draftText(saved, 'phone'), city: draftText(saved, 'city'), maritalStatus: draftText(saved, 'maritalStatus'),
@@ -59,7 +81,7 @@ export function assetValidationMessage(form: AssessmentForm): string {
const amounts = [form.totalDebtBalance, form.monthlyLoanPayment, form.creditCardTotalLimit, form.creditCardUsedLimit] const amounts = [form.totalDebtBalance, form.monthlyLoanPayment, form.creditCardTotalLimit, form.creditCardUsedLimit]
const amountLabels = ['欠款总余额', '贷款月还款金额', '总额度', '已使用总额度'] const amountLabels = ['欠款总余额', '贷款月还款金额', '总额度', '已使用总额度']
for (let i = 0; i < amounts.length; i++) { for (let i = 0; i < amounts.length; i++) {
if (amounts[i].length > 0 && !/^[0-9]{1,12}([.][0-9]{1,2})?$/.test(amounts[i])) return amountLabels[i] + '请输入有效金额,最多两位小数' if (amounts[i].length > 0 && !wanAmountValid(amounts[i])) return amountLabels[i] + '范围为 0–99999.999999 万元,最多六位小数'
} }
return '' return ''
} }
@@ -77,7 +99,7 @@ export function saveAssessmentDraft(type: string, step: number, form: Assessment
const files: UTSJSONObject[] = documents.map((item): UTSJSONObject => ({ name: item.name, path: item.path })) const files: UTSJSONObject[] = documents.map((item): UTSJSONObject => ({ name: item.name, path: item.path }))
const grants: string[] = authorizations.filter((item) => item.selected).map((item) => item.key) const grants: string[] = authorizations.filter((item) => item.selected).map((item) => item.key)
const payload: UTSJSONObject = { const payload: UTSJSONObject = {
type, step, name: form.name, idCard: form.idCard, phone: form.phone, city: form.city, maritalStatus: form.maritalStatus, type, step, moneyUnit: 'wan', draftVersion: 2, name: form.name, idCard: form.idCard, phone: form.phone, city: form.city, maritalStatus: form.maritalStatus,
occupation: form.occupation, company: form.company, monthlyIncome: form.monthlyIncome, socialSecurity: form.socialSecurity, housingFund: form.housingFund, occupation: form.occupation, company: form.company, monthlyIncome: form.monthlyIncome, socialSecurity: form.socialSecurity, housingFund: form.housingFund,
house: form.house, car: form.car, savings: form.savings, creditCard: form.creditCard, otherAssets: form.otherAssets, house: form.house, car: form.car, savings: form.savings, creditCard: form.creditCard, otherAssets: form.otherAssets,
creditLoanCount: form.creditLoanCount, totalDebtBalance: form.totalDebtBalance, monthlyLoanPayment: form.monthlyLoanPayment, creditLoanCount: form.creditLoanCount, totalDebtBalance: form.totalDebtBalance, monthlyLoanPayment: form.monthlyLoanPayment,
@@ -85,10 +107,10 @@ export function saveAssessmentDraft(type: string, step: number, form: Assessment
enterpriseName: form.enterpriseName, businessCode: form.businessCode, annualRevenue: form.annualRevenue, operatingYears: form.operatingYears, enterpriseName: form.enterpriseName, businessCode: form.businessCode, annualRevenue: form.annualRevenue, operatingYears: form.operatingYears,
documents: files, authorizations: grants, documentConsent, dataConsent documents: files, authorizations: grants, documentConsent, dataConsent
} }
uni.setStorageSync('assessmentActiveType', type) setUserStorage('assessmentActiveType', type)
uni.setStorageSync('assessmentDraft:' + type, payload) setUserStorage('assessmentDraft:' + type, payload)
// 保留现有提交与档案入口读取的活动草稿。 // 保留现有提交与档案入口读取的活动草稿。
uni.setStorageSync('assessmentDraft', payload) setUserStorage('assessmentDraft', payload)
} }
export function showAgreementNotice(title: string): void { export function showAgreementNotice(title: string): void {
uni.showModal({ title, content: '完整协议将在相关服务开放时提供。当前勾选仅保存您的选择,不会发起实名认证、查询外部数据或上传资料。', showCancel: false, confirmText: '我知道了' }) uni.showModal({ title, content: '完整协议将在相关服务开放时提供。当前勾选仅保存您的选择,不会发起实名认证、查询外部数据或上传资料。', showCancel: false, confirmText: '我知道了' })
+7
View File
@@ -0,0 +1,7 @@
// 发布前选择目标环境并配置 HTTPS 地址;未配置时请求失败,不回退模拟服务。
export const APP_ENVIRONMENT: string = 'development' as string
const DEVELOPMENT_API_URL = 'http://127.0.0.1/api/v1'
const TEST_API_URL = ''
const PRODUCTION_API_URL = ''
export const API_BASE_URL = APP_ENVIRONMENT == 'production' ? PRODUCTION_API_URL : (APP_ENVIRONMENT == 'test' ? TEST_API_URL : DEVELOPMENT_API_URL)
export const API_DEMO_ENABLED = false
+30
View File
@@ -0,0 +1,30 @@
// 金额转换使用整数分,避免万元与元之间的浮点乘法误差。
export function wanAmountValid(value: string): boolean {
if (!/^[0-9]{1,5}([.][0-9]{1,6})?$/.test(value)) return false
return wanToCents(value) <= 99999999999
}
export function wanToCents(value: string): number {
const parts = value.split('.')
const fraction = parts.length > 1 ? parts[1] : ''
return parseInt(parts[0]) * 1000000 + parseInt((fraction + '000000').substring(0, 6))
}
export function wanToYuan(value: string): string {
if (!wanAmountValid(value)) throw new Error('金额范围为 0–99999.999999 万元,最多六位小数')
const cents = wanToCents(value)
const fraction = (cents % 100).toString()
return Math.floor(cents / 100).toString() + '.' + (fraction.length == 1 ? '0' + fraction : fraction)
}
export function yuanToWan(value: string): string {
if (value.length == 0) return ''
if (!/^[0-9]+([.][0-9]{1,2})?$/.test(value)) return value
const parts = value.split('.')
const fraction = parts.length > 1 ? parts[1] : ''
const digits = parts[0] + (fraction + '00').substring(0, 2)
const padded = '000000' + digits
const whole = parseInt(padded.substring(0, padded.length - 6)).toString()
const decimal = padded.substring(padded.length - 6).replace(/0+$/, '')
return whole + (decimal.length > 0 ? '.' + decimal : '')
}
+11
View File
@@ -0,0 +1,11 @@
import { getSession } from './api.uts'
// 旧的设备级缓存不自动归给新登录用户;按稳定userId隔离,匿名使用独立空间。
export function userStorageKey(key: string): string {
const session = getSession()
const owner = session == null ? 'guest' : 'user:' + (session['userId'] as string)
return 'airz:v1:' + owner + ':' + key
}
export function getUserStorage(key: string): any | null { return uni.getStorageSync(userStorageKey(key)) }
export function setUserStorage(key: string, value: any): void { uni.setStorageSync(userStorageKey(key), value) }
export function removeUserStorage(key: string): void { uni.removeStorageSync(userStorageKey(key)) }