feat: 接入内容服务端真实接口并优化页面交互

- 迁移内容数据到服务端接口,移除本地模拟数据
- 首页新增推荐课程加载与未读消息检查
- 资讯、课程、搜索页面支持分页加载与错误重试
- 登录页新增恢复课程、消息、帮助页的路由逻辑
- 评估分析页新增处理中状态动效与阶段提示
- API 环境切换到正式域名 airz.hqling.cloud
This commit is contained in:
hujun
2026-10-01 20:26:41 +08:00
parent 3b2a0139e4
commit 2a9ff403c4
41 changed files with 21742 additions and 427 deletions
@@ -0,0 +1,3 @@
[ 132ms] [LOG] AI融资已启动 @ http://localhost:5173/App.uvue?import&vue&type=script&setup=true&lang.uts:15
[ 142ms] [LOG] Preload pages in uni-app-x development mode. @ http://localhost:5173/@fs/D:/Develop/HBuilderX/plugins/uniapp-cli-vite/node_modules/@dcloudio/uni-h5/dist-x/uni-h5.es.js:25391
[ 2279ms] [VERBOSE] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o @ http://localhost:5173/#/packages/account/login/login:0
@@ -0,0 +1 @@
[ 136ms] [VERBOSE] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o @ http://localhost:5173/#/packages/account/login/login:0
@@ -0,0 +1,3 @@
[ 534432ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://airz.hqling.cloud/api/v1/assessments/mock-processing/status:0
[ 534894ms] [VERBOSE] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o @ http://localhost:5173/#/packages/account/login/login:0
[ 535838ms] [VERBOSE] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o @ http://localhost:5173/#/packages/account/login/login:0
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
[ 1576ms] [LOG] AI融资已启动 @ http://localhost:5174/App.uvue?import&vue&type=script&setup=true&lang.uts:15
[ 1586ms] [LOG] Preload pages in uni-app-x development mode. @ http://localhost:5174/@fs/D:/Develop/HBuilderX/plugins/uniapp-cli-vite/node_modules/@dcloudio/uni-h5/dist-x/uni-h5.es.js:25391
[ 6617ms] [VERBOSE] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o @ http://localhost:5174/#/packages/account/login/login:0
@@ -0,0 +1,3 @@
[ 133ms] [LOG] AI融资已启动 @ http://localhost:5174/App.uvue?import&vue&type=script&setup=true&lang.uts:15
[ 143ms] [LOG] Preload pages in uni-app-x development mode. @ http://localhost:5174/@fs/D:/Develop/HBuilderX/plugins/uniapp-cli-vite/node_modules/@dcloudio/uni-h5/dist-x/uni-h5.es.js:25391
[ 2219ms] [VERBOSE] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o @ http://localhost:5174/#/packages/account/login/login:0
@@ -0,0 +1,2 @@
[ 59673ms] [LOG] AI融资已启动 @ http://localhost:5174/App.uvue?import&vue&type=script&setup=true&lang.uts:15
[ 59676ms] [LOG] Preload pages in uni-app-x development mode. @ http://localhost:5174/@fs/D:/Develop/HBuilderX/plugins/uniapp-cli-vite/node_modules/@dcloudio/uni-h5/dist-x/uni-h5.es.js:25391
@@ -0,0 +1,26 @@
- generic [ref=e16]:
- generic [ref=e17]:
- generic [ref=e18]: A
- generic [ref=e23]: 融资
- generic [ref=e24]: 欢迎登录 AI融资
- generic [ref=e25]: 智能评估,让融资更简单
- generic [ref=e26]: 密码登录
- generic [ref=e27]:
- generic [ref=e28]: "+86"
- generic [ref=e30]:
- generic: 请输入手机号
- spinbutton [ref=e31]
- generic [ref=e34]:
- generic: 请输入登录密码
- textbox [ref=e35]
- generic [ref=e36] [cursor=pointer]: 登录
- generic [ref=e37]: 使用验证码登录
- generic [ref=e39]: 其他登录方式
- generic [ref=e43]:
- generic [ref=e44]: 微信
- generic [ref=e48]: 一键登录
- generic [ref=e56]:
- text: 我已阅读并同意
- generic [ref=e57]: 《用户协议》
- text: 和
- generic [ref=e58]: 《隐私政策》
@@ -0,0 +1,5 @@
- generic [ref=e62]:
- generic [ref=e63]: 正在读取评估状态
- generic [ref=e64]: 正式提交记录 · mock-processing
- generic [ref=e73]: 刷新状态
- generic [ref=e74]: 查看已提交资料
@@ -0,0 +1,5 @@
- generic [ref=e157]:
- generic [ref=e158]: 正在读取评估状态
- generic [ref=e159]: 正式提交记录 · mock-processing
- generic [ref=e168]: 刷新状态
- generic [ref=e169]: 查看已提交资料
@@ -0,0 +1,5 @@
- generic [ref=e10]:
- generic [ref=e11]: 正在读取评估状态
- generic [ref=e12]: 正式提交记录 · mock-processing
- generic [ref=e21]: 刷新状态
- generic [ref=e22]: 查看已提交资料
@@ -0,0 +1,5 @@
- generic [ref=e10]:
- generic [ref=e11]: 正在读取评估状态
- generic [ref=e12]: 正式提交记录 · mock-processing
- generic [ref=e20]: 刷新状态
- generic [ref=e21]: 查看已提交资料
@@ -0,0 +1,12 @@
- generic [ref=e25]:
- generic [ref=e26]: 我的融资报告
- generic [ref=e32]:
- generic [ref=e33]: 评估概况
- generic [ref=e35]: 匹配结果
- generic [ref=e37]: 改善建议
- generic [ref=e43]:
- generic [ref=e44]: "评估记录 #mock-report"
- generic [ref=e45]:
- generic [ref=e46]: 报告暂不可读取
- generic [ref=e47]: 请从融资档案选择有效的评估记录。
- generic [ref=e48]: 前往融资档案
@@ -0,0 +1,12 @@
- generic [ref=e25]:
- generic [ref=e26]: 我的融资报告
- generic [ref=e32]:
- generic [ref=e33]: 评估概况
- generic [ref=e35]: 匹配结果
- generic [ref=e37]: 改善建议
- generic [ref=e43]:
- generic [ref=e44]: "评估记录 #mock-report"
- generic [ref=e45]:
- generic [ref=e46]: 报告暂不可读取
- generic [ref=e47]: 请从融资档案选择有效的评估记录。
- generic [ref=e48]: 前往融资档案
+30 -4
View File
@@ -1,5 +1,31 @@
# AI 融资移动端
2026-10-01开发更新(0.49 / v1.42):积分 HTTP 协议与隔离开发检查已完成,真实87请求/1079项检查通过;显式Bearer、JSON Content-Type、三个唯一字符串字段、原始查询参数白名单及错误方法405已落实,跨域预检沿用既有入口。主服务238项、HTTP输入190项纯检查及原表23项只读预检通过;新增流水时间/正UINT主键校验,14类质量计数全0,原表仍需4增量。检查创建的独立合成数据库、Token及临时服务已清理,原两表和Redis队列摘要保持。积分仍默认关闭,业务规则未批准,前端页面尚未接入,未部署;最新见[开发进度0.49](docs/开发进度.md)和[积分契约](docs/积分规则与开发契约.md)。
迁移纯110项、独立合成库apply/完整备份/Windows ACL/恢复89项通过:仅复制共享库表结构,在新库合成2会员4流水,实际4增量后重复apply为0;双表schema和NDJSON备份在第二新库完整恢复、摘要一致。已修Windows原生备份路径校验并补路径边界纯检查。两自建库、私密INI/备份及权限probe已清理,原表未执行DDL。下一开发子项为积分页真实读取、登录恢复和原键确认,规则未发布时继续关闭签到。
以下0.48为上一轮后端基础记录;当前能力与检查以0.49为准。
2026-10-01开发更新:POINT-002 积分后端基础进入开发中,新增余额、规则、流水、签到和任务五个路由及领域服务;配置默认关闭,规则版本、奖励和权益/期限/等级策略均为null。旧后台score/level修改及旧User::score路径已封闭,既有level 0/1初始化保持。schema.sql增加期望的两字段/两索引及中文注释,显式迁移脚本已编写并通过只读预检,apply未运行。页面仍是本机体验签到,未接后端,不表示已实现页面真实到账。首阶段奖励额、权益、有效期和等级建议仍未获确认,DEC-POINT/POINT-001保持待确认,POINT-003/004未实施;27页面/63任务不变。最新见[开发进度0.48](docs/开发进度.md)和[积分契约](docs/积分规则与开发契约.md)。
本轮主服务纯237项、写保护纯98项、迁移纯84项、隔离事务45项、只读预检22项及16PHP lint通过。事务检查仅使用127.0.0.1:3309隔离库内随机pts_合成表,覆盖双PHP同键/异键、锁等待跨业务日及trigger故障回滚;原fa_user/score_log结构与数据摘要保持,合成表精确清理。连续两次显式默认只读迁移预检在3309/airz_product_test_20260930通过:4增量、13类质量计数各项0、applied=false,无备份/apply,读前后原两表摘要一致。未实际迁移原表、未验证迁移apply/完整备份/ACL、未HTTP/浏览器/Redis/worker/模型调用,不改正式库或默认.env,未部署。下一步先业务确认、隔离迁移演练及HTTP开发检查,再接积分页面,不复用历史发布授权。
2026-10-01历史设计(0.47):积分规则与技术方案已形成草案,见[积分规则与开发契约](docs/积分规则与开发契约.md)及[开发进度0.47](docs/开发进度.md)。首阶段建议每日10分、上海业务日一次且不补签,资料/邀请奖励关闭;奖励额、权益、有效期和会员等级处理仍待DEC-POINT确认。保留 `/points/check-in`目标路径,拟请求为requestId/expectedRuleVersion/expectedDate,流水拟采用有界游标;复用现有业务键/事件唯一/时间索引,仅拟增加请求键/规则版本、用户请求唯一索引和(user_id,id)游标索引。本轮仅规则/接口/数据库设计,未实现任何积分API、签到到账、迁移或部署,未执行数据库/运行测试/收费调用。POINT-001仍阻塞,POINT-002/003/004未实施,下一步确认首阶段规则后推进POINT-002;总任务数63不变。
以下日期段落保留历史开发记录;当前源码和检查以0.49为准,不复用此前生产迁移或发布授权。
本轮仅文档校验:8文档UTF-8无BOM/无行尾空白、两新方案10处本地链接及围栏配对、两仓库限定文档diff检查通过;未执行运行测试,积分规则尚未批准。
2026-10-01开发更新:ADMIN-003 模型运行参数和调用额度受控配置子项源码已完成。独立后台 `modelconfig/index/update` 只允许暂停新调用、新评估模型Flash/Pro、每日三场景共享次数1–1000和评估尝试1–3;环境启用开关仍是硬限制,旧调用模型冻结,进行中的请求自然收尾。复用保留 `fa_config.ai_runtime_policy` JSON,不新增表/字段、不编辑凭据/地址/timeout/maxTokens。版本比较、CSRF、请求重放和成功审计同事务;技术独立v5模板,旧规则/绑定保持。策略纯83/事务24、并发43、业务路径纯63/隔离DB43、权限97、审计20、HTTP207(95请求)及桌面/移动页面67项检查通过;0模型/worker调用,合成夹具/私密JSON精确清理、原Config/site.php和13表/队列保持。仅本地开发,正式库、默认 `.env`、线上和移动端未改,未部署或验收,最新记录见[开发进度0.46](docs/开发进度.md)及[交接](docs/新会话交接.md)。
2026-10-01历史:分析常驻进程心跳与后台进度超时提示子项源码及隔离检查已完成,记录见[开发进度0.45](docs/开发进度.md)。供应商余额/费用对账仍依赖接口能力确认,外部告警投递方案另定,ADMIN-003保持开发中。
2026-10-01历史:ADMIN-003 首个子项已增加后台只读模型与服务检查、调用用量和会员当日预留额度查询。供应商未探测,未知Token与已知零分开;技术新增独立v4模板,旧角色绑定保留。该阶段未接分析进程心跳,记录见[开发进度0.44](docs/开发进度.md)。
2026-10-01最新:资讯、融资课堂、详情、首页推荐与搜索已接真实服务,课程完成进度按会员存服务器,后台支持内容编辑/发布/下架与章节排序。FAQ已改为服务端读取,帮助页新增文字反馈提交、本人记录和后台处理闭环;正式后端连同消息中心及产品编辑/公共目录已部署至 https://airz.hqling.cloud,FAQ/反馈本轮源码尚未部署。前端体验版API已同步新域名,微信/Android开发编译通过;本轮小程序需重新发行,微信request合法域名改为 `https://airz.hqling.cloud`,头像上传/文件下载还需核对相应平台域名配置。H5未部署、真机及多设备未验收。最新证据与恢复见[交接](docs/新会话交接.md)及[开发进度0.40](docs/开发进度.md);以下日期段落为历史记录。
2026-09-30消息中心:评估成功/失败通知、服务器已读、分类分页、原版本跳转及首页真实红点已完成本机开发,50项隔离数据库、57项HTTP/Playwright、16项索引迁移恢复检查及微信/Android编译通过。fa_user_message只增加一个唯一索引,无新表/字段。正式索引/PHP和本轮前端尚未发布;用户上一版小程序已上传,HTTPS排查暂停。最新状态见[交接](docs/新会话交接.md)顶部及[开发进度0.37](docs/开发进度.md),以下日期段落为历史状态。
2026-09-30产品展示更新:正式库现有六条产品已同步正常业务名称和资料,匹配/详情/报告/对比/行动/助手/PDF去除环境标签,内部来源记录保留。微信开发编译及手机/桌面页面联调通过;本轮源码未部署、小程序/H5未发行。备份与恢复、验证及发布边界见[交接](docs/新会话交接.md)顶部和[开发进度0.36](docs/开发进度.md)。
2026-09-30最新:已实现“最新资料+评估版本”,新建评估新增记录,修改评估回填并在原记录下保存新版本;历史报告/匹配/助手/PDF绑定版本。正式MySQL5.7/airz迁移、配套PHP部署与63项维护验收已完成,服务恢复;小程序/H5尚未发布。微信/Android此前编译通过,体验版API已配置https://aitest.ychysg.cn/api/v1,但外网HTTPS仍连接重置,发行前需解决并复测。完整状态见[交接](docs/新会话交接.md)及[版本契约](docs/评估版本与最新资料.md)。以下日期段落为历史记录;后端.env仍连接远程正式库,本机worker启动说明只可用于显式隔离环境。
@@ -61,11 +87,11 @@ $projectPath = (Get-Location).Path
- 首页及跨包共用的轻量位图位于 `static/mobile/`,专属大图位于各分包的 `static/`,原始图片保留在 `static/images/` 与 `static/icons-generated/`。箭头继续使用 `static/icons/` 下的 SVG。
- 底栏使用 `static/mobile/navigation/` 的专用位图,由现有高清图标按可见边界裁切、等高居中得到。更换素材时保持一致的图案高度和画布留白,避免仅统一图片元素尺寸却出现视觉错位。
- 微信打包忽略原始图片目录和未使用的 `static/logo.png`,规则位于 `manifest.json` 的 `mp-weixin.packOptions.ignore`。新增资源要检查引用与排除规则,最终包体以微信开发者工具结果为准。
- API 环境配置在 `services/config.uts`,开发地址为 `http://127.0.0.1/api/v1`。共享请求已统一Bearer、FastAdmin响应解包及401处理;模拟开关关闭,缺服务配置时失败而非模拟成功。测试/生产地址待配置,生产只允许HTTPS。真机需改为可访问的局域网/正式地址,不能使用手机自身127.0.0.1。
- API 环境配置在 `services/config.uts`,开发地址为 `http://127.0.0.1/api/v1`,当前test环境体验版地址为 `https://airz.hqling.cloud/api/v1`。共享请求已统一Bearer、FastAdmin响应解包及401处理;模拟开关关闭,production地址仍空,切换production前须明确配置HTTPS地址。
## 当前前端交互
个人与企业评估不提供草稿功能:填写页与确认页仅通过内存传递本次输入,离开流程、刷新或退出账户后丢弃;已接入正式提交及历史列表/详情接口;提交后资料只读。选图仅供本机预览,证件资料上传和数据查询尚未接入。课程章节进度、消息已读与体验签到均保存在当前设备,不代表服务器账户数据。
个人与企业评估不提供草稿功能:填写页与确认页仅通过内存传递本次输入,离开流程、刷新或退出账户后丢弃;已接入正式提交及历史列表/详情接口,可修改原评估并增加输入版本。选图仅供本机预览,证件资料上传和数据查询尚未接入。消息、已读状态及课程章节进度已接服务器,首页同真实未读数;体验签到仍保存在当前设备。
个人中心顶部的头像、昵称、手机号区域统一进入修改资料;账户安全独立提供修改密码、验证码重设密码及换绑手机号。昵称和头像缓存按稳定userId隔离,手机号仅保留为内部兼容键。头像已接入后端上传及持久化,限制 500KB 以内的 JPG / PNG / WebP,服务端验证真实图片并重新编码,保存时校验附件归属,不修改融资档案的实名信息。密码登录、原密码改密及退出已接入Redis会话;短信验证和换绑尚未接入,不在本机模拟成功,也不保存密码、验证码或其散列。安全接口成功后清除当前会话并要求重新登录;验证码计时仅在服务端发送成功后启动。
@@ -81,11 +107,11 @@ $projectPath = (Get-Location).Path
新闻与课程的数据定义位于 `packages/content/services/content.uts`。正式匹配与产品适配位于 `packages/finance/services/matches.uts`;GET /assessments/:id/matches及结果详情返回冻结的产品范围、判定和证据;GET /products/:id返回当前公共资料。金额范围使用元的十进制字符串,在适配层精确转换为万元。旧无评估GET /matches和模拟fetchProduct入口已移除。后端产品表使用独立的额度、期限和利率范围字段,匹配分数与理由属于评估结果。申请入口目前仅引导准备资料,不向金融机构提交申请。
首页搜索进入内容分包的 `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`,支持全部、新闻、融资课堂筛选、分页及真实详情跳转。共享 `services/content.uts` 调用 `GET /content/search`,参数为 `keyword`、`type`(`all` / `news` / `lesson`)、`page`、`pageSize`,返回 `items/total/hasMore`;多关键词需同时命中标题、摘要或正文。离页、账户切换和新关键词拒绝迟到响应,空态/断网可重试,无本地内容数组兜底。
## 页面与分包
“帮助与反馈”从个人中心进入内容分包的 `help/help`,按账户使用、融资评估、资料与隐私分组展示可展开的问答,不跳转 AI 助手。数据与客服接入点位于 `packages/content/services/help.uts`;预留 `GET /help/faqs` 返回 `{ items: HelpQuestion[] }`。联系客服当前只展示暂未开放提示,不发起客服会话或提交反馈。
“帮助与反馈”从个人中心进入内容分包的 `help/help`,按账户使用、融资评估、资料与隐私分组展示可展开的问答,并提供文字反馈提交、本人记录及处理回复,不跳转 AI 助手。FAQ数据位于 `packages/content/services/help.uts`,反馈数据位于 `packages/content/services/feedback.uts`;公开FAQ使用 `GET /help/faqs`,反馈接口需要登录。附件服务尚未开放,联系客服当前只展示暂未开放提示。
`pages/` 只保留 `home/home.uvue`,也是唯一主包页面。页面注册统一维护在 `pages.json` 的 `pages` 和 `subPackages`,分包内的 `path` 相对于 `root`。
+169 -32
View File
@@ -1,9 +1,143 @@
# AI 融资平台开发文档
> 版本:v1.30 · 基线日期:2026-09-22 · 更新日期:2026-09-30 · 状态:正式库6条产品已同步正常业务文案,报告/PDF/助手去标签的3个PHP文件已正式部署并完成76项服务器验收,页面改动待发行;产品地区待核实、完整发布问题清单及既有编辑/公共目录本地联调完成,四条公开银行材料为隔离库草稿,未部署。最新资料/评估版本及会话任务列精简已完成正式库维护;小程序/H5未发布,外网HTTPS沿用此前连接重置状态。原型04—10及PDF既有功能保留。
> 版本:v1.42 · 基线日期:2026-09-22 · 更新日期:2026-10-01 · 状态:积分后端及真实HTTP隔离开发检查完成,默认关闭、DEC-POINT待确认;原表未迁移、积分页面未接、未部署。模型运行参数/调用额度受控配置、后台分析进程心跳/进度超时提示、只读服务检查/调用用量、评估排障、受控人工重试与操作审计、业务权限模板、资料/消息只读及敏感字段保护源码完成未部署;FAQ服务端和反馈闭环源码完成未部署。资讯/课堂/搜索/会员课程进度、消息中心和产品编辑/公共目录已正式部署至https://airz.hqling.cloud;新域名公网HTTPS通过。微信/Android此前开发编译通过,小程序需重新发行,H5未部署、真机及多设备待验收。最新资料/评估版本及会话精简约定保留。
> 配套台账:[开发进度](开发进度.md)。本文定义范围、接口和验收口径,台账记录实际执行与证据。
> 文档初始规划后已开始开发;以下更新记录优先于原始基线。除明确列为已实现的部分外,计划接口和增量数据仍待开发。
### 0.49 2026-10-01 积分协议与隔离开发检查
五个积分接口仅接受显式合法Bearer,不使用Cookie或旧Token头回退。签到须application/json(可选UTF-8 charset)及恰好三个唯一字符串字段,普通与Unicode等价重复JSON键均拒绝;查询校验原始QUERY_STRING、解码键白名单/唯一性及与PHP解析值一致,拒绝重复、NUL、坏编码和空片段。五条精确路由将错误方法交由积分守卫统一405,原始方法与有效方法必须一致、覆盖头拒绝;允许Origin的无令牌OPTIONS由既有Common入口处理。v1框架错误JSON补no-store,未知路由仍404;这一缓存头适用于其它v1框架错误,不改变其状态和错误体。
真实HTTP87请求/1079项检查通过,覆盖草案/发布/暂停/坏规则、跨日/版本冲突、丢弃已提交成功响应后的原键确认、账户停用/删除/撤销、本人快照分页/旧NULL/隐私、余额边界和Redis不可用。HTTP脚本使用独立临时ROOT/RUNTIME、随机ENV_PREFIX、新合成数据库及Redis DB15随机Token作用域,不加载仓库正式.env。测试数据库、Token、临时服务与文件已清理,原fa_user/score_log和原Redis队列摘要一致,无worker/模型调用。
新增非NULL历史流水时间校验(上海四位年份上限253402271999)和正UINT主键校验;NULL时间保留,首次快照坏主键返回POINT_LEDGER_INVALID。主服务238项、HTTP输入190项纯检查通过;23项原表只读预检通过,仍4增量/14类质量计数全0。业务规则未获确认,运行配置false/null,POINT-002保持开发中、POINT-003/004未实施,页面仍本机体验,27页面/63任务不变。下一开发子项可接积分页真实读取和原键确认状态;启用真实奖励、原表迁移及部署仍需明确安排。
迁移纯110项、独立apply/备份/Windows ACL/恢复89项通过。检查只复制共享库两表结构,另建两个随机合成库(2会员4流水);第一库apply4增量、重复apply0,二表完整schema+NDJSON备份恢复到第二库后逐列摘要一致,目录/3文件仅当前用户ACL及读写probe通过。实际演练修复Windows原生备份路径识别及父级路径拒绝,新增纯validateBackupPath检查。两新库、私密INI/备份/probe已按所有权清理,原表保持。安全回执在C:/Users/JUN/AppData/Local/Temp/airz-points-migration-20261001/apply-20261001-115749-d50a5cd87fd5a84a/verification.json;该恢复仅为合成环境检查,不是生产恢复方案执行证据。
### 0.48 2026-10-01 历史:积分后端基础(默认关闭、未迁移/未部署/未接页面)
新增PointsRules/UserPoints、v1 Points控制器及五条严格路由,复用认证会员身份,提供余额、规则、ID有界流水、每日签到和任务状态。配置安全默认enabled=false,ruleVersion/reward及权益/期限/等级策略为null;损坏或不可读规则固定失败,不能用体验10分兜底。每天10分、不自动过期、level不联动等仍是待确认建议,用户尚未定稿,DEC-POINT/POINT-001保持待确认;POINT-002仅后端基础开发中,POINT-003未做,POINT-004页面仍本机体验。27页面、63任务及编号不变,完整契约见[积分规则与开发契约](积分规则与开发契约.md)。
签到恰好requestId/expectedRuleVersion/expectedDate三个字段;先锁正常会员,再查成功请求回执,未命中才校验锁后上海业务日、固定当日事件和当前发布规则。余额与一条冻结流水同事务,原成功请求可跨日/暂停重放,回执balanceAfter只代表历史余额;同日异键拒绝,无零分占位。只有1062/1205/1213且明确回滚才返回写冲突,未知提交/回滚为账本不可用。流水只本人、字符串ID游标、历史NULL元数据保留,不返回自由memo;未实施资料/邀请任务为unavailable、reward=null。
旧通用后台score/level直接修改、伪造字段与旧User::score已封闭,原level 0/1初始化保持,不新增管理员积分调整。schema.sql和ScoreLog中文注释增加期望request_id CHAR(36)/rule_version VARCHAR(64),ASCII ascii_bin可空,唯一(user_id,request_id)与idx_user_point_id(user_id,id);原业务键/事件唯一/时间索引复用。新增[显式积分迁移脚本](D:/AAdesk/202609/airz/database/migrate_points.php),默认只读,apply须精确环境/目标、停写确认和双表完整私密备份;MySQL DDL隐式提交,不自动修复或整表恢复。脚本实际apply和ACL仍未运行,任何原fa_user/score_log未执行迁移。
本轮检查:主服务纯237项、旧写保护纯98项、迁移纯84项、隔离事务45项、只读预检22项及16PHP lint通过。事务检查只在127.0.0.1:3309/airz_product_test_20260930的随机pts_合成表执行,覆盖真正双PHP同键/异键、锁等待跨业务日及trigger故障回滚;原fa_user/score_log结构与数据摘要保持,合成表精确清理。相同精确目标连续两次实际默认只读迁移预检通过:4增量、13类质量计数各项0、applied=false,无backup/apply,原两表读前后结构/数据摘要一致;PDO原生事务追踪已修正。未连接正式库或默认.env,未HTTP/浏览器/Redis/worker/模型调用,未部署或进行运营/真机验收。下一步先确认业务规则、显式隔离迁移验证及HTTP开发检查,再接积分页面;后端源码/合成表测试不能作为页面已到账证据。
### 0.47 2026-10-01 历史:积分规则与开发方案草案
本轮仅整理[积分规则与开发契约](积分规则与开发契约.md)及后端[积分数据库设计](D:/AAdesk/202609/airz/database/points-design.md),没有实现API、签到到账、迁移脚本或部署,不执行数据库、运行测试或收费模型调用。POINT-001仍受DEC-POINT待确认阻塞,POINT-002/003/004未实施,任务总数63不变;文档方案不能作为运行能力或业务验收证据。
首阶段建议每日10分、服务端Asia/Shanghai每天一次且不补签,不启用连续加成,资料/邀请奖励关闭。本机体验签到及固定邀请码不导入、不补发;真实余额保留服务端score。奖励额、权益范围、有效期及会员level处理仍须明确确认,未发布配置安全默认停止新入账且奖励为null,不能用页面10分兜底。旧后台绝对值score编辑和User::score吞异常/联动level是实施前门禁,需封闭或统一共同账户锁及事件审计。
拟 `POST /points/check-in`恰好三个字段requestId/expectedRuleVersion/expectedDate;expectedDate是页面读取的服务端日期预期,不能指定补签,未成功请求必须匹配取得用户锁后的上海当日。成功流水拟保存事件键、请求键、规则版本及before/delta/after冻结回执,原请求可跨日/暂停返回旧回执但不覆盖当前余额,回执后重新GET余额。`GET /points/ledger`拟改有界snapshotId/beforeId游标,目前无已发布积分API,不涉及分页兼容迁移;具体DTO及固定错误见主契约。
仓库结构已有fa_user_score_log的business_type/business_id、uk_user_business及idx_user_time,不重复添加;首阶段仅拟增加可空request_id/rule_version、唯一(user_id,request_id)及 `idx_user_point_id(user_id,id)`游标索引,不建独立签到/请求/连续表,邀请关系留后续POINT-003。没有连接目标库或执行DDL,实际实施前仍需显式目标预检/备份/保护所有旧写方。下一步先确认首阶段规则后POINT-002,发布和验收另行安排,不复用历史授权。
本轮文档校验:8个目标文档UTF-8无BOM/无行尾空白,两新方案10处本地链接目标存在、代码围栏配对,两仓库限定文档diff检查通过(仅既有LF/CRLF提示)。只读方案复核已核对已有列/索引,并补清错误优先级、已确认回滚竞争错误与ID分页索引。未连接数据库/Redis/HTTP、未调用模型,也未进行运行测试;POINT-001决定仍未批准,不将文档检查当作开发功能通过。
### 0.46 2026-10-01 模型运行参数与调用额度受控配置
ADMIN-003 本轮完成独立后台 `GET modelconfig/index` 与 `POST modelconfig/update`受控配置子项源码。复用 `fa_config` 的保留名称 `ai_runtime_policy` 存 `schemaVersion=1` JSON,不新增表或字段;通用配置入口、模型保存/删除和配置文件刷新保护该行,不允许绕过专用入口或落入公开配置文件。只开放四字段:是否接受新调用、新评估使用Flash/Pro、同会员每日评估/产品解释/助手共享次数1–1000、评估最大尝试1–3;助手和产品解释仍固定最多3次,不编辑凭据、供应商地址、timeout、maxTokens或实际环境开关。
环境 `deepseek.enabled`是硬限制,有效开关为环境启用且运行策略接受调用;页面不能绕过环境禁用。策略无跨操作缓存,三场景在会员锁内一次读取共享锁保护的当前策略快照,预留次数沿既有口径,降低限额不清零已有预留。没有保留行时沿环境配置,记录损坏或数据库不可读时停止接受新模型调用并返回固定错误,不静默启用。新评估按新策略冻结模型,旧调用日志的模型继续使用;正在curl的调用自然收尾,暂停后的未开始任务沿既有禁用路径失败,已有预留不退回,不恢复或清空队列。
保存只接受四字段、64位小写十六进制 `expectedVersion`、`cfg_`加32位小写十六进制 `requestId`、固定 `reasonCode` 和CSRF。管理员/当前权限重新核验、策略CAS版本比较和成功审计在同事务;审计失败回滚。成功原请求重放返回原回执,不覆盖后续修改;同键改参数或旧版本冲突拒绝,失联后用原键确认结果。拒绝日志只保存安全请求标识/版本/原因/固定码,不记录完整POST、CSRF、凭据或异常原文。
新增 `modelconfig`、`modelconfig/index`、`modelconfig/update` 三节点及独立技术 `v5`,继承技术v4,旧技术v4/v3/v2/v1、客服v2、运营v1和账号绑定保持。仅隔离库 `127.0.0.1:3309/airz_product_test_20260930`显式备份应用4项、重复预检0项,不进行生产迁移或换绑。仅本地开发和显式隔离检查,正式库、默认 `.env`、线上、真实worker和移动端未改,不部署或做运营/真机验收。ADMIN-003仍开发中,供应商账单/余额能力和外部告警方案未确认;维护见[服务检查与受控配置](D:/AAdesk/202609/airz/database/admin-services.md)及[后台权限说明](D:/AAdesk/202609/airz/database/admin-permissions.md)。
开发检查通过:策略纯83项/事务24项、双PHP/双连接并发43项、业务路径纯63项/隔离DB43项、权限97项、审计20项,原服务纯53/用量76/重试39项;服务HTTP207项(95请求)及Chrome Playwright桌面1280×900/手机390×844页面67项通过。首行竞争实测MySQL1213只有一方保存成功,确定1062/1205/1213映射冲突且不自动重试;同键重放、异意图拒绝、审计失败回滚、暂停/旧模型冻结及策略故障安全关闭均覆盖。坏记录禁止编辑,服务返回固定MODEL_RUNTIME_POLICY_INVALID,用量仍保留已预留数,额度/剩余数/最大尝试为NULL。页面覆盖提交已成功但响应丢失→刷新保留原键→同审计/版本重放、另一管理员冲突自动刷新和旧技术v4拒绝配置但可查服务。无未解释console/pageerror、横溢或文字重叠,一条刻意中断响应的网络错误另记。
收尾修复:保存确认关闭动画期间快速响应不再阻断冲突后的自动刷新;服务规范化策略及三个请求元字段的顺序,同值同请求键仅改变POST字段顺序仍返回原回执,不覆盖后续版本或重复审计。乱序幂等新增2项纯检查/1项真实隔离事务检查,6表摘要一致并完整回滚。
0模型/worker调用;并发夹具精确清理、14表摘要一致,HTTP held清理3项通过并复核原13表/Redis原队列;原Config行与site.php摘要保持,凭据JSON和scope私密JSON删除。最终23PHP lint、2JS语法、26源码/测试文件UTF-8无BOM和空白检查通过;原3309/PID19100、8097/PID43340仍仅监听127.0.0.1,无本机analysis:work。证据 `C:/Users/JUN/AppData/Local/Temp/airz-admin-runtime-policy-20261001/verification.json`、`ui-result.json`及 `saved-desktop.png/saved-mobile.png/invalid-mobile.png`。临时页面脚本依赖已删除夹具,不能直接重跑,须新建显式隔离夹具;继续先复查监听与bootstrap,不用默认环境或旧cleanup/reset。本轮不引入供应商余额或外部告警实现承诺。
### 0.45 2026-10-01 分析进程心跳与进度超时提示
ADMIN-003 本轮完成常驻 `analysis:work` 进程的只读可观测性子项源码。使用独立Redis JSON观测键,键名按数据库配置和 `analysis.queue` 派生隔离摘要;不复用队列载荷或数据库业务字段。只观测最近登记的一个常驻进程,新实例可接替旧实例,随机实例标识本次启动;后续进度更新和停止标记原子核验instanceId及startedAt,首次登记响应丢失后也不能重复无条件覆盖新owner。普通进度更新可原子补登记缺失键,停止不复建缺失键。`--once` 和 `--recover-only` 不登记,不能被当作常驻服务。
进程在启动、工作边界、空闲、队列降级和退出路径记录 `starting/working/idle/degraded/stopped`,停止上报不证明任务正常完成。Redis失败采用尽力记录,不能影响任务执行、调用预算、队列租约、业务日志或已有恢复机制;不改 `DeepSeekClient`,阻塞模型请求期间不虚构持续进度。超时阈值取 `max(analysis.heartbeat.stale_after_seconds默认180, deepseek.timeout+60, analysis.lease_seconds, 30)`;观测TTL为86400秒且须大于实际阈值,超时记录在过期前仍可读取。
后台沿用 `GET servicestatus/index` 的只读权限,展示最后进度时间、记录年龄和阈值。缺记录、未启用、格式损坏、Redis不可用与进度超时分别返回固定状态,不输出进程/实例标识、环境地址、队列名、路径或原异常。超时只表示超过阈值未记录进度,不能判定操作系统进程死亡、其他worker状态、任务是否一定失败或供应商是否健康;外部告警投递尚未接入。
本子项无新增权限节点/模板或数据库结构;技术 `v4` 与历史角色绑定沿用,不进行迁移或换绑。仅本地开发和显式隔离检查,正式库、默认 `.env`、线上及移动端源码未改,不部署、不做运营或真机验收。ADMIN-003 保持开发中,供应商对账和受控配置编辑仍待实施。实现契约、隔离验证和回退边界见后端[服务检查维护](D:/AAdesk/202609/airz/database/admin-services.md)。
本轮检查通过:心跳纯133项、真实隔离Redis90项、服务纯53项/隔离数据库14项、服务HTTP207项(95请求)及Chrome Playwright 1280×900/390×844页面43项;9个PHP文件lint及JS语法通过。覆盖owner接替/首次响应丢失、丢键补登记、停止不复建、exit/致命错误退出上报、Redis故障和上述9种状态,页面无console/pageerror、文档横溢或文字重叠。0模型调用,合成夹具/观测键/私密JSON及专用PHP8098精确清理,原13表与原队列摘要保持;原3309/8097保留,无真实worker。安全回执与截图 `C:/Users/JUN/AppData/Local/Temp/airz-admin-heartbeat-20261001/verification.json`、`ui-result.json`、`redis-result.json`;0.44的检查数字保留为上一阶段。
下一开发项建议推进模型运行参数和调用额度的受控配置;供应商余额/费用对账仍依赖接口能力确认,外部告警投递方案另定。该建议不授权生产配置修改或发布。
### 0.44 2026-10-01 只读模型与服务检查、调用用量
ADMIN-003 首个子项新增后台 `GET servicestatus/index` 和 `GET servicestatus/usage`。页面分服务检查/调用用量两视图,按会员ID、日期、场景和状态查询,20条分页,支持刷新、重置、空态和失败恢复。加载期间锁定当前查询控件,清除旧指标和分页,避免失败时误读旧结果;桌面和移动表格分别在内部横向滚动。控制器包含超管的所有写入口拒绝,不派发、恢复、消费队列或直接调用模型。
服务检查只读取当前PHP配置、安全布尔值/计数和固定错误码。模型检查启用、运行凭据存在、固定HTTPS来源和模型选择,不请求供应商;数据库为基本读取,队列为配置连接的PING/LLEN/ZCARD,登录存储为Redis PING;本地上传目录仅检查存在/可写,不写探针文件。业务短信尚未接入,分析进程没有心跳,状态明确为待接入/未知,队列为空不能证明进程存活。未输出地址、目录、队列名、凭据或异常原文。
调用查询仅投影已识别评估/产品解释/助手的安全元数据;日期为PHP时区日历日、首尾包含,默认当日,最多31天。已删除记录不进入报表;会员今日额度独立遵循真实预算,统计今日00:00后全部assistant预留,包含失败、软删除、遗留和未来时间记录,不随历史窗口/场景/状态变化。称为已预留次数,不推算收费。Token单值和汇总以十进制字符串保精度,未知为NULL并统计未知记录,已知零为字符串0,不读取正文、输入、报告、匹配或上下文。已登记模型/密钥与用户Token额度尚未参与真实执行,不能当作供应商余额。
新增3个权限节点和独立技术 `v4` 模板,旧技术 `v3/v2/v1`、客服 `v2`、运营 `v1` 规则及账号绑定保留,不自动扩权。无新增业务表/字段;正式库、默认 `.env`、线上和移动端源码未改,未部署或开展运营/真机验收。ADMIN-003 保持开发中,供应商余额/费用对账、受控配置编辑和心跳告警仍待开发。接口、显式隔离测试及恢复见[服务检查维护](D:/AAdesk/202609/airz/database/admin-services.md)。
本轮开发检查通过:服务纯48项/隔离数据库13项、用量纯76项/隔离数据库26项、权限定义90项;服务HTTP203项(95请求)、原后台权限227项(158请求)、Chrome Playwright 1280×900/390×844页面56项。涵盖大整数/未知及零、今日额度与历史筛选分离、真实20条翻页、加载防重复及错误清旧结果、旧角色拒绝和只读超管;无console/pageerror、文档横溢或文字重叠。合成夹具精确清理、凭据JSON删除,13表原有行及Redis原队列摘要保持,0模型调用。安全回执 `C:/Users/JUN/AppData/Local/Temp/airz-admin-services-20261001/verification.json`;原3309/8097保留、worker未启动。下一开发子项优先补分析进程心跳及失联提示,外部告警投递方案另定。
### 0.43 2026-10-01 受控人工重试与操作审计
ADMIN-002 继续实施已有评估任务的人工重试。新增 `POST assessment/retry`,仅接受正文中的评估 ID、CSRF、`adm_` 加32位小写十六进制请求键、预期当前版本/任务 ID 和固定原因枚举。详情页有失败版本确认弹窗、重复提交保护、失联后原键确认、接受后的状态刷新和操作日志入口。请求键冻结原重试意图,参数变化或版本/任务变化返回冲突;成功重放返回原任务,不重新增加次数。
管理员、会员、会话和当前输入加锁,复用用户重试的失败状态、尝试上限、每日调用预留和生命周期。成功审计复用 `fa_admin_log`,与调用预留和回执同事务提交,审计失败全部回滚。提交后才派发;派发失败仍返回已接受的持久回执,原键重放可补投。日志仅保存安全 ID、原因、前后次数和稳定结果,不记录CSRF、资料、报告或模型正文。后台请求命名空间与用户请求分开,排障可按管理员请求 ID 查到原版本与任务。
新增 `assessment/retry` 只进入独立技术 `v3` 模板;旧技术 `v2`、客服 `v2`、运营 `v1` 规则及绑定保留,不自动扩权。只在本机隔离库备份应用2项权限增量,复检0项;没有业务表字段迁移。正式库、默认 `.env`、线上与移动端源码未改,未部署、未做运营或真机验收。ADMIN-002 保持开发中,完整外部查询/对账依赖 DATA-004;下一独立开发项可做 ADMIN-003 服务状态检查。接口、测试与恢复见后端[评估排障维护](D:/AAdesk/202609/airz/database/admin-assessment.md)。
本轮开发检查通过:重试服务纯39项/隔离事务45项、审计90项、权限定义82项、诊断纯48项/隔离事务47项;重试HTTP与数据库1016项(91次请求),独立PHP并发、成功及拒绝审计均故障、队列失联/原键补投均覆盖。原排障回归1075项(200次请求)、原权限227项(158次请求)通过。Chrome Playwright桌面1280×900/移动390×844页面38项通过,包括响应丢失后取消/页面内刷新仍保留原请求、CSRF恢复、冲突刷新、原任务/审计重放和旧角色隐藏按钮;仅刻意拦截产生一次预期网络错误,无新增未解释console/pageerror。合成账号、评估、日志、触发器和随机队列精确清理,13表原有行及其他队列摘要一致,0次模型调用。证据在 `C:/Users/JUN/AppData/Local/Temp/airz-admin-retry-20261001/verification.json` 与 `ui-result.json`;8098专用PHP已停止,3309与原8097保留。
### 0.42 2026-10-01 已有评估任务只读排障
ADMIN-002 首个子项源码与隔离开发检查完成。FastAdmin 新增 `GET assessment/index` 和 `GET assessment/detail?ids=<assessmentId>`,按评估 ID、任务 ID、请求 ID、会员 ID 精确筛选,并支持类型、状态、阶段及限定排序。列表默认20条、上限100,详情提交版本与调用尝试独立分页。详情展示当前任务、派发/租约、阶段耗时、历史失败尝试和已有 Token/耗时;历史任务和重试请求可关联原版本,不切换到最新结果。接口契约与维护见后端[排障说明](D:/AAdesk/202609/airz/database/admin-assessment.md)。
查询服务在数据库中投影 JSON 元数据,不读取或返回提交表单、报告正文、匹配内容、分析上下文、模型正文、供应商异常原文或密钥。产品解释调用排除;无版本关联的遗留调用明确标记,未知用量显示未知,迟到用量更新时间不当作完成时间。配置/额度拦截产生的零调用任务保留诊断;坏版本、错会话指针、缺任务或运行租约分别给稳定提示。所有写入口包含超管均拒绝,不派发、恢复、重试或调用模型。
权限仅新增 `assessment`、`assessment/index`、`assessment/detail` 三节点和独立客服/技术 `v2` 模板,运营保持 `v1`。旧客服/技术 `v1` 及账号绑定保留,不自动扩权;正式分配 `v2` 前须配套发布排障控制器和后台资源。隔离库增量预检5项、备份应用后复检0项,精确刷新隔离菜单缓存,无业务表/字段变更。
48项纯投影、42项隔离事务及1053项HTTP/数据库检查通过(194次请求);桌面1280×900与移动390×844页面27项交互检查通过,查询、详情弹窗、版本切换、分页、错误及空态可用,无新增控制台错误。截图与临时脚本保存在 `C:/Users/JUN/AppData/Local/Temp/airz-admin-assessment-20261001/`,浏览器夹具及账号已精确清理,原记录保持,0次模型调用。正式库、默认 `.env` 与移动端源码未改,未部署或开展运营/真机验收。ADMIN-002 保持开发中,人工重试、操作审计及完整外部查询/对账仍待后续开发,外部查询依赖 DATA-004。
### 0.41 2026-10-01 后台菜单与角色权限
ADMIN-001 复用现有管理员权限表,新增按规则名称解析实际 ID 的运营/客服/技术模板,包含动作及必要父菜单;不创建账号或自动绑定。运营管理公开产品/内容,客服处理反馈和只读融资资质,技术查看配置及受控启停密钥。`user/info/financial` 是字段授权节点,不是用户 API 或可调用业务动作;技术不授该节点。各角色均可管理本人配置,未授权仪表盘时首屏进入有权业务页。完整矩阵及增量初始化/缓存/恢复约定见后端[权限维护说明](D:/AAdesk/202609/airz/database/admin-permissions.md)。
会员/资料身份保持掩码,资料附件和证件原文不对后台返回;资料与系统消息禁止任何后台写入,超级管理员也不例外。列表及模型关联选择器使用查询/输出白名单,密钥不回显且批量只允许启停、不允许改消耗或所属模型。反馈历史附件不回显,日志写入和历史读取统一脱敏并省略 URL 查询串。通用排序/缓存清理重新执行权限校验,三个业务模板均不授这些系统操作。
源码及隔离开发检查完成,没有生产变更或移动端修改;FAQ、反馈字段/业务和本轮受控后台必须配套发布后才能在正式后台绑定新模板。ADMIN-002 当前只读子项见0.42;ADMIN-003 尚未实现,ADMIN-004 运营验收仍未开展。
### 0.39 2026-10-01 FAQ服务端化开发
FAQ复用`fa_acticle`的`type=6`,正文保存为`schemaVersion=1`、`kind=faq`的JSON,包含分类、问题和答案;公开接口为`GET /api/v1/help/faqs`,只返回已发布且结构有效的内容,支持按分类筛选。新增`Faq`后台编辑模型、控制器和编辑页,沿用现有状态、管理员权限和版本冲突保护,不新增数据库表或字段。
初始FAQ材料位于`database/faq_initial.json`,通过`database/import_faq.php`按显式环境和目标导入,重复按slug保留已有记录,预检默认回滚。前端`packages/content/services/help.uts`已删除本地FAQ兜底,服务端为空或失败时按现有空态/重试展示。正式数据库、PHP部署和前端发行均未执行。
### 0.40 反馈提交与处理闭环
会员反馈接口为`POST/GET /api/v1/feedback`与`GET /api/v1/feedback/:id`,鉴权会员ID来自Bearer会话。提交体只接受`content`、`attachmentIds`、`requestId`;内容去HTML、保留换行、最多255字,`requestId`为8—64位ASCII安全字符。同用户同请求键同正文幂等返回记录,不同正文返回409;当前附件服务未开放,`attachmentIds`必须为空数组。
返回项为`id/content/attachmentIds/status/reply/createdAt/updatedAt/handledAt`,状态包括`pending`、`processing`、`resolved`、`closed`。列表只读取当前会员,`page/pageSize`默认20、上限50;详情严格校验归属。后台反馈编辑白名单仅为`status/reply`,状态进入处理中或完成状态自动记录`handled_by/handled_at`,回到待处理则清空;新增、删除、批量更新和导入均拒绝。
`fa_feedback`迁移由`database/migrate_feedback.php`执行,新增请求键、回复和处理元数据列及唯一索引,历史NULL请求键保留。脚本默认只预检,必须显式`--env`、`--target`,写入还需`--apply --backup-dir`;本轮只在隔离库应用并有完整备份,未执行正式迁移或部署。帮助页的意见反馈使用空附件数组、失败重试复用请求键,并展示本人的处理状态和回复。
### 0.38 2026-10-01 内容、学习进度与新域名发布
新闻、融资课堂、详情、首页推荐与搜索同步接真实API,列表category/topic及page/pageSize分页,返回items/total/hasMore。搜索type=all/news/lesson、多词AND匹配发布正文并按来源IP限流60次/分钟,不记录原始关键词;未发布、结构不合法或下架内容不公开。前端共享services/content.uts,分包适配保留原型字段/图片,支持空态、断网重试和迟到响应保护,无静态数组兜底。
新闻复用fa_acticle.type=5,课程复用fa_finance;既有content TEXT保存schemaVersion=1 JSON。后台结构化编辑/发布/下架、封面上传、章节排序及版本保护完成,旧协议正文兼容。章节ID固定,新增fa_user_lesson_progress记录当前会员、课程、章节、正文摘要和首次完成时间;重排保留完成,正文更新失效,lessonVersion冲突409,重复完成幂等。现有业务表不增字段;FAQ服务端未实施,CONTENT-001/004保持开发中。
40项隔离数据库、81项内容HTTP/Playwright、13项后台检查及微信/Android开发编译通过,原数据清理复验一致。正式增量维护已新增课程进度表与消息唯一索引、按slug导入4篇新闻/3门课程、限定发布34个PHP/后台资源文件(含产品管理),64项服务器HTTPS/FPM及假模型检查通过、0收费调用,原有数据与私密配置不变,worker/入口恢复。公网内容200、匿名受保护接口401、敏感路径404、HTTP301及TLS校验通过。
正式域名https://airz.hqling.cloud,前端test环境API为/api/v1。小程序需重新发行并设置微信request合法域名;H5未部署,真机、多设备与真实模型未验收。恢复及备份见[内容维护说明](D:/AAdesk/202609/airz/database/content-publication.md)与开发进度0.38;旧段落中的未部署和HTTPS阻塞为历史状态。
### 0.37 2026-09-30 站内消息契约(历史本机阶段)
消息页与首页复用共享services/messages.uts,真实读取当前会员消息,支持分类/游标分页、单条/全部已读、原输入版本报告/档案跳转、空态和失败重试。登录恢复、离页/换分类/账户切换拒绝旧响应;分页错误保留在末端,底栏不遮挡列表。所有阅读状态在服务器保存,不复用旧本机静态已读键。微信/Android本地编译及H5手机/桌面联调通过。
复用fa_user_message,唯一键(user_id,business_type,business_id),无新表/列。评估成功/失败事件同事务落库,business_id是system输入日志revisionId,同版每事件最多一条;重试成功新增成功通知,失败重试保持原通知。详情校验会话及日志归属/删除状态,不返回任意URL;原失败版本后来成功时打开该版本报告,不切到会话最新版本。
实际接口及字段见5.5和后端[消息维护说明](D:/AAdesk/202609/airz/database/user-messages.md)。未读数与全部已读均为全分类;分页固定snapshotId,批量仅更新id<=snapshotId,新消息保持未读。50项隔离数据库、57项HTTP/Playwright及16项迁移恢复检查通过,数据清理25表原摘要一致,无收费调用。MSG-001—004待正式部署/真机验收;本轮索引、PHP及前端尚未发布,用户上一版上传不包含本轮变更。站内消息不包含订阅推送、群发或新的运营后台。
### 0.36 2026-09-30 正常业务文案与来源展示
当前六条产品用于开发,按用户要求前台统一采用业务名称、机构显示名及正常材料/条件/建议,内部来源标记不作为可见标签。来源publisher/title为产品管理中心/产品参数与准入条件说明;稳定source reference不变,前端显示“产品规则档案”,产品/来源规则版本增加。金融参数、用途和准入条件保持,inputVersion=3不采集融资金额/期限/用途的约定不变;来源文字更新不新增银行核验结论。
@@ -452,10 +586,10 @@ REPORT-004页面子项完成本地联调:report/report改为评估概况、匹
| 第三方身份 | 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和context_data,保存本人评估/产品ID及快照摘要引用;当前无这两列;历史普通会话默认NULL,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_finance、fa_user_lesson_progress | 已实现content JSON带稳定章节ID/版本,进度唯一(user_id,lesson_id,chapter_id),正文摘要判定失效 | CONTENT-001、CONTENT-003 |
| 内容发布与FAQ | fa_acticle | 新闻type=5,FAQ type=6;结构化正文复用content JSON,协议原正文保留 | CONTENT-001 |
| 消息去重 | fa_user_message | 现有user_id/business_type/business_id组合唯一索引,无新列;批量已读按列表snapshotId水位更新,正式已迁移 | MSG-001、MSG-002 |
| 签到与邀请 | fa_user、fa_user_score_log | 已有业务键/事件唯一/时间索引;schema期望request_id/rule_version、用户请求唯一及idx_user_point_id(user_id,id),迁移脚本已编写但原表未迁移;成功流水冻结回执,邀请码唯一和关系表留POINT-003,见[积分契约](积分规则与开发契约.md) | POINT-002、POINT-003 |
| 反馈处理与客服 | fa_feedback | 处理状态、回复、处理人/时间;客服历史优先使用供应商能力,只保存必要关联标识 | SUPPORT-002、SUPPORT-003 |
变更只追加有明确业务目的的字段或关系。新增这些支撑表不改变“不新增独立评估、报告、匹配表”的约定。详细DDL在对应数据任务内提交审阅;已有数据需先预检、备份、回填验证,再收紧约束。不能直接重跑原空业务表限定的迁移脚本。
@@ -638,7 +772,7 @@ REPORT-004页面子项完成本地联调:report/report改为评估概况、匹
**目标:** 后台发布的内容、图文课程和FAQ可被前端检索阅读,进度跨设备一致。 **阶段:** M3。**剩余:** 4–6人日。
- **现状:** 前端本地资讯/课程/FAQ,课程进度为设备级键;后台存在表和CRUD但发布流程不完整。
- **现状:** 资讯/课程/搜索/首页真实接口、云端进度及发布后台已正式上线;FAQ服务端源码已完成但尚未导入/部署,真机/多设备待验收,见0.39。
- **前端:** 新闻、文章、课堂、课程页接真实分页/详情;处理404/下架,搜索竞态和章节完成状态。
- **后端:** 发布/下架、富文本清洗、分类与首页推荐、搜索和FAQ;稳定章节ID与进度幂等。
- **数据:** 复用fa_acticle/fa_finance;章节数据结构化;用户章节完成关系独立存储;不引入视频和付费课程。
@@ -656,11 +790,11 @@ REPORT-004页面子项完成本地联调:report/report改为评估概况、匹
**目标:** 将真实业务通知、阅读状态与首页红点保持一致。 **阶段:** M3。**剩余:** 2–4人日。
- **现状:** 前端静态通知和本机已读;表含read_at与业务引用,没有用户端业务API。
- **现状:** 当前会员消息/已读API、评估通知与唯一索引已正式部署,页面及首页红点完成;本轮前端需重新发行,见0.38。
- **前端:** 分类分页、未读数、已读/全部已读、与正确评估记录跳转。
- **后端:** 在业务事务完成后可靠生成通知,事件唯一键去重;读取和更新均限定当前用户。
- **后端:** 通知与评估结果/失败状态在同一事务保存,事件唯一键原子去重;读取和更新均限定当前用户及原提交版本。
- **数据:** 复用fa_user_message,补事件唯一键;is_read和read_at同步;全部已读采用最大ID水位。
- **接口:** GET /messages、GET /messages/unread-count、POST /messages/:id/read、POST /messages/read-all。 具体方法和字段见第5章;匹配分页/详情、公共产品详情已实现,公共产品列表仍计划新增。
- **接口:** GET /messages、GET /messages/unread-count、GET /messages/:id、POST /messages/:id/read、POST /messages/read-all。具体字段见5.5;先生产索引、后PHP、最后前端。
- **异常与边界:** 队列重试不重复通知;全部已读期间新增消息仍未读;本期站内消息不等同App推送。
| 任务编号 | 任务与交付范围 | 优先级 | 前置任务/外部条件 | 验收条件 | 人日 |
@@ -674,12 +808,12 @@ REPORT-004页面子项完成本地联调:report/report改为评估概况、匹
**目标:** 积分与真实任务事件一致,邀请可归因且奖励不会重复发放。 **阶段:** 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-002后端基础开发中,五接口源码、默认关闭配置和旧写方保护完成,原表未迁移、未部署;POINT-001受DEC-POINT阻塞,POINT-003/004未实施。页面仍为设备日期本机签到+10及固定体验邀请码,未接真实积分。
- **前端:** 后续接真实余额、流水、服务端签到状态和已发布规则版本;邀请/任务进度按后续POINT-003范围开放,不能把本机记录当服务端到账。
- **后端:** 已实现锁正常会员、锁后上海日期、同日业务键/成功请求重放、余额/流水同事务及固定失败;旧后台score/level编辑和User::score封闭。防自邀与可信邀请奖励尚待开发。
- **数据:** 复用score及已有业务键/事件唯一/时间索引;schema期望两可空元字段和两索引,迁移脚本已编写,原表未迁移;历史流水不补伪造回执、不重建余额,邀请关系另行设计,见[数据库方案](D:/AAdesk/202609/airz/database/points-design.md)。
- **接口:** GET /points、GET /points/rules、GET /points/ledger、POST /points/check-in、GET /points/tasks源码及真实HTTP隔离开发检查完成,原表未迁移/未部署,不能视为线上能力;严格Bearer/JSON/query、签到三字段及有界游标见[积分契约](积分规则与开发契约.md)。邀请读取/绑定仍为后续目标,未实现。
- **异常与边界:** 每日10分仅待确认建议,上海每日一次不补签,资料/邀请关闭,权益/期限/level策略待确认;未发布或未知不当作0分/成功,复制分享不发奖,锁后日期防跨午夜误入账。
| 任务编号 | 任务与交付范围 | 优先级 | 前置任务/外部条件 | 验收条件 | 人日 |
| --- | --- | --- | --- | --- | ---: |
@@ -690,11 +824,11 @@ REPORT-004页面子项完成本地联调:report/report改为评估概况、匹
### 4.13 SUPPORT|反馈与人工客服
**目标:** 帮助页既能提交可追踪反馈,也能真实到达人工服务。 **阶段:** M3。**剩余:** 3–6人日。
**目标:** 帮助页既能提交可追踪反馈,也能真实到达人工服务。 **阶段:** M3。**剩余:** 2–4人日。
- **现状:** FAQ可展开,客服仅未开放提示,反馈表有基础CRUD。
- **前端:** 反馈表单/附件/记录/回复;客服在线/排队/离线状态及失败回退。
- **后端:** 反馈归属与后台处理、服务端访客签名、客服会话关联;外部系统消息遵循渠道身份规则。
- **现状:** FAQ服务端读取,文字反馈提交/本人记录/后台处理源码完成但尚未部署;客服仅未开放提示。
- **前端:** 反馈文字表单、请求键重试、记录/回复;附件入口保留为空数组,客服在线/排队/离线状态及失败回退待供应商。
- **后端:** 反馈归属与后台处理已实现;服务端访客签名、客服会话关联待供应商,外部系统消息遵循渠道身份规则。
- **数据:** 复用fa_feedback补处理结果;不把反馈图片当公共文件;客服历史优先由供应商维护。
- **接口:** POST/GET /feedback、GET /feedback/:id、POST /support/session。 具体方法和字段见第5章;匹配分页/详情、公共产品详情已实现,公共产品列表仍计划新增。
- **异常与边界:** 无坐席能留言且后台有责任人;SDK失败提示可使用站内反馈;退出切换账号清理客服身份。
@@ -710,7 +844,7 @@ REPORT-004页面子项完成本地联调:report/report改为评估概况、匹
**目标:** 让运营人员能管理业务、定位故障并执行受控操作。 **阶段:** M3。**剩余:** 3–5人日。
- **现状:** 通用管理员权限和业务CRUD基础已有;不能据此认为完整运营流程已完成。
- **现状:** ADMIN-001角色模板、字段投影与只读保护、ADMIN-002已有评估排障/受控人工重试/操作审计及ADMIN-003只读服务检查/调用用量/分析进程心跳与进度超时提示/四字段受控运行策略源码完成,尚未部署;完整外部查询、供应商余额/费用对账、外部告警及运营流程仍待能力确认或开发。
- **前端:** FastAdmin后台增加任务、模型额度、查询状态和运营入口,非移动端新增主导航。
- **后端:** 角色授权、敏感字段掩码、任务/查询定位、受控重试和审计;不允许手改已提交评估输入及正式结果。
- **数据:** 复用管理员fa_auth_*;保留被删除会员组/规则表的删除状态;不恢复历史无关业务表。
@@ -833,7 +967,7 @@ REPORT-004页面子项完成本地联调:report/report改为评估概况、匹
| 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 |
| Message | id、type:system/service/activity、title、content、isRead、readAt、createdAt;详情另含target:{kind:report/revision,assessmentId,revisionId,type:personal/enterprise}或null、targetUnavailable |
| PointsLedger | id、delta:int、before:int、after:int、businessType、businessId、memo、createdAt |
| Feedback | id、content、attachmentIds、status、reply、createdAt、handledAt;用户端不暴露无关管理信息 |
@@ -922,22 +1056,25 @@ REPORT-004页面子项完成本地联调:report/report改为评估概况、匹
| 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 | 公开 | topic、page/pageSize | items、total、hasMore | 已实现;图文课程,仅已发布 |
| 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 /content/home | 公开 | 无 | lessons、articles | 已实现;已发布新闻/课程推荐 |
| 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 | 首次绑定、禁止自邀、已有关系不覆盖 |
| GET /messages | 登录 | type=all/system/service/activity、limit默认20最大50;分页beforeId及snapshotId | items、snapshotId、nextBeforeId、unreadCount | 正式已部署;当前用户ID倒序,分页固定上界 |
| GET /messages/unread-count | 登录 | 无 | unreadCount:int | 正式已部署;全分类未读数 |
| GET /messages/:id | 所有者 | 无 | item、target、targetUnavailable | 正式已部署;原revisionId目标,删除关联后提示不可用 |
| POST /messages/:id/read | 所有者 | JSON {} | id、isRead、readAt、unreadCount | 正式已部署;幂等,保留首次readAt |
| POST /messages/read-all | 登录 | snapshotId:string | updatedCount、snapshotId、unreadCount | 正式已部署;全分类仅更新水位以内,新消息仍未读 |
| GET /points | 登录 | 无 | balance、serverDate、timezone、signedToday、checkInEnabled、ruleVersion、checkInReward、balanceState | 源码/隔离HTTP检查完成,原表未迁移/未部署;一致读取,未知不当零 |
| GET /points/rules | 登录 | 无 | status:draft/published/paused、ruleVersion、checkInReward、timezone、publishedRules | 源码已实现未部署;默认draft、版本/奖励/规则为null,未批准不启用 |
| GET /points/ledger | 登录 | limit默认20最大50;后续beforeId+snapshotId | items、snapshotId、nextBeforeId | 源码已实现未部署;本人有界游标,不返回memo;原表需先显式迁移 |
| POST /points/check-in | 登录 | requestId、expectedRuleVersion、expectedDate,恰好三字段 | ledgerId、delta、balanceAfter、signedDate、ruleVersion、replayed | 源码已实现未部署、默认关闭;锁后当日、历史回执不覆盖当前余额 |
| GET /points/tasks | 登录 | 无 | serverDate、timezone、items:{task,status,reward,ruleVersion}[] | 源码已实现未部署;draft签到及资料/邀请均unavailable、奖励为null,不能客户端自报完成 |
| GET /invitations | 登录 | 无 | personalCode、shareUrl、统计摘要 | 后续POINT-003草案,当前无个人真实码 |
| POST /invitations/bind | 登录 | inviteCode、requestId | bindStatus | 后续POINT-003草案;首次有效绑定/禁止自邀,绑定不直接发奖 |
| POST /feedback | 登录 | content、attachmentIds、requestId | Feedback | 非空且当前表内容最长255字符;附件归属;幂等 |
| GET /feedback | 登录 | page/pageSize | Feedback分页 | 只本人 |
| GET /feedback/:id | 所有者 | 无 | Feedback | 处理回复可读 |
+181 -43
View File
@@ -1,6 +1,144 @@
# AI 融资平台开发进度
基线日期:2026-09-22。文档版本:1.30。最新实施见0.36节;此前章节保留阶段记录。技术方案和接口说明见[开发文档](./开发文档.md)。本台账现有63项任务,编号与开发文档保持一致。
基线日期:2026-09-22。文档版本:1.42。最新积分协议与隔离开发检查见0.49节,后端基础和规则设计历史见0.48/0.47节;此前章节保留阶段记录。技术方案和接口说明见[开发文档](./开发文档.md)。本台账现有63项任务、27页面,编号与开发文档保持一致。
### 0.49 2026-10-01 积分协议与隔离开发检查
积分HTTP边界已补:显式Bearer、原始/有效方法一致及覆盖头拒绝、JSON Content-Type和三唯一字符串字段、原始query解码白名单/去重/与PHP解析一致。五条精确路由错误方法统一405,跨域预检沿既有Common入口通过,v1框架错误加no-store。重复JSON键(含Unicode等价)、重复query/NUL/坏编码/空片段均拒绝;不改其它API鉴权。
真实87HTTP请求/1079项检查通过,包含已提交响应丢失后原键确认、跨日/规则更新/暂停、账户停用/删除/Token撤销、本人快照分页、旧NULL/0流水、余额上下限、损坏回执和Redis不可用。使用独立临时ROOT/RUNTIME及随机ENV_PREFIX、新建合成库、Redis DB15随机前缀,不加载正式.env;本轮数据库、Token、临时服务/文件清理完毕,原两表和原队列摘要保持。主服务238、HTTP输入190项纯检查通过;原表23项只读预检通过,4增量、14类质量计数全0。迁移新增历史非NULL时间和流水正UINT主键阻断,首次快照坏ID映射账本异常。
配置仍enabled=false且发布元字段null,业务规则待DEC-POINT确认。POINT-002开发中、POINT-003/004未实施,页面仍本机体验,27页面/63任务保持。未迁移共享原表、未连接正式库、未改默认.env、未调用模型/worker、未部署或开展运营/真机验收。下一子项为积分页面真实读取及原键确认流程,可在规则未发布状态继续开发;实际奖励启用另行确认。
迁移纯110项及独立合成库apply/备份/ACL/恢复89项通过。第一新库2会员4流水、apply4增量后重复为0,完整双表schema+NDJSON恢复至第二新库,逐列摘要一致;目录及3文件Windows ACL/读写probe通过。修复Windows原生绝对备份路径及父级路径识别,补纯路径边界。两自建库、私密INI/备份/probe均精确清理,共享源表结构/全部行摘要一致;安全回执C:/Users/JUN/AppData/Local/Temp/airz-points-migration-20261001/apply-20261001-115749-d50a5cd87fd5a84a/verification.json保留。未在共享原表或生产执行apply。
### 0.48 2026-10-01 历史:积分后端基础(默认关闭、原表未迁移/未部署/未接页面)
用户要求继续下一轮;奖励数额、权益、期限和会员等级建议仍未获业务回复,DEC-POINT/POINT-001待确认。已新增PointsRules/UserPoints、v1 Points五路由及默认关闭配置,未发布元字段全null,异常规则安全拒绝;POINT-002后端基础进入开发中,POINT-003未做、POINT-004页面仍本机体验且未接。27页面、63任务和原任务编号保持,详见[积分契约](积分规则与开发契约.md)。
事务服务先锁正常会员、先成功请求重放、再校验锁后上海业务日/固定日事件/有效规则;同日同键原回执、异键拒绝,只更新score并写完整冻结流水。ID有界游标只本人,历史NULL不伪造,memo不返回;历史balanceAfter不当当前余额。旧后台score/level修改及legacy User::score已封闭,现有level 0/1初始化保持,无管理员任意调整入口。schema.sql/ScoreLog中文元注释增加期望的request_id/rule_version、唯一(user_id,request_id)及idx_user_point_id(user_id,id),原业务索引复用。
显式[积分迁移脚本](D:/AAdesk/202609/airz/database/migrate_points.php)已编写:默认只读预检,apply需显式环境/精确目标、停写和双表完整私密备份;MySQL DDL隐式提交、无自动修复或整表回退。实际默认只读预检在3309/airz_product_test_20260930连续两次通过,4增量、13类质量计数各项0、applied=false,无backup/apply,原两表读前后结构/数据摘要一致;PDO原生事务追踪已修正。apply/完整备份/ACL尚未运行,所有原fa_user/score_log均未迁移。主服务纯237项、迁移纯84项、写保护纯98项、隔离事务45项、只读预检22项及16PHP lint通过。事务只使用同一隔离库随机pts_合成表,真正双PHP同键/异键、锁等待跨业务日、trigger故障回滚通过;原两表结构/数据摘要保持,合成表精确清理。
本轮未生产、未默认.env、未部署、未HTTP/浏览器/Redis/worker/模型,不进行运营或真机验收。源码及合成表检查不能替代原表迁移、页面到账或线上能力。下一步先业务确认、显式隔离真正迁移验证及HTTP开发检查,再接积分页;邀请规则/关系和完整POINT-004继续后续安排,不复用历史生产授权。
### 0.47 2026-10-01 历史:积分规则与开发方案草案
本轮仅完成[积分规则与开发契约](积分规则与开发契约.md)和后端[积分数据库方案](D:/AAdesk/202609/airz/database/points-design.md)的规则建议、接口和结构设计,不实现API、签到到账、迁移/部署,也不执行DB、运行测试或收费模型调用。POINT-001仍阻塞于DEC-POINT,POINT-002/003/004未实施,63任务不变;不以设计记录替代开发检查或业务验收。
首阶段建议每日10分、上海业务日一次不补签,资料/邀请关闭;数额、权益、有效期和level策略仍待确认,配置未发布安全默认停止新入账。复用现有score/流水,不导入体验签到或固定码;business_type/business_id、uk_user_business、idx_user_time已有,首阶段仅拟request_id/rule_version、用户请求唯一及idx_user_point_id(user_id,id)游标索引,成功流水回执不另建签到表。
拟保留POST /points/check-in,正文恰好requestId/expectedRuleVersion/expectedDate,锁后日期校验防跨午夜误发奖;流水拟有界游标,主文档已链接完整契约。旧后台绝对值score写入与User::score吞异常/level联动须实施前封闭或统一账户锁和审计。下一步确认首阶段规则后POINT-002,邀请资格/关系与完整POINT-004后续安排,不复用以前生产授权。
本轮只读方案复核及文档校验完成:8文档UTF-8无BOM/无行尾空白,两新方案10处本地链接目标存在、围栏配对;两仓库限定文档git diff --check通过,只有既有LF/CRLF提示。源码字段/已有索引与方案一致,错误优先级/固定已回滚竞争错误码/ID分页索引已补。未连接DB/Redis/HTTP、未调用模型,不是运行测试或积分到账证据;DEC-POINT未批准,POINT-001仍阻塞,其余未实施。
### 0.46 2026-10-01 模型运行参数与调用额度受控配置开发
ADMIN-003 本轮完成四字段受控运行策略子项源码:暂停新调用、新评估模型Flash/Pro、每日三场景共享次数1–1000和评估尝试1–3;助手/产品解释仍固定3次,环境启用开关仍为硬限制,凭据/地址/timeout/maxTokens不编辑。独立后台GET/POST复用保留 `fa_config.ai_runtime_policy` JSON,无新表/字段。通用配置入口/模型/文件刷新保护该行,三场景在会员锁内共享锁读取一次当前策略快照,无跨操作缓存;旧调用模型冻结,正在curl自然收尾,暂停后未开始任务沿禁用路径失败,已有预留保留。
保存严格白名单、CSRF、预期版本、随机请求键和四种固定原因;当前管理员/权限、CAS比较和成功审计同事务,审计失败回滚,原请求重放返回原回执而不覆盖后续变更。拒绝日志仅安全元数据。新增3节点/独立技术v5模板继承v4,隔离库显式备份应用4项、复检0项;旧规则及绑定保持,不自动换绑,不进行生产迁移。
本轮仍仅本地开发和显式隔离检查,不改正式库、默认 `.env`、线上、真实worker或移动端,不部署或开展验收。ADMIN-003保持开发中,供应商余额/费用对账和外部告警仍缺能力/方案,下一开发范围应按现有依赖选择,不承诺未确认供应商接口。维护见[服务检查与受控配置](D:/AAdesk/202609/airz/database/admin-services.md)。
检查通过:策略纯83/事务24、独立双PHP/双连接并发43、业务路径纯63/隔离DB43、权限97、审计20,原服务53/用量76/重试39;服务HTTP207项(95请求)与Chrome Playwright桌面1280×900/手机390×844页面67项。并发首行实测1213,一方保存一方固定冲突;同键原回执/异意图拒绝、成功审计回滚、三场景快照/旧模型、暂停和配置故障安全关闭均覆盖。页面检验实际提交后响应丢失仍原键重放、另一管理员冲突自动刷新、CSRF/字段白名单/Referer不回显、技术v4/客服/运营拒绝写及超管通用Config保护。坏记录不回退旧指标,额度/剩余数/最大尝试NULL仍保留已预留次数;无未解释console/pageerror、横溢或文字重叠,刻意中断响应的一条网络错误另记。
收尾修正保存确认关闭动画与快速冲突响应的刷新时序;请求字段规范化固定顺序,等价POST乱序重放仍返回原审计/回执、不改后续版本。乱序新增2项纯检查及1项隔离DB检查已计入83/24,6表摘要保持并回滚。
并发14表及HTTP原13表/原Redis队列摘要一致,原Config和site.php摘要保持;held清理3项通过,owned夹具精确清理、凭据和scope私密JSON删除,0模型/worker调用。23PHP lint、2JS语法、26源码/测试文件UTF-8无BOM/空白检查通过,3309/PID19100和8097/PID43340仍仅监听127.0.0.1,无本机analysis:work。证据目录 `C:/Users/JUN/AppData/Local/Temp/airz-admin-runtime-policy-20261001/`有 `verification.json/ui-result.json`、`saved-desktop.png/saved-mobile.png/invalid-mobile.png`;页面脚本依赖已删夹具,不能直接重跑,须显式新建。Browser插件未提供,沿用本机Playwright,无新增依赖;继续先复查监听和bootstrap,不运行默认worker或旧cleanup/reset。
### 0.45 2026-10-01 分析进程心跳与进度超时提示开发
ADMIN-003 本轮完成最近登记常驻分析进程的进度观测子项源码。独立Redis JSON键按数据库配置及分析队列隔离,不新增业务表或字段;随机实例标识本次启动,原子校验instanceId及startedAt,新进程可接替,旧进程的进度更新/停止不能覆盖接替者,首次响应丢失重试也不能无条件抢占。普通进度更新可原子补登记缺失键,停止不复建。`--once`、`--recover-only`不登记。进程记录启动、工作、空闲、降级和停止边界,心跳存储失败不改变任务执行、租约、预算或日志。
后台保持只读权限,显示最后进度、年龄和阈值,区分缺记录/未启用/损坏/Redis不可用/进度超时。阈值为配置值(默认180秒)、模型timeout+60、租约秒数和30秒的最大值;TTL86400秒且大于阈值。进度超时不能证明进程死亡、其他worker停止或供应商失联,退出路径上报停止不证明任务正常完成;外部通知/告警投递尚未接入。
本轮无权限增量/账号换绑/数据库结构迁移,不修改默认 `.env`、正式库、线上或移动端,不部署、不开展运营和真机验收。ADMIN-003 保持开发中,供应商余额/账单对账和受控配置编辑仍待开发;维护见[服务检查说明](D:/AAdesk/202609/airz/database/admin-services.md)。
开发检查通过:心跳纯133项、真实隔离Redis90项、服务纯53项/隔离数据库14项、服务HTTP207项(95请求)与Chrome Playwright桌面1280×900/移动390×844页面43项,9文件PHP lint及JS语法通过。覆盖新owner保护、首写已落地却丢响应12项、丢键补登记、停止不复建、once/recover-only不登记、exit/致命错误上报、Redis不可达及9种后台状态;页面无console/pageerror、文档横溢或文字重叠。0模型调用,原13表及原Redis队列摘要一致。HTTP held夹具精确清理3项通过,临时凭据JSON和浏览器专用scope私密JSON删除,随机观测键及专用8098/PID36740精确清理;原MySQL3309/PID19100与PHP8097/PID43340仍监听127.0.0.1,无真实worker。回执与截图在 `C:/Users/JUN/AppData/Local/Temp/airz-admin-heartbeat-20261001/`;Browser插件未提供,沿用本机Playwright,无新增依赖。临时UI夹具已删除,复跑先建立新的显式隔离夹具,不运行默认环境或旧cleanup/reset。
下一开发项建议推进模型运行参数和调用额度的受控配置;供应商余额/费用对账仍依赖接口能力确认,外部告警投递方案另定,ADMIN-003不标整体完成。
### 0.44 2026-10-01 只读模型与服务检查、调用用量开发
ADMIN-003 进入开发中,首个只读子项源码完成。后台新增固定七项服务检查、任务排队/运行/租约观察,以及按会员/日期/场景/状态查询的安全调用元数据、20条分页和会员今日预留额度。模型只查配置、不请求供应商;队列和登录Redis仅有限只读命令,目录仅权限预检,短信未接入,worker无心跳显示未知。所有写入口包含超管均拒绝。
额度统计匹配真实执行路径,今日全部assistant预留包含失败、软删除、遗留和未来时间记录,与报表日期/场景/状态独立。报表只含已识别场景且未删除记录;未知Token保留NULL并统计未知条数,已知零和大整数用十进制字符串,不估算费用。查询开始及失败清除旧结果,控件禁用防止筛选/翻页错配;不读取正文/报告/匹配/上下文/凭据,不派发、恢复、消费任务或调用模型。
权限新增3节点及独立技术 `v4` 模板;旧技术 `v3/v2/v1`、客服 `v2`、运营 `v1` 和账号绑定保持,无业务表字段迁移。正式库、默认 `.env`、线上与移动端源码未改,未部署或开展运营/真机验收。供应商余额/账单与费用对账、受控配置编辑和心跳告警尚未实现,ADMIN-003不标整体完成;维护见[服务检查说明](D:/AAdesk/202609/airz/database/admin-services.md)。
开发检查通过:服务纯48/隔离数据库13、用量纯76/隔离数据库26、权限定义90;服务HTTP203项(95请求),原权限227项(158请求),Chrome Playwright桌面1280×900/移动390×844页面56项。真实20条分页、大Token字符串、未知与零、历史窗口/场景不改今日额度、加载防重、错误清旧结果及恢复、旧角色拒绝均覆盖。无console/pageerror/文档横溢;Browser插件未提供,沿用本机Playwright,无新增依赖。合成账号/会员/会话/调用/日志精确清理,凭据JSON删除,13表原有行及原Redis队列摘要保持,0模型调用。权限备份、截图和安全回执在 `C:/Users/JUN/AppData/Local/Temp/airz-admin-services-20261001/`;原3309/8097保持、worker未启动。下一开发子项建议分析进程心跳与失联提示,外部告警投递待方案。
### 0.43 2026-10-01 受控人工重试与操作审计开发
ADMIN-002 的人工重试及操作审计源码完成。详情页仅对有权管理员的当前失败任务显示重试,确认固定原因后提交预期版本/任务、CSRF和随机请求键。网络结果未确认时保留同一请求,重放返回原任务,接受后刷新详情及调用列表并可查看操作日志。会员状态、版本、任务、尝试上限和每日额度均在锁定事务中校验;后台与用户重试共享生命周期且请求命名空间隔离。
成功审计与任务/调用预留同事务,审计失败回滚;提交后派发,派发失败保留接受回执以便原键补投。拒绝日志只记录安全白名单和稳定错误,不保存完整POST、CSRF、身份资料、报告或模型正文。旧版本输入、已发布结果和匹配保持冻结。技术新增独立 `v3` 模板,旧技术 `v2`、客服 `v2`、运营 `v1` 和账号绑定保持;隔离库备份应用1节点/1模板共2项,重复预检0项,无业务表字段变化。
本轮重试服务纯39项/事务45项、审计90项、权限定义82项及诊断纯48项/事务47项通过;重试HTTP/数据库1016项(91请求)、原排障回归1075项(200请求)和原权限227项(158请求)通过。覆盖两个独立PHP进程同键/异键竞争、审计插入失败完整回滚及拒绝日志故障、模型关闭、次数/日额度、队列不可达后原键补投、参数和隐私保护。Redis投递允许同任务重复消息,执行幂等依赖任务/租约,不承诺队列exactly-once。
Chrome Playwright 1280×900/390×844页面38项通过,核对确认、状态冲突刷新、失联后取消/页面内刷新仍可确认原请求、CSRF更新、原任务/审计重放、状态/列表刷新及旧角色按钮隐藏。无新增未解释console/pageerror;刻意丢弃一次接受响应是预期网络故障。13表原有记录及其他Redis队列摘要一致,合成账号/评估/日志/触发器/随机队列和凭据JSON已精确清理,profile恢复,0次模型调用。安全回执及截图在 `C:/Users/JUN/AppData/Local/Temp/airz-admin-retry-20261001/`,专用8098已停止,原隔离3309/8097保留。Browser插件未提供,沿用本机Playwright,未安装依赖。
正式库、默认 `.env`、线上与移动端源码未改,未部署或开展运营/真机验收。维护见[评估排障说明](D:/AAdesk/202609/airz/database/admin-assessment.md)。ADMIN-002 保持开发中,完整外部查询/对账仍依赖 DATA-004;下一独立开发项可做 ADMIN-003 服务状态检查。
### 0.42 2026-10-01 已有评估任务只读排障开发
ADMIN-002 进入开发中,已有评估任务的只读子项完成。后台按评估 ID、任务 ID、请求 ID、会员 ID 查询阶段/状态/派发/租约/安全错误,详情分别分页展示提交版本与调用尝试。当前任务与历史版本区分;历史零调用阻断任务仍可检索,遗留无版本关联调用明确标注,产品解释调用不计入评估尝试。Token/耗时的未知与零区分,迟到用量更新时间不作为任务完成时间。
服务只投影元数据,不加载表单、报告、匹配、上下文或供应商正文;缺版本、错关联、不支持格式、缺任务和运行租约给稳定提示。超管和普通业务角色均不能通过此控制器写入、重试、恢复或派发,查询不触发模型。客服/技术新增独立 `v2` 模板,旧 `v1` 及管理员绑定保持;运营仍使用 `v1`。隔离库只增3节点/2模板,显式备份应用5项后预检0项,菜单缓存精确刷新,无业务表字段变化。
48项纯函数、42项隔离事务、1053项HTTP/数据库开发检查通过(194次请求),新增边界包括超管写入拒绝、历史零调用、旧日志、坏版本、错会话关联与缺失任务/租约。原后台权限回归另227项通过(158次请求),权限定义75项、隐私37项和字段投影73项通过。1280×900及390×844页面27项检查通过:查询/清空、详情弹窗、版本切换、独立分页、错误/空态及尺寸正常,无新增console/pageerror。截图和临时脚本在 `C:/Users/JUN/AppData/Local/Temp/airz-admin-assessment-20261001/`,全部合成账号和任务已精确清理,原有记录一致,0次模型调用。
本轮未部署、未改正式库/默认 `.env`/移动端代码,未做运营或真机验收。详细接口与维护见[排障说明](D:/AAdesk/202609/airz/database/admin-assessment.md)。下一开发子项为受控人工重试及操作审计;完整外部查询/对账仍依赖 DATA-004,ADMIN-002 不标整体完成。
### 0.41 2026-10-01 后台菜单与角色权限开发
ADMIN-001 源码完成,暂不做运营、真机或发布验收。复用现有 `fa_auth_rule/fa_auth_group/fa_auth_group_access`,新增运营、客服、技术三个独立最小权限模板。运营管理产品/文章/课堂/FAQ;客服只读会员/资料/消息、处理反馈及查看融资资质字段;技术只读会员/资料/消息和模型配置、查看/受控启停密钥、查看自身及下属管理员日志。全员保留本人配置与改密能力,不授全局统计、权限管理、硬删除、导入或附件管理,不恢复会员组/会员规则。
资料和系统消息的后台写入口全部关闭,包含超级管理员、批量、导入和回收站;资料按 `user/info/financial` 投影,姓名/手机/企业名称/代码保持掩码,证件原文和附件永不输出。会员/密钥/模型列表限制字段、过滤及排序,模型选择器只返回 ID/名称;密钥批量只能修改合法启停状态,不能修改所属模型、密文或消耗。反馈列表/编辑不显示历史附件;操作日志新写和历史读取均脱敏,请求日志不保留 URL 查询串。通用缓存清理和排序改为校验管理员权限,角色首屏只选择有权业务页。
新增独立 `database/migrate_admin_permissions.php` 和[权限维护说明](D:/AAdesk/202609/airz/database/admin-permissions.md):显式环境/目标、默认只读,应用前备份并锁定三权限表,只补缺失节点/模板,不覆盖既有节点/角色/账号绑定,模板人工漂移或不兼容层级阻断。隔离库 `127.0.0.1:3309/airz_product_test_20260930` 已增加50节点和3模板,原管理员未自动绑定;备份位于受限临时 `airz-admin-permissions-20261001` 目录。初始化后精确刷新隔离 `__menu__` 缓存,正式发布须与受控控制器及FAQ/反馈业务配套部署。
PHP/JS语法、权限/隐私纯内存回归及227项真实隔离HTTP/数据库检查通过(158次请求);合成会员/资料/消息/反馈/模型/密钥/管理员/绑定/日志精确清理,13张相关表原有行保持,0次模型调用。正式数据库、`.env`、线上部署和移动端源码均未修改。开发检查不代表运营或生产验收,任务保持开发中;下一步 ADMIN-002 可先开发已有评估任务的只读排障,完整外部查询仍依赖 DATA-004。
### 0.39 2026-10-01 FAQ服务端化开发
继续功能开发,暂不做真机或跨设备验收。FAQ改为复用`fa_acticle`的`type=6`和`content` JSON,结构为`schemaVersion=1/kind=faq`,新增公开`GET /api/v1/help/faqs`,按分类返回已发布且结构有效的问题;损坏、草稿和下架内容不公开。没有新增表或字段。
后台新增独立FAQ编辑入口,沿用现有管理员权限、发布状态和版本冲突保护;FAQ初始内容使用显式目标的`database/import_faq.php`和`database/faq_initial.json`导入,预检默认不写入,不能连接默认`.env`。前端帮助页移除本地FAQ数组,始终读取服务端并保留空态/错误重试。
本轮仅完成源码、种子脚本和隔离事务回归,未导入正式库、未部署PHP/后台资源、未修改正式数据。FAQ之后继续开发反馈提交/处理闭环;人工客服仍受`EXT-CS`供应商选择阻塞。原有资讯、课堂、搜索、课程进度、消息和产品部署状态不变。
### 0.40 2026-10-01 反馈提交与处理闭环开发
继续功能开发,暂不做真机、跨设备和正式发布验收。反馈复用`fa_feedback`并新增`request_id`、`reply`、`handled_by`、`handled_at`及`(user_id,request_id)`唯一索引;显式迁移脚本`database/migrate_feedback.php`默认只预检,执行要求隔离/正式目标、`--apply`和备份目录,历史NULL请求键兼容。本轮仅对本机隔离库应用,未修改正式库。
后端新增`FeedbackRecords`、`Feedback`控制器和三条登录接口:文字内容去HTML并限制255字,附件暂只接受空数组;同会员同请求键幂等返回原记录,参数冲突409;列表分页和详情严格按当前会员归属。后台隐藏新增、删除、批量和导入,只允许白名单修改状态与回复,自动记录管理员和处理时间。
帮助页新增意见反馈表单、失败重试复用请求键、本人记录分页和回复展示,登录后可返回反馈页;FAQ保持原服务端读取,客服按钮继续显示未开放。PHP语法、隔离迁移预检/应用和58项反馈数据库回归通过,正式迁移、部署、真机及验收未执行。
### 0.38 2026-10-01 资讯、课堂、搜索与正式部署
新闻/课堂列表、详情、首页推荐与搜索已从静态数组切换真实接口,支持分类、分页/总数、空态/错误重试及迟到响应保护。保留原型布局,关键约束中文注释。后台复用文章和融资课堂,JSON结构化编辑、发布/下架、封面、稳定章节ID/排序、保存版本冲突及旧协议正文兼容完成。CONTENT-002/003待验收;CONTENT-001/004包含未完成的FAQ,保持开发中。
复用fa_acticle.type=5及fa_finance.content TEXT,无现有表新增字段;新建fa_user_lesson_progress关系表,按会员保存固定章节ID/正文摘要/首次完成,重排保留进度、正文更新失效,lessonVersion冲突409、重复完成幂等。正式消息唯一索引一并迁移。4篇新闻/3门课程按slug只新增,重复预检0改动;6条产品、现有会员/助手/报告及输入版本存储保持。
40项真实MySQL5.7隔离数据库、81项内容HTTP/Playwright(42次隔离请求)、13项后台UI/HTTP和166项产品编辑纯内存回归通过。390×844与1280×900无运行错误,微信/Android开发编译通过;UI合成内容/用户/Token/进度精确清理,原行摘要一致,0次收费调用。证据在C:/Users/JUN/AppData/Local/Temp/airz-content-20261001/的qa-ui.json、qa-admin.json、cleanup.json和截图。
用户授权后,经宝塔新增airz.hqling.cloud绑定、部署Let's Encrypt证书并移除旧aitest.ychysg.cn;内部站点xctai.cn与目录不变。停写/停空闲worker并备份,正式消息索引/课程进度表增量迁移、7条内容导入、34源码/后台资源限定部署完成,含此前产品编辑/公共目录与消息功能。64项服务器HTTPS/FPM及假模型验收通过、2次显式假评估、0收费调用;26表预期摘要、原有行、无关源码/.env/全局参数保持,夹具和独立队列清理。
worker和公网入口已恢复,三服务active、队列[0,0,0]、MainPID=1041115/NRestarts=0(后续重查)。公网11条路径及HTTP301检查通过:内容/字典200、匿名消息/产品401、敏感路径404、TLS校验0。证书到期2026-12-29 16:12:27 UTC。服务器/root/airz-content-20261001和受限本机D:/AAdesk/202609/airz-backups/content-deploy-20261001保留22文件备份/验收,归档包与全部文件SHA256一致。恢复见[内容维护说明](D:/AAdesk/202609/airz/database/content-publication.md),不可整库回退或删有进度的关系表。
前端test API已改为https://airz.hqling.cloud/api/v1;小程序需重新发行,并把微信request合法域名改为https://airz.hqling.cloud,上传/下载按实际使用配置相应合法域名。本轮未代上传小程序、未部署H5,真机/多设备/真实模型未验收。旧域名阻塞和消息未部署段落仅为历史阶段;本机测试仍须显式隔离bootstrap,禁止默认.env或本机worker连接活动库。
### 0.37 2026-09-30 消息中心与评估事件
MSG-001—004完成本机实施,状态待验收:复用消息页原分类/图标/底栏,移除静态通知与本机已读缓存;接入服务端分类分页、真实未读总数、单条/全部已读、详情及原版本报告/档案跳转。首页红点来自真实未读数,登录后可返回消息页;离页、换分类及账户切换拒绝旧请求。分页失败保留已加载列表并在末端显示重试,滚动高度按顶栏/分类/底栏扣除,避免导航遮挡。Android局部函数前向引用使用延后绑定回调兼容,关键约束中文注释。
后端UserMessages/Messages与五个路由完成;评估成功/失败通知同结果事务保存,唯一键原子去重。business_id指system输入日志revisionId,修改重评产生新版本通知,失败重试不重复;详情校验当前用户及关联会话/输入日志删除状态。列表固定snapshotId上界和beforeId游标,全部已读只更新上界以内,新增通知保持未读,单条首次readAt保持。fa_user_message只增加uk_user_message_event唯一索引,无新表/字段、不回填历史通知。
50项MySQL5.7.26真实隔离数据库、57项HTTP/Playwright(42次隔离API)及16项索引迁移/恢复检查通过。手机390×844、桌面1280×900,console/pageerror为0;覆盖成功/失败版本、重试去重、权限、分页/错误恢复、已读与红点、新通知及账户切换迟到响应,含两个独立PHP连接并发事件插入。微信/Android本地编译通过,H5热更新及页面联调通过;模型全部使用假客户端,0次收费调用。测试账户/Token/资料/会话/消息和本批随机队列已精确清理,25表原有行摘要一致,原4条草稿和6条产品保留。
用户已上传上一版小程序;**本轮消息索引迁移、PHP部署及前端发行尚未执行**。公网HTTPS排查按用户要求暂停,未复测公网、真机或真实模型。迁移默认只读、显式配置/目标,执行需维护标识和备份,阻断重复组、活动任务及错误同名索引;只恢复代码/索引,不整表覆盖新消息。说明见后端[消息维护与恢复](D:/AAdesk/202609/airz/database/user-messages.md),临时证据在airz-messages-20260930目录。63任务、27页面不变,站内消息不含外部推送或运营群发。
### 0.36 2026-09-30 产品业务展示与正式资料同步
@@ -381,10 +519,10 @@ P0 表示核心流程、安全、首发外部能力或发布门槛;P1 表示
| PRODUCT 产品匹配 | 03/04/05/06已接正式快照及公共详情;本地产品为演示资料 | 范围/规则/版本及快照已有 | 发布预检、规则/冻结/排序/持久化已实现 | 匹配分页/详情、公共详情已接;公共列表待开发 | 真实产品及金标准待确认 | 本地接口和H5验证通过;整模块未验收 |
| REPORT AI 报告 | 已接真实自填报告,移除固定评分/雷达 | 报告快照、调用用量及任务元数据已接入 | Redis任务、补投/租约、重试已实现 | 自填报告支线已联调 | 指定Pro/Flash均真实可用 | 完整产品/查询范围未完成 |
| CHAT AI 助手 | 模拟对话 | 会话/消息/使用量字段已有 | 调用、上下文、配额待开发 | 待开发 | 依赖 DeepSeek | 未完成 |
| CONTENT 内容课堂 | 新闻/课程/搜索/FAQ 本地数据,学习进度本机 | 文章/融资课堂表已有,课程进度待增量设计 | 内容后台基础需补齐 | 真实列表/详情/进度/搜索待开发 | 内容运营素材待准备 | 未完成 |
| MSG 消息中心 | 静态消息及本机已读 | read_at/关联业务字段已有 | 通知事件/去重需补齐 | 列表/计数/已读待开发 | 无独立外部渠道要求 | 未完成 |
| POINT 积分邀请 | 体验签到、本机积分/任务 | 积分流水及业务幂等基础已有,邀请关系待设计 | 可信奖励事件和并发账本待开发 | 待开发 | 奖励业务规则待定 | 未完成 |
| SUPPORT 客服反馈 | 帮助页与 FAQ;客服暂未开放 | feedback 基础表已有 | 反馈闭环、客服签名待开发 | 待开发 | 人工客服待定 | 未完成 |
| CONTENT 内容课堂 | 新闻/课程/搜索/首页真实接口,云端进度;FAQ服务端读取 | 原内容表JSON及课程进度关系表正式上线 | 发布/下架/章节编辑/FAQ维护完成 | 隔离/服务器验收通过,真机/多设备待验收 | 原4新闻/3课程按slug导入;FAQ源码未部署 | 开发中,见0.39 |
| MSG 消息中心 | 服务端消息、分类分页、已读及首页红点 | read_at/关联业务字段及唯一事件索引已有 | 通知事件/去重已实现 | 本机/服务器检查通过,真机/多设备待验收 | 无独立外部渠道要求 | 开发中,见0.37 |
| POINT 积分邀请 | 本机体验签到,未接真实后端 | 既有业务索引复用;schema期望两字段/两索引,新合成库迁移/恢复通过,原表未迁移 | 五接口/事务/严格HTTP/默认关闭和旧写保护源码完成未部署 | 主服务238/HTTP输入190/迁移110纯检查、真实HTTP1079项87请求、合成apply/恢复89及只读预检23通过;页面未接 | DEC-POINT待确认,见0.49和[积分契约](积分规则与开发契约.md) | POINT-001阻塞,POINT-002开发中,003/004未实施 |
| SUPPORT 客服反馈 | 帮助页FAQ、文字反馈、本人记录;客服暂未开放 | feedback处理字段、请求键唯一索引已在隔离库应用 | 反馈闭环及后台白名单处理完成;客服签名待开发 | POST/GET反馈及详情已实现;附件服务未开放 | 人工客服待定 | 反馈开发完成,见0.40;客服未完成 |
| ADMIN 后台运营 | FastAdmin 管理界面已有 | 管理员权限保持,会员组保持删除 | 档案/产品/密钥表单已适配,运营排障待补齐 | 面向管理员的新增能力待开发 | 依赖各渠道状态 | 基础界面修改不等于运营验收 |
| RELEASE 发布验收 | Web/Android 曾完成本地编译 | 本机迁移已有,其他环境须单独设计升级 | 未建立完整首发部署/恢复链路 | 无全量真实联调证据 | 域名/HTTPS/队列部署等待定 | H5 首发、真机和多端发布均未完成 |
@@ -409,7 +547,7 @@ P0 表示核心流程、安全、首发外部能力或发布门槛;P1 表示
| 页面 | 注册路径 | 主要任务 | 当前状态 |
| --- | --- | --- | --- |
| 首页 | pages/home/home | BASE-004、CONTENT-002、MSG-003 | 原型/本地数据 |
| 首页 | 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 | 元/万元转换、正式提交及网络重试幂等 |
@@ -429,13 +567,13 @@ P0 表示核心流程、安全、首发外部能力或发布门槛;P1 表示
| 修改密码 | 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/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 | 本机学习进度 |
| 新闻列表 | 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. 里程碑与依赖顺序
@@ -446,7 +584,7 @@ P0 表示核心流程、安全、首发外部能力或发布门槛;P1 表示
| 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 | 助手、内容、课堂、搜索、消息、积分邀请、反馈和人工客服及运营后台验收通过 | 未开始;现有页面/部分本机功能为基础,真实业务按对应任务推进 | 待记录 / 待记录 |
| M3 全部页面业务化 | CHAT、CONTENT、MSG、POINT、SUPPORT、ADMIN | 20–35 | 62–106 | 助手、内容、课堂、搜索、消息、积分邀请、反馈和人工客服及运营后台验收通过 | 开发中;助手/内容/消息后端已部署,FAQ/积分/反馈客服及业务验收待完成 | 待记录 / 待记录 |
| M4 H5 验收上线 | RELEASE | 7–11 | 69–117 | 全部一期任务验收;普通 H5/微信内 H5 与渠道支持矩阵通过;部署恢复演练和发布记录完整 | 未开始,部署资源待定 | 待记录 / 待记录 |
后续多端里程碑不计入上述 69–117 人日,也不属于一期 H5 验收:微信小程序需平台 AppID、隐私配置、授权/支付无关能力复核、包体与真机测试和提审;Android 需签名、真实设备与网络/权限适配、渠道材料;iOS 需 macOS/Xcode、签名、真实设备、隐私材料和审核。具体范围、顺序与工时在 H5 稳定后另行确认,不能拿历史本地编译代替发布。
@@ -647,17 +785,17 @@ DEC-DEMAND已确认并发布,需求录入/提交/历史子范围已实现;
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| 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类型及协议入口。 | 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 |
| CONTENT-001 | 全栈开发(待指定) | 后台可发布/下架;章节重排不换ID;草稿不可公开;FAQ可维护。 | 40项数据库/13项后台检查、服务器验收通过;FAQ服务端与后台源码完成,尚未导入/部署,见0.39 | 2026-10-01 |
| CONTENT-002 | 全栈开发(待指定) | 首页/列表/详情ID一致;无模拟兜底内容;下架详情有明确不可用状态。 | 81项内容HTTP/页面及公网接口通过,微信/Android编译通过;真机待验收 | 2026-10-01 |
| CONTENT-003 | 全栈开发(待指定) | 用户隔离;重复完成幂等;章节排序变化不串进度;跨设备一致。 | 版本冲突/重排/正文变化及会员权限检查通过;多设备待验收 | 2026-10-01 |
| CONTENT-004 | 全栈开发(待指定) | 旧搜索响应不覆盖新关键词;返回ID可开详情;FAQ与实际业务行为一致。 | 分类/多词/分页/竞态/空错态通过,搜索正式部署;FAQ服务端与前端接入源码完成,尚未导入/部署 | 2026-10-01 |
直接外部/业务阻塞:无;仍须满足前置任务。
@@ -667,17 +805,17 @@ DEC-DEMAND已确认并发布,需求录入/提交/历史子范围已实现;
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| 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 | 通知事件与去重 | 定义评估结果等事件、通知去重键及写入时机,补齐唯一性保障。 | 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 |
| MSG-001 | 全栈开发(待指定) | 重复任务不会重复通知;成功/失败通知关联正确assessmentId及revisionId。 | 同事务/并发去重及旧版本通过,正式索引/PHP已部署,见0.38 | 2026-10-01 |
| MSG-002 | 全栈开发(待指定) | 跨用户读写拒绝;已读重复操作幂等;列表/计数一致。 | 50项数据库/HTTP及正式FPM检查通过;前端待发行 | 2026-10-01 |
| MSG-003 | 全栈开发(待指定) | 换设备状态一致;打开通知到对应历史;关联记录不可用有提示。 | 57项手机/桌面和微信/Android编译通过;真实多设备/真机待验收 | 2026-10-01 |
| MSG-004 | 全栈开发(待指定) | 全部已读不误吞操作后新增消息;通知去重和未读数通过数据库核对。 | 水位/并发/索引恢复及正式维护通过,原数据保持,前端待发行 | 2026-10-01 |
直接外部/业务阻塞:无;仍须满足前置任务。
@@ -688,18 +826,18 @@ DEC-DEMAND已确认并发布,需求录入/提交/历史子范围已实现;
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| POINT-001 | 积分规则定稿 | 确认签到、资料完成、邀请奖励的条件/次数/额度、业务时区和权益范围。 | P1 | BASE-001、DEC-POINT | 0.5–1 | 阻塞 |
| POINT-002 | 积分账户与签到 | 事务更新score与流水、业务唯一键、服务端日期签到、余额/流水查询。 | P1 | POINT-001、AUTH-002 | 1.5–2 | 未开始 |
| 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 | 全栈开发(待指定) | 形成版本化活动规则;复制/分享不等于奖励;未确认奖励规则不得上线。 | 规则建议见0.47、基础实现见0.48;数额/权益/期限/等级仍待DEC-POINT确认,不启用发布配置 | 2026-10-01 |
| POINT-002 | 全栈开发(待指定) | 同用户同日并发只加一次;流水与积分一致;错误不静默吞掉。 | 0.49五接口/事务/严格HTTP/默认关闭及旧写保护完成;纯服务238/输入190/迁移110、HTTP1079项87请求、新合成库apply/恢复89、原表只读预检23通过;原表未迁移、页面未接/未部署 | 2026-10-01 |
| POINT-003 | 全栈开发(待指定) | 客户端不能自报完成奖励;重复/自邀拒绝;已有关系不随分享覆盖。 | 邀请资格/奖励规则与关系方案后续确认;未实施,无真实邀请码/奖励 | 2026-10-01 |
| POINT-004 | 全栈开发(待指定) | 真实到账后才提示成功;跨账户/跨设备一致;邀请复制不立即发奖励。 | 页面实施边界已列设计,当前仍体验签到,未联调真实积分API或到账 | 2026-10-01 |
直接阻塞:POINT-001 → DEC-POINT。
直接阻塞:POINT-001 → DEC-POINT;POINT-002推进默认关闭的实现与隔离检查,HTTP后端检查已完成,规则发布/真实入账仍须POINT-001定稿及原表迁移、页面开发检查。
### SUPPORT|反馈与人工客服
@@ -708,7 +846,7 @@ DEC-DEMAND已确认并发布,需求录入/提交/历史子范围已实现;
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| SUPPORT-001 | 客服方案与运营准备 | 选定H5客服、身份签名、坐席/工作时间、离线策略、反馈状态及责任人。 | P0 | BASE-001、EXT-CS | 0.5–1 | 阻塞 |
| SUPPORT-002 | 反馈提交与处理闭环 | 帮助页增加反馈表单/记录;附件权限、后台状态/回复、用户查看处理结果。 | P1 | AUTH-002、FILE-002 | 1–2 | 未开始 |
| 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 | 未开始 |
@@ -727,16 +865,16 @@ DEC-DEMAND已确认并发布,需求录入/提交/历史子范围已实现;
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| 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-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-001 | 全栈开发(待指定) | 管理员权限最小化;无需恢复已删除会员组/规则;敏感字段继续掩码。 | 源码及隔离权限开发检查完成,见0.41;未部署,未做运营验收 | 2026-10-01 |
| ADMIN-002 | 全栈开发(待指定) | 可定位失败原因;重试仍走生命周期和幂等;不允许后台手改正式结果。 | 已有评估排障、受控人工重试及审计源码完成,见0.43;完整外部查询/对账待开发,未部署/验收 | 2026-10-01 |
| ADMIN-003 | 全栈开发(待指定) | 密钥不回显;配置失败可定位;本地消耗与渠道对账有入口。 | 只读服务/用量/今日额度、进程心跳及四字段受控运行策略源码完成,见0.46;供应商对账/外部告警待能力确认,未部署/验收 | 2026-10-01 |
| ADMIN-004 | 全栈开发(待指定) | 运营可独立发布/下架/处理反馈及定位异常;业务字段无占位数据。 | 未验收;无真实业务验收证据 | 2026-09-22 |
直接外部/业务阻塞:无;仍须满足前置任务。
@@ -761,7 +899,7 @@ DEC-DEMAND已确认并发布,需求录入/提交/历史子范围已实现;
直接阻塞:RELEASE-003 → EXT-DEPLOY。
任务状态计数(仅用于核对台账):未开始 34 项;开发中 11 项;待联调 0 项;待验收 1 项;已完成 0 项;阻塞 14 项。这些计数不换算为项目完成率。
任务状态计数(仅用于核对台账):未开始 33 项;开发中 12 项;待联调 0 项;待验收 1 项;已完成 0 项;阻塞 14 项。这些计数不换算为项目完成率。
## 5. 外部依赖、业务决策与阻塞传播
+165 -1
View File
@@ -1,6 +1,170 @@
# AI融资项目新会话交接
记录日期:2026-09-30。本文件为接续入口;当前存储与流程以[评估版本与最新资料](评估版本与最新资料.md)、[开发进度](开发进度.md)0.36节及[开发文档](开发文档.md)v1.30为准。历史章节中的“待开发”应结合最新更新阅读。
记录日期:2026-10-01。本文件为接续入口;当前存储与流程以[评估版本与最新资料](评估版本与最新资料.md)、[开发进度](开发进度.md)0.49节(积分协议与隔离检查)及[开发文档](开发文档.md)v1.42为准,0.48/0.47保留基础和规则设计历史。历史章节中的域名、部署及“待开发”以最新记录为准。
## 本轮开发:2026-10-01 积分协议与隔离检查
用户继续开发,仍暂不做运营、真机或发布验收。积分五接口协议已补显式Bearer、原始HTTP方法和覆盖头拒绝、JSON Content-Type/三唯一字符串字段、原始query白名单/重复与编码拒绝;五个精确路径错误方法405,未知路径404,v1框架错误补no-store。Common现有CORS入口已在鉴权前处理允许Origin的匿名OPTIONS,本轮未改共享CORS或其它API鉴权。
真实HTTP87请求/1079项检查通过:草案/发布/暂停/坏规则、同键历史重放、跨日/规则变更、实际丢弃已提交成功响应后原键确认、停用/删除/撤销、Cookie/旧Token头拒绝、原始query和JSON重复键、本人快照分页/隐私/旧NULL/0、余额边界、坏回执和Redis不可用。测试用新合成数据库、Redis DB15随机Token前缀及临时ROOT/RUNTIME、随机ENV_PREFIX;不加载仓库正式.env。自建数据库、Token、服务和临时文件已删除,原两表结构/数据及原Redis队列摘要保持,0模型/worker调用。
主服务238项、HTTP输入190项纯检查通过,原表23项只读预检通过(4增量/14类质量计数全0)。迁移质量检查新增非NULL历史时间(0至253402271999上海四位年份范围)及正UINT流水主键,NULL历史时间继续保留;首次快照坏ID映射POINT_LEDGER_INVALID,不误报客户端422。
迁移纯110项、独立合成apply/双表完整备份/Windows ACL/恢复89项通过。只复制共享两表结构,在第一新库合成2会员4流水,apply4增量/重复apply0;完整schema+NDJSON恢复第二新库、逐列摘要一致,目录3文件仅当前用户ACL及读写probe验证通过。演练修复Windows原生绝对路径及父级路径校验,补纯validateBackupPath。两个自建库、私密INI/备份/probe按所有权精确清理,共享原表摘要保持;安全回执C:/Users/JUN/AppData/Local/Temp/airz-points-migration-20261001/apply-20261001-115749-d50a5cd87fd5a84a/verification.json保留,不能把合成恢复当生产迁移证据。
业务规则仍待DEC-POINT确认,真实配置enabled=false、版本/奖励/权益/期限/等级策略null。POINT-002开发中,POINT-003未做,POINT-004积分页面尚未接后端,27页面/63任务保持。下一子项可实施积分页真实余额/规则/流水读取、登录恢复和签到原键确认;未发布状态不写死10分或固定邀请码,不导入本机体验记录。原表迁移、实际奖励发布和部署另行安排,不复用历史生产授权。
新HTTP开发检查入口为后端tests/points_http.php --env=<明确本机3309私密环境>,附points_http_bootstrap.php/router.php;脚本自行建立和清理独立合成库/临时服务,不复用旧bootstrap初始化应用。3309/PID19100、8097/PID43340仍仅监听127.0.0.1;继续先核对监听/配置,不默认运行worker、旧cleanup/reset或读取正式.env。
## 历史开发:2026-10-01 积分后端基础(默认关闭、原表未迁移/未部署/未接页面)
用户继续下一轮,数额/权益/期限/等级的首阶段建议尚未获业务回复,DEC-POINT/POINT-001待确认。已新增PointsRules/UserPoints、v1 Points五路由(余额/规则/流水/签到/任务),默认enabled=false且规则版本、奖励、权益/期限/等级策略为null。POINT-002后端基础开发中,POINT-003未做,POINT-004页面仍本机体验签到+10及固定码,未接后端;27页面/63任务保持。完整接口与规则状态见[积分契约](积分规则与开发契约.md),结构/迁移说明见后端 `D:/AAdesk/202609/airz/database/points-design.md`。
入账先锁正常会员、优先原成功请求回执,再校验锁后上海日期、固定当日事件及发布规则;同日同键重放、异键拒绝,余额/一条完整流水同事务,历史balanceAfter不覆盖当前余额。仅1062/1205/1213且确定回滚映射写冲突,未知提交/回滚为账本不可用。流水ID有界游标只本人,NULL历史元字段保留,不下发memo。旧后台score/level修改与旧User::score封闭,现有level 0/1初始化保持;资料/邀请任务不可用,不给普通运营发奖入口。
schema.sql期望增加request_id CHAR(36)、rule_version VARCHAR(64),ASCII ascii_bin可空,唯一(user_id,request_id)及idx_user_point_id(user_id,id),ScoreLog中文元注释已补。迁移脚本 `database/migrate_points.php` 默认只读、显式环境/精确目标,apply需停写确认及双表完整私密备份,MySQL DDL隐式提交、无自动恢复。本轮只读预检已在3309/airz_product_test_20260930通过:4项增量、质量计数各项0、applied=false;修正PHP7.4的原生事务追踪,采用PDO::beginTransaction配合RR READ ONLY。未实际迁移任何原fa_user/score_log,apply、完整备份和Windows ACL尚未运行。
本轮开发检查通过:主服务纯237项、旧写保护纯98项、迁移纯84项、隔离事务45项、独立只读预检22项及16PHP lint。事务只使用127.0.0.1:3309隔离库随机pts_合成表,真正双PHP同键/异键、锁等待跨业务日和trigger故障回滚覆盖;原fa_user/score_log结构与数据摘要保持,合成表精确清理且余留0。只读预检连续两次、精确目标、13类安全计数、无backup/apply及原两表结构/数据摘要一致通过。未正式库、未默认.env、未部署、未HTTP/浏览器/Redis/worker/模型,不开展运营或真机验收;后端源码/合成表检查不等于页面已到账。
下一步先确认业务规则,再显式隔离迁移apply/备份/重复预检验证、HTTP开发检查及前端真实读取/原键确认联调;邀请资格/关系留POINT-003,完整POINT-004后续推进。3309/8097仍仅监听127.0.0.1、原PID保持;继续先核对隔离监听/bootstrap,不默认初始化应用、运行worker、旧cleanup/reset或重用历史生产授权,默认.env仍指正式库。
## 历史设计:2026-10-01 积分规则与开发契约(0.47)
本轮仅整理规则/接口/数据库设计草案,新增[积分规则与开发契约](积分规则与开发契约.md)及后端 `D:/AAdesk/202609/airz/database/points-design.md`;未实现任何积分API、签到到账、迁移脚本或部署,未连接DB、执行运行测试或收费模型。POINT-001仍受DEC-POINT待确认阻塞,POINT-002/003/004未实施,27页面/63任务保持;不把本轮文档校验称为开发检查通过。
首阶段建议每日10分、服务端上海业务日每日一次且不补签,资料/邀请关闭,积分不导入本机体验记录。奖励数额、权益、有效期和会员level策略待确认;未发布规则必须停止新入账且奖励null,不使用页面示例兜底。现有score_log已具business_type/business_id、uk_user_business、idx_user_time,首阶段仅拟新增request_id/rule_version两个可空字段、用户请求唯一及idx_user_point_id(user_id,id)游标索引,成功流水保存冻结回执,不重复增加已有业务列/索引或另建签到表;本轮只读仓库结构,目标库须实施前显式预检。
拟接口保留POST /points/check-in,严格三字段requestId/expectedRuleVersion/expectedDate,expectedDate为服务端日期预期、不是补签,取得用户锁后按当日验证,防锁等待跨午夜误发奖。流水目标改为snapshotId/beforeId有界游标,当前无已发布积分API;旧成功请求跨日/暂停可返回原回执,随后重新读取余额,不能用历史balanceAfter覆盖当前值。后台绝对值score编辑、User::score吞异常及level联动是实施前门禁;邀请关系/资格/奖励另属POINT-003。
下一步先确认首阶段规则,再实施POINT-002的写方保护、明确配置、字段增量、余额/流水/签到服务及相应页面;完整邀请和POINT-004后续推进。本轮正式库、默认.env、线上、源码/测试/schema保持,不复用此前生产迁移或部署授权。继续前仍先核对隔离监听/bootstrap,不默认运行worker或旧cleanup/reset。
本轮仅文档校验:8目标文档UTF-8无BOM/无行尾空白,两新方案10处本地链接目标存在、围栏配对,两仓库限定文档diff检查通过(仅既有LF/CRLF提示);只读方案复核已对齐源码字段/已有索引,补清错误优先级、已回滚竞争错误码及ID游标索引。未连接数据库/Redis/HTTP、未调用模型,不是运行测试,未形成真实积分开发检查或到账证据;POINT-001决策仍未批准。
## 历史开发:2026-10-01 模型运行参数与调用额度受控配置(未部署)
用户要求继续开发,仍暂不做运营、真机或发布验收。本轮ADMIN-003独立 `GET modelconfig/index` / `POST modelconfig/update`受控配置子项源码完成,四字段为暂停新调用、新评估模型Flash/Pro、每日三场景共享次数1–1000和评估尝试1–3;助手/产品解释仍固定3次,环境开关是硬限制,不编辑凭据/地址/timeout/maxTokens。复用保留 `fa_config.ai_runtime_policy` JSON,无新表/字段,通用配置入口/模型/refreshFile保护该行;无跨操作缓存,三场景会员锁内一次共享锁策略快照,旧调用模型冻结,进行中的curl自然收尾,暂停后的未开始任务沿禁用路径失败且保留已有预留。
保存CAS版本、CSRF、`cfg_`随机请求键、四种原因和成功审计同事务;审计失败回滚,原键重放返回旧回执,不覆盖后续变更,拒绝日志只记白名单。技术v5继承v4,新增3节点/1模板共4项已仅在 `127.0.0.1:3309/airz_product_test_20260930`显式备份初始化、复检0项,旧规则/绑定保持,不换绑或生产迁移。
本轮检查通过:策略纯83/事务24、双PHP/双连接并发43、业务路径纯63/隔离DB43、权限97、审计20,原服务53/用量76/重试39;服务HTTP207项(95请求)、Chrome Playwright桌面1280×900/手机390×844页面67项。首行并发实测MySQL1213,一方成功另一冲突;只确定1062/1205/1213映射冲突,不自动重试。覆盖提交成功却丢响应后刷新保留原键/同audit及version重放、另一管理员冲突刷新、CSRF/字段白名单/Referer不回显、旧技术v4可读服务但无配置读写权、客服运营拒写和超管通用Config保留行保护。坏策略禁止编辑、服务固定报错,quota保留已预留数但dailyLimit/remainingToday/maxAttempts为NULL。无未解释console/pageerror、横溢或文字重叠,刻意中断响应的一条网络错误另记。
收尾修了确认关闭动画期间快速响应阻断自动刷新的时序问题,以及同请求字段顺序变化误判异意图的问题。元字段固定规范顺序,乱序重放返回原audit/version且不覆盖后续保存;新增2项纯/1项隔离DB检查计入83/24,6表摘要一致并完整回滚。
并发14表、HTTP原13表和Redis原队列摘要一致,原Config与site.php摘要保持;held精确清理3项通过,owned配置/账号/绑定/审计/任务夹具已精确清理,凭据JSON及scope私密JSON删除,0模型/worker调用。23PHP lint、2JS语法、26源码/测试文件UTF-8无BOM/空白检查通过。原MySQL3309/PID19100和PHP8097/PID43340仍仅监听127.0.0.1,无本机analysis:work,bootstrap仍为 `C:/Users/JUN/AppData/Local/Temp/airz-product-editor-20260930/bootstrap.php`。证据目录 `C:/Users/JUN/AppData/Local/Temp/airz-admin-runtime-policy-20261001/`有 `verification.json/ui-result.json`、`saved-desktop.png/saved-mobile.png/invalid-mobile.png`。页面脚本依赖已删夹具,不可直接重跑,须重新建立显式隔离夹具。
仅本地开发,正式库、默认 `.env`、线上、真实worker和移动端未改,未部署/验收,ADMIN-003保持开发中。供应商账单/余额与外部告警尚需能力/方案确认,下一项按已具备依赖选择,不承诺外部能力。维护见后端 `D:/AAdesk/202609/airz/database/admin-services.md`和 `database/admin-permissions.md`。继续先复查3309/8097监听及显式bootstrap,不默认运行worker/恢复、不重跑旧cleanup/reset,不复用历史生产发布授权。
## 历史开发:2026-10-01 分析进程心跳与进度超时提示(未部署)
用户要求继续下一步,仍暂不做运营、真机或发布验收。本轮ADMIN-003最近登记常驻分析进程的进度观测子项源码完成:独立Redis JSON键按数据库及配置队列隔离;随机实例标识本次启动,原子核验instanceId及startedAt,新进程可接替,旧进程不能覆盖新进程的进度或停止状态。首写已落地却丢响应后也只走原子校验,普通进度更新可补登记缺失键,停止不复建。一次执行和仅恢复模式不登记;启动/工作/空闲/队列降级/停止边界记录安全元数据,Redis故障不影响业务执行、任务租约、调用预算和日志。
后台沿用服务检查只读GET及技术v4权限,无新节点/模板、角色换绑或数据库结构迁移。显示最后进度、年龄及阈值,明确区分缺记录、未启用、损坏、存储不可用和进度超时;超时不证明进程死亡、全部worker停止或供应商健康,退出路径上报停止不证明任务正常完成。暂不改DeepSeekClient,阈值为配置值(默认180秒)、模型timeout+60、任务租约及30秒的最大值,TTL86400秒且大于阈值;外部告警投递仍待方案。供应商余额/费用对账和受控配置编辑未完成,ADMIN-003保持开发中。
验证通过:心跳纯133项、真实隔离Redis90项(含首写丢响应12项)、服务纯53项/隔离DB14项、服务HTTP207项(95请求)、Chrome Playwright桌面1280×900/移动390×844页面43项,9文件PHP lint/JS语法通过。覆盖新owner保护、丢键恢复/停止不复建、once/recover-only、退出和致命错误回执、Redis故障和9种后台状态;无console/pageerror、文档横溢或文字重叠,0模型调用。13表原有记录和Redis原队列摘要保持,HTTP held精确清理3项通过;合成账号/会员/会话/调用/日志及所有随机观测键精确清理,凭据JSON和浏览器scope私密JSON删除。
仅本地开发/显式隔离检查,不部署、不改正式库、默认 `.env` 或移动端。维护契约见后端 `D:/AAdesk/202609/airz/database/admin-services.md`;证据目录 `C:/Users/JUN/AppData/Local/Temp/airz-admin-heartbeat-20261001/`有 `verification.json/ui-result.json/redis-result.json`及截图。专用8098/PID36740已停止,原MySQL3309/PID19100、PHP8097/PID43340仍监听127.0.0.1,无真实worker;原bootstrap仍为 `C:/Users/JUN/AppData/Local/Temp/airz-product-editor-20260930/bootstrap.php`。继续先复查监听和隔离配置,不复用已删夹具、不运行默认worker或旧cleanup/reset。下一开发项建议推进模型运行参数和调用额度的受控配置;供应商余额/费用对账仍依赖接口能力确认,外部告警投递方案另定,不复用历史生产发布授权。
## 历史开发:2026-10-01 只读模型与服务检查、调用用量(未部署)
用户要求继续下一步,仍暂不开展运营、真机或发布验收。ADMIN-003 首个只读子项源码完成:后台 `GET servicestatus/index/usage`,固定七项服务检查与任务排队/运行/租约观察,以及按会员/日期/场景/状态的安全调用元数据、20条分页和会员当日预留额度。模型只查PHP当前配置,不探测供应商;队列和登录Redis仅有限只读命令,目录只检查存在/权限不写探针,短信未接入、worker无心跳显示未知。队列正常不能证明进程存活,登记模型/密钥及用户Token额度不作为真实执行或供应商余额。
报表仅含已识别且未删除的评估/产品解释/助手记录,首尾日期包含、最多31天、默认PHP当日。额度按真实预算独立统计今日00:00后全部assistant预留,包含失败、软删除、遗留和未来时间,不随报表窗口/场景/状态变化。Token单值/汇总保留十进制字符串,未知NULL与已知0区分;不估算费用。数据不含正文、输入、报告、匹配、上下文、凭据、连接路径或原异常。包含超管的写入口均拒绝,不派发/恢复/消费队列或调用模型;页面加载/失败清旧指标和分页、锁定查询控件避免筛选错配。
新增3节点及独立技术 `v4` 模板,旧技术 `v3/v2/v1`、客服 `v2`、运营 `v1` 规则和账号绑定保留,不自动扩权。显式隔离库 `127.0.0.1:3309/airz_product_test_20260930` 备份应用4项,重复预检0项,原8097的 `__menu__`精确刷新;无业务表字段迁移。权限备份和本轮页面证据目录 `C:/Users/JUN/AppData/Local/Temp/airz-admin-services-20261001/`。正式库、默认 `.env`、线上与移动端源码未改,未部署。
维护与显式隔离测试见后端 `D:/AAdesk/202609/airz/database/admin-services.md`,权限见 `database/admin-permissions.md`。ADMIN-003保持开发中,供应商余额/费用对账、受控配置编辑和心跳告警仍待开发;ADMIN-002完整外部查询/对账继续依赖DATA-004。FAQ/反馈及全部受控后台仍须配套正式部署后再明确分配新模板,不运行默认.env/worker或旧cleanup/reset。
验证:服务纯48项/隔离DB13项、用量纯76项/隔离DB26项、权限定义90项;服务HTTP203项(95请求)、原权限227项(158请求)、Chrome Playwright桌面1280×900/移动390×844页面56项通过。覆盖真实20条分页、未知/零与大Token、历史筛选不改今日额度、加载防重、错误清旧结果与恢复、超管禁写和旧角色拒绝;无console/pageerror/横溢或文字重叠。全部合成账号/会员/会话/调用/日志精确清理,凭据JSON删除,13表原有行和Redis原队列摘要保持,0模型调用。安全回执 `verification.json`、`ui-result.json`和截图在上述临时目录;脚本依赖已删夹具,不能直接重跑,需显式新建随机夹具。
原MySQL3309/PHP8097保留,监听127.0.0.1,模型关闭、worker未启动。下一轮先重查监听和原bootstrap `C:/Users/JUN/AppData/Local/Temp/airz-product-editor-20260930/bootstrap.php`;不要默认加载正式 `.env` 或运行旧cleanup/reset。下一开发子项建议补分析进程心跳与失联提示,外部告警投递方案另定;供应商余额/费用对账和受控配置编辑尚未完成。
## 历史开发:2026-10-01 受控人工重试与操作审计(未部署)
用户授权继续下一步,仍暂不开展运营、真机或发布验收。ADMIN-002 的人工重试/审计源码已完成:`POST assessment/retry` 严格要求CSRF、预期当前失败版本/任务、`adm_`加32位小写十六进制随机请求键和四种固定原因。失败详情显示确认弹窗,防重复提交,失联保留原键,接受后刷新当前元数据/历史尝试并提供操作日志入口。用户与管理员请求命名空间隔离,排障查询已支持管理员请求键定位原版本和任务。
固定顺序锁管理员/会员/会话/当前输入,复用用户失败重试、尝试上限及每日调用预留。成功审计复用 `fa_admin_log`,与任务及回执同事务;审计失败全回滚。成功重放先核验原审计意图、返回原任务,不随新版本变化创建新任务。提交后才派发,失败保留接受回执,可原键补投;结束钩子的拒绝日志仅白名单,写失败不会覆盖既有固定响应。表单输入、已发布报告、匹配和历史版本保持冻结。Redis可重复投递同任务,执行幂等靠任务/租约校验,不能表述为队列exactly-once。
新增 `assessment/retry` 仅技术 `v3` 模板授权。旧技术 `v2`、客服 `v2`、运营 `v1`及账号绑定保留,不自动扩权。本机隔离库显式备份应用1节点/1模板共2项,重复预检0项,两个隔离runtime的 `__menu__`精确刷新;没有业务表字段迁移。正式库、默认 `.env`、线上及移动端源码均未修改,未部署。维护见后端 `D:/AAdesk/202609/airz/database/admin-assessment.md`。
验证:重试服务纯39项/隔离事务45项、审计90项、权限82项、诊断纯48项/隔离事务47项;重试HTTP/数据库1016项(91请求),原排障回归1075项(200请求),原权限227项(158请求)均通过。Chrome Playwright桌面1280×900/移动390×844页面38项通过,特别覆盖失联→取消→页面内刷新→CSRF更新→原键返回同任务/审计,以及冲突刷新、状态/调用列表刷新和旧技术角色隐藏按钮。无新增未解释console/pageerror,刻意丢弃一次响应的网络错误单独记录。13表原有行及其他队列摘要一致,0次模型调用。
本轮合成账号、会员、评估、调用、消息、日志、专用trigger与随机队列已精确清理,临时凭据JSON已删除,profile恢复。两条浏览器匿名登出日志经确切ID/时间/来源/行hash核验后纳入本轮清理,未按范围删除。安全回执 `C:/Users/JUN/AppData/Local/Temp/airz-admin-retry-20261001/verification.json`、`ui-result.json`;截图和权限备份亦在该目录。临时UI脚本依赖已删除夹具,不能直接重跑;需显式创建新夹具。8098专用PHP已停止,原MySQL3309/PHP8097保留,下一轮先核对监听和原bootstrap,不运行默认.env/worker或旧cleanup/reset。
ADMIN-002 保持开发中,完整外部查询/对账依赖 DATA-004;下一独立开发项可推进 ADMIN-003 服务状态检查。FAQ/反馈及全部受控后台仍须配套正式部署后再明确分配新模板,不直接运行默认worker或旧cleanup/reset。
## 本轮继续开发:2026-10-01 已有评估任务只读排障(未部署)
用户要求继续下一步开发,仍暂不做验收。ADMIN-002 的已有评估任务只读排障完成源码和隔离开发检查:FastAdmin 新增评估任务列表与详情,按评估/任务/请求/会员 ID 精确查询,显示阶段、派发、租约、稳定错误提示及分别分页的提交版本/调用尝试。当前任务、已发布结果和历史输入版本分开;历史零调用、未关联旧调用、坏版本/错指针、缺任务/租约可定位,产品解释不混入评估尝试。未知用量不冒充零,迟到用量更新时间不冒充完成时间。
只在数据库提取安全元数据,不返回表单、报告、匹配、分析上下文、模型正文、供应商异常原文和密钥。控制器包含超管的所有写入口拒绝,无恢复、派发、人工重试或模型调用。新增3个只读权限节点及客服/技术 `v2` 模板,运营仍 `v1`;原客服/技术 `v1` 与账号绑定保留,不自动扩权。只对隔离库 `127.0.0.1:3309/airz_product_test_20260930` 显式备份应用5项,重复预检0项,精确刷新隔离 `__menu__`;备份及UI截图在 `C:/Users/JUN/AppData/Local/Temp/airz-admin-assessment-20261001/`。
48项纯投影、42项隔离事务、1053项HTTP/数据库开发检查通过(194次请求),包含超管写入拒绝和上述异常关联;原后台权限另227项回归通过(158次请求),权限定义75项、隐私37项和字段投影73项通过。桌面1280×900/移动390×844页面27项查询、详情、tab、分页、错误/空态检查通过,无新增控制台错误。合成对象及临时账号JSON已精确清理、原记录保持,0次模型调用。正式库、默认 `.env`、线上和移动端源码未改,未部署或做运营/真机验收。
接口与测试命令见后端 `D:/AAdesk/202609/airz/database/admin-assessment.md`,权限说明仍 `database/admin-permissions.md`。ADMIN-002 保持开发中,下一开发子项建议受控人工重试与操作审计;完整外部查询/对账依赖 DATA-004,ADMIN-003 服务检查也尚未做。正式发布仍须配套FAQ/反馈字段和受控后台,再由超管明确分配新模板。
隔离MySQL3309/PHP8097继续监听127.0.0.1,bootstrap仍为 `C:/Users/JUN/AppData/Local/Temp/airz-product-editor-20260930/bootstrap.php`,模型关闭、worker未启动。下一轮先核对监听;禁止加载默认正式 `.env` 或运行旧cleanup/reset。临时UI脚本依赖已经删除的合成凭据JSON,不能直接重跑,应使用显式隔离测试重新创建随机夹具。
## 本轮继续开发:2026-10-01 后台菜单与角色权限(未部署)
用户同意继续 ADMIN-001 功能开发,仍暂不开展验收。已新增 FastAdmin 运营/客服/技术三个最小权限模板,复用三张管理员权限表,不恢复会员组/会员规则、不创建账号或自动绑定。运营管理产品和内容;客服处理反馈及只读融资资质;技术只读配置、受控启停密钥及查看范围内日志。姓名、手机号、企业信息保持掩码,资料附件/证件原文/密码/令牌/密钥不输出;独立 `user/info/financial` 只给客服。技术模型配置写入及密钥新增/轮换由超管保留,受控配置继续属于 ADMIN-003。
资料与系统消息后台全只读(包含超管和批量/导入/回收站);会员、资料、密钥列表及模型选择器限制查询和返回字段。密钥批量仅改合法状态,不改密文/所属模型/用量。反馈列表和编辑移除历史附件地址;日志新写入和历史读取脱敏,URL不保留查询串。通用排序与缓存清理补权限校验,角色登录首屏只打开有权业务页。
后端 `database/migrate_admin_permissions.php` 必须显式 `--env --target`,默认只读;应用要求 `--apply --backup-dir`,备份/锁表后仅增量插入,模板漂移、危险同名节点或不兼容层级拒绝。只对隔离库 `127.0.0.1:3309/airz_product_test_20260930` 初始化50节点/3模板并精确刷新 `__menu__`,既有角色和账号绑定保持;备份在 `C:/Users/JUN/AppData/Local/Temp/airz-admin-permissions-20261001/`。源码、FAQ/反馈业务及反馈字段必须配套正式部署后再绑定新角色,不能给旧版通用CRUD直接授权。
PHP/JS语法和纯内存回归通过;`tests/admin_access.php` 227项隔离HTTP/数据库检查(158次请求)通过,含角色菜单/默认页、直接越权、财务字段隔离、只读超管、客服回复、密钥事务/选择器及历史日志脱敏。合成对象精确清理,13表原有行保持,0次模型调用。正式库/默认 `.env` 未改,本轮未部署、未做运营或真机验收;台账保持开发中。
隔离MySQL3309与PHP8097已沿用原配置恢复,监听127.0.0.1;PHP关闭模型,未启动worker。临时bootstrap仍为 `airz-product-editor-20260930/bootstrap.php`,继续前先重查监听,不运行旧cleanup/reset脚本。下一步推荐 ADMIN-002:先做已有评估任务的只读阶段/错误定位,完整外部查询仍依赖 DATA-004;FAQ/反馈和权限业务尚未上线。
## 2026-10-01 数据库注释与实体类注释已部署
已按授权完成正式库 `111.230.144.231:3306/airz` 的注释同步:26 张表、157 个已有字段的中文注释已更新,重复预检为 0 项待修改;线上复核为 26 张表注释、342 个已有字段注释。因正式 `fa_feedback` 仍缺少 `request_id`、`reply`、`handled_by`、`handled_at`,本次只同步现有结构,没有上线反馈闭环字段和 API。
同时仅发布 29 个实体模型类的类级 PHPDoc/`@property` 注释,逐文件备份并确认可执行代码未变化。FAQ、反馈闭环和内容编辑业务逻辑仍未部署。部署前后 `airz-analysis`、`php-fpm-74`、Redis 均 active,worker `NRestarts=0`,分析队列、保留队列和延迟队列均为 0。服务器备份为 `/root/airz-comments-20261001/`,本机回执在 `D:/AAdesk/202609/airz-backups/comments-deploy-20261001/`。
## 本轮继续开发:2026-10-01 FAQ服务端化(未部署)
在不进行真机/跨设备验收的前提下,已完成FAQ服务端开发:复用`fa_acticle`的`type=6`和`content` JSON(`schemaVersion=1/kind=faq`),新增公开`GET /api/v1/help/faqs`及分类筛选;新增独立后台FAQ编辑模型、控制器、页面和版本保护;前端帮助页移除本地FAQ数组,改为始终读取服务端。没有新增表或字段,没有写入正式库或部署。
初始内容见后端`database/faq_initial.json`,导入脚本为`database/import_faq.php`,必须显式指定隔离/正式环境与目标,默认预检回滚,重复按slug保留已有记录。源码语法检查及隔离事务中的FAQ保存/读取/下架检查通过;现有本地HTTP服务因尚未导入种子返回空列表是预期。下一步继续做反馈提交/处理闭环;人工客服接入仍等待`EXT-CS`供应商方案。
## 本轮继续开发:2026-10-01 反馈提交与处理闭环(未部署)
已完成`SUPPORT-002`源码:复用`fa_feedback`新增`request_id`、`reply`、`handled_by`、`handled_at`和会员请求键唯一索引;迁移脚本为后端`database/migrate_feedback.php`,显式环境/目标,默认只预检,写入要求`--apply --backup-dir`。本轮仅对本机隔离库`127.0.0.1:3309/airz_product_test_20260930`应用,备份在受限临时目录;正式库、正式`.env`、PHP部署和后台正式资源均未修改。
新增`FeedbackRecords`和登录接口`POST/GET /api/v1/feedback`、`GET /api/v1/feedback/:id`。反馈正文去HTML、最多255字;同会员同`requestId`幂等,参数冲突返回409;附件服务未开放,非空`attachmentIds`拒绝。后台只能编辑状态与回复,自动记录处理管理员/时间,新增、删除、批量和导入拒绝。帮助页保留服务端FAQ并新增意见反馈、本人记录和重试。
验证:新增/修改PHP lint通过;隔离迁移预检、应用及重复预检通过;`tests/feedback_records.php`隔离数据库回归58项通过,含并发幂等、越权、历史NULL键和原始字段保护;HBuilderX 5.26 Web、微信小程序和Android 27页编译通过。未做真机、跨设备、视觉验收或正式部署;人工客服仍等待`EXT-CS`供应商方案。
## 最新内容与部署:2026-10-01 已完成
用户授权继续功能开发、改用airz.hqling.cloud并部署后端。新闻/融资课堂/详情/首页推荐/搜索已接真实服务,支持分类分页、总数、下架/空态/错误恢复及迟到响应保护,移除原静态数组兜底,保留原型页面层级。后台复用文章和融资课堂入口,结构化编辑、发布/下架、封面及章节排序/版本冲突保护完成。FAQ与反馈源码已完成但尚未部署,CONTENT-002/003待真机及多设备验收,27页/63任务不变。
复用fa_acticle(新闻type=5)与fa_finance现有content TEXT保存schemaVersion=1的JSON,不给现有会员/资料/会话/内容表增加字段。新增fa_user_lesson_progress关系表;按当前会员保存固定章节ID、正文摘要和首次完成时间,章节重排保留进度、正文更新使对应进度失效,lessonVersion冲突返回409。消息表uk_user_message_event唯一索引本轮一并上线。初始原文4篇新闻/3门课程按slug只新增,重复预检无改动,不添加环境标签。身份资料、评估输入版本及match_result存储约定保持。
本机40项MySQL5.7隔离数据库、81项内容HTTP/Playwright(42次隔离请求)、13项后台UI/HTTP检查及166项产品编辑纯内存回归通过;390×844与1280×900截图无运行错误,微信/Android开发编译通过。服务器64项真实HTTPS/FPM内容/学习进度/消息/产品目录/评估报告/PDF检查通过,2次显式假评估、0次收费调用。测试会员/Token/消息/进度/资料/会话/独立队列精确清理;26张表按预期摘要一致,所有原有行、无关源码、.env与数据库全局参数保持。
正式34个源码/后台资源文件已通过宝塔文件页面和终端限定发布;排除.env、vendor及环境相关application/extra/site.php。新增消息索引和课程进度表后导入7条内容,重启PHP-FPM,恢复worker与公网入口;维护gate已移除。三服务active,最终worker MainPID=1041115/NRestarts=0、队列[0,0,0],后续需重新核对。不要重跑已完成维护阶段或默认本机worker,默认后端.env仍指正式库。
当前正式域名为 **https://airz.hqling.cloud**,API `/api/v1`,后台 `/ZYurKboadX.php`。DNS A为111.230.144.231,宝塔新增绑定、部署Let's Encrypt RSA2048证书后移除aitest.ychysg.cn旧绑定;内部站点名与目录仍xctai.cn及/www/wwwroot/xctai.cn,public为运行目录。证书到期2026-12-29 16:12:27 UTC;公网11条路径检查和HTTP301跳转通过,TLS校验0,内容/字典200、匿名消息/产品401、敏感路径404。历史旧域名HTTPS阻塞不代表新域名现状。
服务器备份 `/root/airz-content-20261001/`;数据库/源码/nginx/证书/宝塔数据库、增量迁移回执及验收记录共22个文件已下载并校验SHA256至受限本机 `D:/AAdesk/202609/airz-backups/content-deploy-20261001/`(server-files内为解包)。恢复见后端[内容发布维护](D:/AAdesk/202609/airz/database/content-publication.md):优先回限定源码并保留增量数据,禁止整库回退、覆盖新消息或删除有进度的课程表。备份含私密信息,不对外分享。浏览器证据仍在临时airz-content-20261001目录。
前端services/config.uts已用https://airz.hqling.cloud/api/v1,APP_ENVIRONMENT=test、模拟关闭、production地址仍空。本轮小程序尚未代上传,需要重新发行,并将微信request合法域名改为https://airz.hqling.cloud;头像上传/文件下载核对相应平台域名配置。H5未部署,真机/多设备/真实模型未验收。FAQ/反馈的正式字段、API和业务逻辑仍待单独授权部署,或继续开发其他未完成模块。
本机隔离MySQL3309、PHP8097与H5 5173沿用,继续先核对监听及临时bootstrap;3309曾退出后已用原配置恢复,不依赖旧PID。不要用默认.env代替隔离配置。MySQL参数绑定采用TP5命名绑定,列表与计数分别新建Query,不能clone共享Builder;Android账户回调需延后绑定。关键约束已加中文注释。
## 历史消息功能:2026-09-30 本机完成,后于10-01部署
用户暂停公网HTTPS排查,已上传上一版小程序,并授权继续功能开发。本轮完成MSG-001—004本机实施:消息中心接真实分类分页/未读数/单条和全部已读、详情及原版本报告/档案跳转,首页红点同服务器状态;登录可回消息页,旧静态通知/本机已读不再使用。保留原图标、分类和底栏,修滚动高度及分页错误提示位置;离页/换分类/账户切换拒绝迟到响应,Android前向函数引用使用延后绑定兼容。关键约束中文注释,27页/63任务不变。
后端新增UserMessages.php、API Messages.php和五条路由,Assessment.php成功/失败同事务通知;business_id为system输入日志revisionId,事件唯一键原子去重。详情校验当前会员、关联会话和输入日志的归属/删除状态;旧消息始终绑定旧版本。列表snapshotId上界+beforeId分页,全部已读只更新上界内,单条首次readAt保持。复用fa_user_message现有列,仅加uk_user_message_event唯一索引,无新表/字段、无历史补通知。
50项MySQL5.7真实隔离数据库检查(5次假模型)、57项HTTP/Playwright(42次隔离API)及16项索引迁移恢复检查通过;手机390×844/桌面1280×900,console/pageerror为0,微信/Android最终开发编译通过,0次收费调用。测试会员/Token/资料/会话/日志/消息和本批随机队列已精确清理,25表原有行摘要一致,原4条材料草稿及6条产品保留。临时证据C:/Users/JUN/AppData/Local/Temp/airz-messages-20260930/qa.json、qa-migration.json、cleanup.json、messages-390.png、messages-1280.png、messages-empty.png及索引备份;凭据与Token不写入仓库。
**正式消息索引迁移、四文件PHP部署及本轮小程序/H5发行尚未执行**;用户已上传的是上一版,以下旧段落中的“尚未上传”不代表当前上一版状态。正式库及.env本轮未改。维护/恢复见后端[消息说明](D:/AAdesk/202609/airz/database/user-messages.md):新生产维护需本轮授权,先停写/停空闲worker/备份,先索引后PHP最后前端;重复组或错误同名索引阻断,不自动删除消息、不整表回退。测试只允许显式本机airz_product_test_*配置,不直接用默认.env或旧集成测试。
本机3309隔离MySQL5.7、8097隔离PHP和5173 H5沿用,继续先查监听;PHP临时bootstrap模型禁用,不运行默认worker。H5源码API仍为正式域名,Playwright验收仅通过浏览器请求转发到8097,不代表正式消息接口可用。公网HTTPS排查继续暂停,不复测公网/真机/真实模型;后续功能建议为资讯、融资课堂与搜索的服务端接入,复用现有内容表和页面。
## 最新产品展示:2026-09-30 正式数据及三文件PHP已部署
+199
View File
@@ -0,0 +1,199 @@
# 积分规则与开发契约
记录日期:2026-10-01。版本:points-backend-20261001-v2。状态:**后端及真实HTTP隔离开发检查完成,业务规则未定稿,默认不入账,原表未迁移、未部署**。归属既有 POINT-001—004,不新增任务编号。POINT-002保持开发中;DEC-POINT/POINT-001仍待确认,POINT-003未实施,POINT-004页面尚未接真实积分。
本文件作为积分实施入口;数据库具体增量见[积分数据库方案](D:/AAdesk/202609/airz/database/points-design.md)。接口源码与真实HTTP隔离检查已完成,未部署;页面仍是本机体验签到。未获规则确认时不填真实奖励,配置默认false/null。
## 0. 最新协议与隔离开发检查
87个真实HTTP请求/1079项检查通过,经过实际ThinkPHP路由、Redis Token鉴权和积分控制器。覆盖匿名/无效/停用/删除/撤销、Cookie/旧Token头拒绝、允许Origin的匿名OPTIONS、JSON/原始query/错误方法、草案/发布/暂停/坏规则、跨日/版本变化、实际丢弃已提交成功响应后的原键确认、本人快照/隐私/旧NULL与0流水、余额边界、坏回执和Redis不可用。
新脚本tests/points_http.php仅取明确私密环境的本机3309连接,创建独立合成数据库、临时ROOT/RUNTIME、随机ENV_PREFIX和Redis DB15随机Token作用域;不读取正式.env,不启动worker或调用模型。成功后自建数据库、Token、临时服务/文件已清理,共享fa_user/fa_user_score_log结构/数据及原Redis队列摘要保持。所有权标记限制目录清理;清理失败返回非0并保留受限目录及安全定位,不能声称资源已移除。
主服务238项、HTTP输入190项纯检查通过。原表23项只读预检通过,仍4增量、14类质量计数各项0,applied=false。迁移新增非NULL历史时间质量计数,正UINT流水ID并入invalidLedgerIntegers;合法NULL历史时间保留,非NULL时间范围0—253402271999,确保上海ISO8601四位年份。首次快照坏主键固定409/POINT_LEDGER_INVALID。当前业务配置保持false/null,页面、原表迁移及部署未完成。
迁移纯110项、独立合成库apply/备份/Windows ACL/恢复89项通过。共享隔离库只复制结构,第一新库合成2会员4流水;apply4增量、重复为0,完整双表schema+NDJSON恢复第二新库并逐列摘要一致。目录3文件ACL及读写probe通过,修正Windows原生绝对路径及父级路径校验,纯路径边界覆盖;两新库、私密INI/备份/probe已清理,源表摘要保持。安全verification保留于C:/Users/JUN/AppData/Local/Temp/airz-points-migration-20261001/apply-20261001-115749-d50a5cd87fd5a84a/verification.json。仅为开发演练,未执行共享原表DDL或生产操作。
### 0.1 历史:0.48后端基础检查
已新增 PointsRules、UserPoints、v1 Points 控制器、五条路由、默认未发布配置、显式迁移脚本及测试。通用后台score/level、通用模型更新与旧User::score均已拒绝写入;新服务直接在账户锁内限定更新score并保存完整流水,不重算level。新会员0分、数据库默认0级及Auth注册1级初始化保持。schema.sql和ScoreLog中文注释已同步目标结构,不能据此声称目标库已经增加字段。
纯检查237项服务、98项写保护、84项迁移通过。隔离MySQL3309随机前缀合成表45项检查通过,包含同键/异键双PHP并发、锁等待跨日和真实流水insert故障回滚;测试表及临时文件已精确清理。原fa_user/fa_user_score_log结构和数据摘要保持。另22项只读迁移入口检查通过,重复预检仍为4增量、13类质量计数均0,applied=false;本轮修复PHP7.4 PDO不追踪原始START事务导致预检commit失败,改PDO事务接口与RR只读快照。
没有迁移原表、运行apply/备份/Windows ACL、调用模型/worker/Redis/HTTP、编译页面或部署。提交结果未知和连接故障由纯替身覆盖,不是实际断网或生产到账证据;页面状态、真实HTTP、迁移应用与恢复仍须后续开发检查。
## 1. 首阶段建议与待确认项
建议先实施 POINT-002 的账户余额、流水、每日签到,再接 POINT-004 对应页面部分。POINT-003 邀请关系和可信奖励独立实施;不因完成签到而标记整个积分模块完成。
| 项目 | 首阶段建议 | 状态与理由 |
| --- | --- | --- |
| 每日签到 | 已登录且状态正常的会员,每个业务日一次,每次 10 分 | **待确认数额**;沿用既有页面建议,不表示已批准真实奖励 |
| 业务日 | 服务端 Asia/Shanghai,00:00 至次日 00:00,不提供补签 | 待确认;设备时间、时区及登录连续天数不作为依据 |
| 连续签到加成 | 不启用,始终固定当期奖励 | 待确认;不需要新增连续签到表 |
| 完善资料奖励 | 首阶段关闭,奖励数额为 null | 必须后续确定完整标准、可信事件和一次性范围;进入档案或编辑资料不发奖 |
| 邀请奖励 | 首阶段关闭,奖励数额为 null | 有效邀请条件、双方奖励、次数和归因期限待定;复制、分享、绑定码不直接发奖 |
| 积分用途 | 首阶段只提供余额与流水,不启用提现、现金兑换、AI 次数抵扣或贷款优惠 | **待确认权益范围**;不能把积分说成授信、信用分、放款条件 |
| 有效期 | 首阶段建议不自动过期 | **待确认**;若需要到期扣减,须先设计到期批次/消费抵扣,不靠定时清零余额实现 |
| 会员等级 | 新签到服务首阶段建议仅变更 score,不新增等级或权益联动 | **待确认**;旧 User::score 会按余额改 level,需要明确兼容处理后实施 |
| 历史体验积分 | 不导入、不补发真实积分 | 本机记录可篡改,匿名也可签到;正式余额只取服务端既有 score |
| 历史服务端数据 | 原 score、流水、日期、备注、业务键保留 | 不按流水合计重建余额、不自动清零、不擅自修复差异 |
首阶段的业务确认应至少覆盖“每天 10 分、上海每日一次、不补签”“资料/邀请奖励关闭”“权益、有效期及等级处理”。在明确确认前,设计状态保持 draft;未来配置的安全默认值必须为未发布、停止新入账,奖励数额不得用页面示例兜底。
## 2. 已核实的源码现状
| 位置 | 当前能力或风险 | 实施处理 |
| --- | --- | --- |
| [积分页面](../packages/account/points/points.uvue) | 余额为本机签到日期数乘 10;设备日期;固定体验码 AI2680;没有积分 API | 接真实 API 后移除该余额算法及固定码,清理体验缓存但不换算奖励 |
| [账户服务](../packages/account/services/account.uts) | 已有登录、账户变化防护;资料 DTO 无 score/share_code | 新增同分包积分服务,复用会话而不扩充无关资料字段 |
| [请求基础](../services/api.uts) | 有 Token 变化及 401 防护;当前错误仅保留 message | 实施时保留现有调用兼容,增加可识别的 HTTP 状态/errorCode,区别确定失败与结果未知 |
| [数据库结构](D:/AAdesk/202609/airz/database/schema.sql) | fa_user.score 为有符号 INT;流水已具业务键、事件唯一及用户时间索引 | 复用余额和流水;目标库实际结构仍须迁移前只读核实 |
| [通用积分方法](D:/AAdesk/202609/airz/application/common/model/User.php) | 旧score()已明确拒绝,通用模型不能更新score/level | 使用UserPoints领域服务,不再吞异常或随积分重算等级 |
| [后台会员模型](D:/AAdesk/202609/airz/application/admin/model/User.php) | 模型事件、后台增改/批量及编辑表单已保护score/level | 积分/等级只读;任意SQL仍不经过模型守卫,新增写方须遵守共同账户锁 |
最初设计依据源码和schema.sql;本轮仅对显式隔离库读取实际结构/数据并在随机合成表检查事务。正式库未连接,隔离原积分表仍缺两个新列和两个索引,不能把合成表或结构文件当作正式迁移证据。
## 3. 规则发布与事件身份
已增加application/extra/points.php,独立于模型调用额度。默认 `enabled=false`,rule_version/check_in_reward/expiry_policy/level_policy/benefits_policy均null;API映射ruleVersion/checkInReward。发布必须提供完整版本、1—1000的规范整数奖励及none/unchanged/none三项策略;这一范围是配置校验上限,不是批准的奖励额。enabled=false且保留完整发布内容表示paused,不删历史回执。损坏/不完整配置固定POINT_RULE_UNAVAILABLE。
规则版本格式为 2—64 位小写 ASCII,`^[a-z][a-z0-9-]{1,63}$`;拟首版名 `points-checkin-20261001-v1`。同一版本永久对应同一奖励额、日期和参与语义;变更必须新版本,不改历史流水。暂停活动不删除成功回执;启用配置编辑后台不属于首阶段,先使用受控部署配置。
每日签到的账本事件固定:
```text
business_type = checkin
business_id = 服务端上海业务日 YYYY-MM-DD
unique = user_id + business_type + business_id
```
事件键不包含规则版本,因此同一天发布新版本也不能重复奖励。新请求只能为当前业务日入账;设备日期和客户端字段不能指定补签。旧已成功请求允许在下一日、规则暂停或新版本发布后返回原回执,但仍要求当前账户有效且拥有该流水。
## 4. 接口源码契约
统一前缀 `/api/v1`,全部要求显式合法Bearer及当前正常会员,不接受Cookie/旧Token头回退。沿用v1 Base的code/msg/data与data.errorCode、Cache-Control: no-store,v1框架错误亦补该缓存头。由认证身份取得userId;未知参数、客户端余额/奖励/业务完成状态/其他用户ID均拒绝。五条精确白名单路径已实现并通过HTTP开发检查,原表迁移/规则发布/页面尚未完成,不视为线上能力。
签到要求application/json,可带UTF-8 charset;正文由原生JSON解码及领域校验,必须三个唯一字符串字段,重复键和Unicode等价重复键拒绝。原始QUERY_STRING校验唯一解码键、白名单及PHP解析一致;NUL、坏编码、重复和空片段拒绝。原始/有效HTTP方法须与接口方法一致,覆盖头存在即拒绝;错误方法405,未知路由404。Common既有入口在鉴权前处理允许Origin的匿名OPTIONS,不重复实现CORS。
| 方法与路径 | 严格输入 | data 主要字段 |
| --- | --- | --- |
| GET /points | 无业务参数 | balance:int、serverDate、timezone、signedToday、checkInEnabled、ruleVersion:string/null、checkInReward:int/null、balanceState:normal/anomaly |
| GET /points/rules | 无业务参数 | status:draft/published/paused、ruleVersion:string/null、checkInReward:int/null、timezone、publishedRules;未发布规则不显示为已生效 |
| GET /points/ledger | limit 默认20/最大50;后续页 beforeId + snapshotId | items、snapshotId、nextBeforeId;本人流水,ID 字符串 |
| POST /points/check-in | requestId、expectedRuleVersion、expectedDate,必须恰好三个字段 | ledgerId、delta:int、balanceAfter:int、signedDate、ruleVersion、replayed:bool |
| GET /points/tasks | 无业务参数 | 首阶段只返回已发布签到任务;未实施资料/邀请奖励明确 unavailable,reward=null |
流水源码采用与现有消息/内容一致的有界游标,替代旧page/pageSize目标草案;没有已发布积分API,不涉及接口兼容迁移。tasks实际返回serverDate/timezone/items,签到任务按已发布规则和今日状态显示,资料/邀请始终unavailable且reward=null。邀请/invitations和/invitations/bind保留后续阶段目标,不返回固定码或假的奖励进度。
### 4.1 签到请求及回执
`requestId` 为 `pts_` 加 32 位小写十六进制,客户端用安全随机数生成,同一用户命名空间内唯一。`expectedRuleVersion` 为页面实际读取的已发布版本;`expectedDate` 为同一次读取返回的 serverDate,是操作预期条件,**不是指定奖励日期**。日期须为真实有效的 YYYY-MM-DD,拒绝非法日历日期、空值、数组和宽松类型转换。
示例仅描述获批发布后的协议形状,当前无这样的运行规则:
```json
{
"requestId": "pts_0123456789abcdef0123456789abcdef",
"expectedRuleVersion": "points-checkin-20261001-v1",
"expectedDate": "2026-10-01"
}
```
入账回执与原流水绑定;重放中 `replayed=true`,其余业务字段保持原值。`balanceAfter` 是这笔入账后的历史余额,不是当前余额;页面必须随后重新 GET /points,不能用旧回执覆盖新余额。不同请求键同日重复签到返回明确的 `POINT_ALREADY_SIGNED`,不写第二条零分流水,也不把新的请求键附到原流水。
### 4.2 余额与流水读取
余额、今日签到状态与规则须来自一次一致的读取:先按当前会员事务读取,固定一个服务端业务日,再完成对应状态查询,避免日界/入账途中混合视图。日期和规则必须一起提供,客户端不能用本机时钟补齐。服务不可读时固定失败,不把错误或未知余额转成 0。
流水DTO为ledgerId/delta/balanceBefore/balanceAfter/businessType/businessDate/ruleVersion/createdAt。时间为ISO8601带时区,历史未知时间保留null;日期为上海YYYY-MM-DD。ID为字符串,支持现有无符号INT至4294967295,服务/迁移要求64位PHP。历史未识别事件统一legacy,业务日/版本可null;不返回原memo、原业务标识或请求键。历史真实0分记录如实展示,不因新服务禁止零分占位而删除。
首次列表取得本会员最大 ID 作为 snapshotId,空为字符串 "0";后续固定上界并按 `id<beforeId` 倒序读取,nextBeforeId=null 表示末页。上界/游标须属于当前用户;不存在或他人标识统一 `POINT_SNAPSHOT_INVALID`,不泄露归属。新流水不插入已翻页窗口,刷新重新取得上界。
负余额保留真实读数并标 balanceState=anomaly,停止新签到等待核对,不静默修正。本轮不根据历史流水合计断言账户余额错误;可能存在早期余额或未完整记录的合法调整。
### 4.3 固定错误
| HTTP / errorCode | 含义与页面处理 |
| --- | --- |
| 401 / AUTH_REQUIRED | 登录或账户状态失效;清当前账户状态,固定登录恢复 |
| 503 / AUTH_STORAGE_UNAVAILABLE | Token存储不可用;不当作已退出或业务未入账,保留待确认请求 |
| 405 / METHOD_NOT_ALLOWED | 原始/有效方法不符或使用方法覆盖;不进入账本操作 |
| 422 / POINT_INPUT_INVALID | 请求字段、类型、ID、版本或日期非法;不入账 |
| 409 / POINT_REQUEST_CONFLICT | 成功请求键已用于另一日期/版本;保留原账本,不改意图重试 |
| 409 / POINT_DATE_CHANGED | 未成功的预期日期不是锁后服务端当前日;刷新,用户重新点击才生成新请求 |
| 409 / POINT_RULE_CHANGED | 预期版本与当前有效版本不同;刷新后重新确认 |
| 409 / POINT_ALREADY_SIGNED | 当前日已有签到;刷新状态,不再次显示增加积分 |
| 409 / POINT_SNAPSHOT_INVALID | 流水游标无效;清旧列表并重新查询 |
| 503 / POINT_RULE_UNAVAILABLE | 规则未发布、暂停、损坏或不可读;关闭新签到,不以 10 分兜底 |
| 409 / POINT_BALANCE_INVALID | 负余额、非规范余额或本次加法越过 INT 上限;不更新 |
| 409 / POINT_WRITE_CONFLICT | 确定的1062/1205/1213竞争且本请求事务已回滚;不自动换键,可用原请求重试 |
| 503 / POINT_LEDGER_UNAVAILABLE | 账本查询/写入暂不可用,或提交/回滚结果无法确认;客户端保留原请求确认结果 |
| 409 / POINT_LEDGER_INVALID | 成功回执元数据损坏或业务唯一键与预期记录不符;不覆盖、不伪造回执 |
网络失败和无法确认的 5xx 不表示未入账。异常原文、SQL、内部连接、其他会员信息和凭据均不下发。
## 5. 事务与重放算法
1. 验证恰好三个字段,规范化按固定字段顺序处理,拒绝 GET query 或其他正文参数。以当前认证用户确定账户。
2. 开启事务,锁定 fa_user 当前行,重新核验 status=normal。账户不存在/禁用不能借历史请求重放。
3. 在用户锁之后以当前读查 `(user_id,request_id)` 的已成功流水。命中时核验 `business_type=checkin`、规范日期、规则版本、delta/before/after 算术及请求预期;同意图返回原回执,异意图固定冲突。历史 NULL 元字段不能充当该回执。
4. 未命中时,在取得用户锁后采样一个上海业务日与时间,校验 expectedDate。锁等待跨午夜必须使用锁后日期;不匹配先返回 POINT_DATE_CHANGED。
5. 以当前读检查固定 `checkin + 当日` 事件;已有则返回 POINT_ALREADY_SIGNED,优先于活动暂停或新规则变化。未签到才读取有效发布规则并校验活动启用和 expectedRuleVersion。业务键不因新请求或新规则版本变化。
6. 校验现有余额及奖励的整数边界,计算 after=before+reward,要求 0≤after≤2147483647。只进行正数签到奖励,负数调整与消费另行设计。
7. 同事务更新用户 score、写一条完整积分流水(事件键、请求键、规则版本、before/after/delta)。任一步失败全部回滚,不能吞异常或写零分占位。
8. 提交成功后返回流水冻结回执。确定1062/1205/1213且确认本请求事务已回滚时返回 POINT_WRITE_CONFLICT,不自动换请求键或无界重试;提交/回滚无法确认时返回 POINT_LEDGER_UNAVAILABLE,用原请求键确认。处理框架回滚异常时保留实际业务失败,不暴露原异常。
所有影响入账的数据库读取使用当前读,不能复用在用户锁前形成的 MySQL RR 快照或外层事务旧视图。规则若采用部署配置,必须每次操作读取该进程当前已发布配置;版本与语义不可变。跨实例滚动部署出现版本不一致时,固定规则冲突优先于偷偷改变奖励。
```mermaid
flowchart TD
A[验证登录和三个预期参数] --> B[锁账户并查成功请求]
B -->|同意图命中| C[返回原流水回执]
B -->|未命中| D[锁后日期校验]
D --> E[检查当日事件 再校验规则与积分边界]
E --> F[余额和一条流水同事务提交]
D -->|日期或版本变化| G[固定冲突并刷新]
E -->|已签到| G
```
## 6. 首阶段数据库与写路径
schema.sql及显式迁移脚本已定义可空request_id/rule_version、唯一(user_id,request_id)和ID游标索引(user_id,id);原表未应用,旧流水保持。已有业务唯一和用户时间索引不重复添加;用户时间索引不能直接保证按ID排序。成功流水保存日唯一和请求回执,不建独立签到/请求/连续签到表。具体字段、中文注释与预检见[数据库增量方案](D:/AAdesk/202609/airz/database/points-design.md)。
本轮已盘点并封闭通用后台绝对值编辑、批量、User::score和模型score/level更新;仅新增服务加锁不能防其他旧写方覆盖余额。受控管理员积分调整独立排期,当前只读;任意SQL不经过模型守卫,后续插件/导入必须重新审查。会员删除的既有历史策略本轮保持,可能有孤儿流水;已删除/停用账户不能通过新服务读取或重放。
新服务拒绝外层事务,确保成功意味着本次真正提交;不回滚调用方已有事务。数据库break_reconnect必须严格false,防断线自动重连使单条SQL意外自动提交。事务竞争只在确认本人事务回滚后映射409,未知提交返回503保留原键,不进行自动SQL重试。
后续邀请关系需独立表,受邀人唯一绑定、禁止自邀、邀请码唯一且历史冲突先人工处理。资料奖励以永久任务事件去重,不能按每次资料修订发奖。只有服务端可信业务事件可触发奖励;不能用客户端声称“资料已完成/好友已注册”发奖。
## 7. 页面实施边界
- 登录后恢复积分页需为现有登录白名单增加固定 `resumePoints` 目标,不接受任意回跳 URL。
- 初次/刷新分别处理加载、失败、空流水、未发布规则、已签到;失败清掉不可信旧状态,不显示假的 0 分或成功。
- 发送中锁定签到按钮;请求结果未知时保留原 requestId/date/version 和账户身份,只允许原请求确认。不能因刷新、关闭提示或跨日自动生成另一签到请求。
- 原回执仅确认原业务日;回执后重新读取当前余额和 serverDate。要签到新一天,必须由用户在刷新后明确点击。
- 离页/换账号拒绝迟到响应,未确认请求随账户作用域隔离;未知请求保留范围先按当前页面会话,不跨退出/换账号迁移。
- 从 API 的 ruleVersion/reward 显示规则与奖励,不再写死 10、AI2680 或本机积分。未接邀请前不显示“真实个人邀请码”或未发生的邀请奖励。
- 清理体验签到缓存不产生服务器写入;原匿名/本机记录不影响服务器 signedToday。
## 8. 后续开发检查清单与顺序
以下为完整开发检查范围;已覆盖部分见第0节。HTTP后端隔离检查已完成,页面和原表迁移/正式发布仍未完成:
| 检查 | 关键反例 |
| --- | --- |
| 并发入账 | 同日同键/异键双进程;只有一笔奖励和流水,余额正确 |
| 历史重放 | 第一天成功、第二天原键仍原回执;新日期复用成功键拒绝 |
| 日期边界 | 锁等待跨午夜;昨日未执行请求不能在新一天到账;客户端时区/时钟篡改无效 |
| 规则边界 | 规则变化/暂停/未发布;成功旧回执仍可读,未成功新请求拒绝 |
| 原子回滚 | 流水 insert、余额 update、commit 故障;无“加分成功但无流水” |
| 数据边界 | 负数、INT 上限、坏元数据、NULL 历史事件、非规范业务键不被误认 |
| 所有权与隐私 | 匿名/禁用/跨账户、流水游标和自由备注;只本人读写 |
| 老写方 | 后台伪造 score/level、legacy 方法/插件调用;不能覆盖新账本 |
| 页面状态 | 实际响应丢失、原键确认、换账号、登录恢复、加载/空/错、迟到回包 |
| 迁移和恢复 | 原表只读预检;新合成库apply4增量/重复0、完整备份/ACL/第二库恢复、旧数据摘要一致及精确清理已通过 |
当前后端可在规则未定稿时保持默认关闭。下一开发子项可实施积分页面真实读取、登录恢复及原键确认流程;规则未发布时签到不可用,不补造奖励。首阶段业务规则定稿、原表迁移和发布另行安排;邀请资格/奖励规则与关系方案留POINT-003,完整POINT-004后续推进。发布参数在确认前保持空值。
正式迁移、上线、真机/多设备、权益运营和真实业务验收后续按实际范围安排,不复用历史生产授权。本轮为源码、纯测试及显式隔离开发检查,不是生产签到到账或积分页面完成证据。
+7 -1
View File
@@ -33,7 +33,13 @@
let afterLoginUrl = '/pages/home/home'
onLoad((options) => {
consumeInitialLaunch()
if (options?.['resumeAssistant'] == '1') {
if (options?.['resumeLesson'] == '1' && /^[1-9][0-9]{0,9}$/.test(options?.['lessonId'] ?? '')) {
afterLoginUrl = '/packages/content/lesson/lesson?id=' + options?.['lessonId']
} else if (options?.['resumeMessages'] == '1') {
afterLoginUrl = '/packages/account/messages/messages'
} else if (options?.['resumeHelp'] == '1') {
afterLoginUrl = '/packages/content/help/help?feedback=1'
} else if (options?.['resumeAssistant'] == '1') {
const target = assistantReturnPath(options?.['assessmentId'] ?? '', options?.['productIds'] ?? '', options?.['chatId'] ?? '', options?.['from'] ?? '', options?.['revisionId'] ?? '')
if (target.length > 0) afterLoginUrl = target
} else if (options?.['resumeNextStep'] == '1') {
+93 -35
View File
@@ -1,17 +1,24 @@
<template>
<view class="page" :style="pageStyle()">
<AppHeader title="消息中心" :back="false" />
<view class="message-tabs"><view v-for="item in tabs" :key="item" :class="tab == item ? 'message-tab active-tab' : 'message-tab'" @click="tab = item"><text :class="tab == item ? 'tab-text active-text' : 'tab-text'">{{ item }}</text></view></view>
<scroll-view scroll-y :style="contentStyle(314, false)">
<view class="message-tabs"><view v-for="item in tabs" :key="item.type" :class="tab == item.type ? 'message-tab active-tab' : 'message-tab'" @click="changeTab(item.type)"><text :class="tab == item.type ? 'tab-text active-text' : 'tab-text'">{{ item.label }}</text></view></view>
<!-- 扣除顶栏96、分类88和底栏130,避免列表末端落到固定导航下。 -->
<scroll-view scroll-y :style="contentStyle(314, false)" @scrolltolower="autoMore">
<view class="messages-content">
<view class="message-summary"><text class="summary-text">{{ unreadCount > 0 ? unreadCount + ' 条未读消息' : '消息已全部阅读' }}</text><view class="read-all" @click="markAllRead"><text class="read-all-text">全部已读</text></view></view>
<view class="message-summary"><text class="summary-text">{{ loading ? '正在加载消息…' : (unreadCount > 0 ? unreadCount + ' 条未读消息' : (notices.length > 0 ? '消息已全部阅读' : '消息中心')) }}</text><view class="read-all" @click="markAllRead"><text :class="marking || loading || snapshotId == '0' || snapshotId.length == 0 ? 'read-all-text disabled-text' : 'read-all-text'">{{ marking ? '正在标记…' : '全部已读' }}</text></view></view>
<view v-if="error.length > 0" class="message-state"><text class="state-title">{{ error }}</text><text class="state-action" @click="reload">重新加载</text></view>
<view v-if="!loading && error.length == 0 && notices.length == 0" class="message-state"><text class="state-title">{{ tab == 'all' ? '暂无消息' : '暂无' + categoryLabel }}</text><text class="state-note">{{ tab == 'all' ? '评估状态更新后,会在这里通知您' : '当前分类暂无消息' }}</text></view>
<text v-if="actionError.length > 0" class="action-error">{{ actionError }}</text>
<view class="notice-list">
<view class="notice" v-for="(item, index) in filteredNotices" :key="item.id" :class="index == filteredNotices.length - 1 ? 'last-notice' : ''" @click="openNotice(item)">
<view class="notice-icon" :class="'notice-icon-' + item.icon"><view class="notice-icon-surface"><image class="notice-symbol" :src="'/packages/account/static/notifications/' + item.icon + '.svg'" mode="aspectFit" /></view><view v-if="item.unread" class="unread"></view></view>
<view class="notice-copy"><view class="notice-head"><text class="notice-title">{{ item.title }}</text><text class="notice-time">{{ item.time }}</text></view><text class="notice-desc">{{ item.desc }}</text><text class="notice-category">{{ item.category }}</text></view>
<view class="notice" v-for="(item, index) in notices" :key="item.id" :class="index == notices.length - 1 ? 'last-notice' : ''" @click="openNotice(item)">
<view class="notice-icon" :class="'notice-icon-' + item.icon"><view class="notice-icon-surface"><image class="notice-symbol" :src="'/packages/account/static/notifications/' + item.icon + '.svg'" mode="aspectFit" /></view><view v-if="!item.isRead" class="unread"></view></view>
<view class="notice-copy"><view class="notice-head"><text class="notice-title">{{ item.title }}</text><text class="notice-time">{{ item.time }}</text></view><text class="notice-desc">{{ item.content }}</text><text class="notice-category">{{ opening == item.id ? '正在打开…' : item.category }}</text></view>
</view>
</view>
<text class="message-footer">点击消息查看完整内容</text>
<text v-if="paginationError.length > 0" class="action-error">{{ paginationError }}</text>
<text v-if="loadingMore" class="message-footer">正在加载更多…</text>
<text v-else-if="nextBeforeId.length > 0" class="message-footer state-action" @click="more">加载更多</text>
<text v-else-if="notices.length > 0" class="message-footer">已显示全部消息</text>
</view>
</scroll-view>
<BottomNav active="message" />
@@ -19,41 +26,86 @@
</template>
<script setup lang="uts">
import { getUserStorage, setUserStorage } from '@/services/storage.uts'
import { getSession } from '@/services/api.uts'
import { UserMessage, fetchMessages, fetchMessage, readMessage, readAllMessages } from '@/services/messages.uts'
import { usePageLayout } from '@/services/layout.uts'
import AppHeader from '@/components/AppHeader.uvue'
import BottomNav from '@/components/BottomNav.uvue'
const { contentStyle, pageStyle } = usePageLayout()
const tabs: string[] = ['全部', '系统通知', '服务通知', '活动消息']
const tab = ref('全部')
type Notice = { id: string, icon: string, category: string, title: string, desc: string, body: string, time: string, unread: boolean }
const notices = ref<Notice[]>([
{ id: 'assessment-guide', icon: 'approval', category: '服务通知', title: '融资评估使用指南', desc: '完成资料填写,了解自己的融资需求。', body: '从首页选择个人或企业融资入口,按步骤填写基本信息、经营或收入、资产与负债情况。提交前请核对资料,评估结果仅供参考,具体申请条件以机构审核为准。', time: '10:30', unread: true },
{ id: 'repayment-guide', icon: 'payout', category: '服务通知', title: '还款安排提醒', desc: '申请前,记得核对每期应还金额与日期。', body: '比较融资方案时,除利率外还应核对还款方式、每期应还金额与到期本金。正式签约后,请以机构提供的合同和还款计划为准,通过官方渠道查询账户状态。', time: '09:15', unread: false },
{ id: 'invite-guide', icon: 'gift', category: '活动消息', title: '邀请积分说明', desc: '邀请前先了解积分规则与适用范围。', body: '可在积分中心查看邀请入口与积分说明。积分发放条件、有效期及可用权益以活动页面公布的规则为准,请勿向他人承诺固定收益或贷款结果。', time: '08:42', unread: false },
{ id: 'privacy-guide', icon: 'system-bell', category: '系统通知', title: '个人信息保护提醒', desc: '留意授权范围,保护账号与验证码。', body: '提交资料或授权数据前,请阅读相应说明,确认使用目的、数据范围和授权期限。不要向他人泄露账户密码、短信验证码或银行支付信息。', time: '昨天', unread: false }
])
const filteredNotices = computed((): Notice[] => tab.value == '全部' ? notices.value : notices.value.filter((item: Notice): boolean => item.category == tab.value))
const unreadCount = computed((): number => notices.value.filter((item: Notice): boolean => item.unread).length)
onShow(() => {
notices.value.forEach((item: Notice): void => {
if (getUserStorage('finance.notice.read.' + item.id) == true) item.unread = false
})
})
const markRead = (id: string): void => {
const item = notices.value.find((value: Notice): boolean => value.id == id)
if (item == null) return
item.unread = false
setUserStorage('finance.notice.read.' + id, true)
type MessageTab = { type: string, label: string }
const tabs: MessageTab[] = [{ type: 'all', label: '全部' }, { type: 'system', label: '系统通知' }, { type: 'service', label: '服务通知' }, { type: 'activity', label: '活动消息' }]
const tab = ref('all'); const notices = ref<UserMessage[]>([]); const unreadCount = ref(0)
const categoryLabel = computed((): string => tabs.find((item: MessageTab): boolean => item.type == tab.value)?.label ?? '')
const snapshotId = ref(''); const nextBeforeId = ref(''); const loading = ref(false); const loadingMore = ref(false)
const marking = ref(false); const opening = ref(''); const error = ref(''); const actionError = ref(''); const paginationError = ref('')
let visible = false; let generation = 0
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeMessages=1' }) }
const reset = () => { notices.value = []; unreadCount.value = 0; snapshotId.value = ''; nextBeforeId.value = ''; error.value = ''; actionError.value = ''; paginationError.value = ''; marking.value = false; opening.value = ''; loading.value = false; loadingMore.value = false }
// UTS原生端不支持局部函数前向引用,账户切换回调在加载函数声明后绑定。
let reloadAfterAccountChange: () => void = () => {}
// 离页、换分类和账户切换后,旧请求不能恢复上一账户的消息或触发业务跳转。
function current(request: number, token: string): boolean {
if (!visible || request != generation) return false
if (getSession()?.getString('token') == token) return true
generation++; reset()
if (getSession() == null) login(); else reloadAfterAccountChange()
return false
}
const openNotice = (item: Notice): void => {
markRead(item.id)
uni.showModal({ title: item.title, content: item.body, showCancel: false, confirmText: '知道了' })
async function load(append: boolean) {
if (!visible || loading.value || loadingMore.value || marking.value || opening.value.length > 0) return
const token = getSession()?.getString('token') ?? ''
if (token.length == 0) { reset(); login(); return }
const request = append ? generation : ++generation
if (append) { if (nextBeforeId.value.length == 0) return; loadingMore.value = true; paginationError.value = '' }
else { reset(); loading.value = true }
try {
const result = await fetchMessages(tab.value, append ? nextBeforeId.value : '', append ? snapshotId.value : '')
if (!current(request, token)) return
notices.value = append ? notices.value.concat(result.items) : result.items
snapshotId.value = result.snapshotId; nextBeforeId.value = result.nextBeforeId; unreadCount.value = result.unreadCount
} catch (err) {
if (current(request, token)) {
const message = err instanceof Error ? err.message : '消息加载失败,请重试'
if (append) paginationError.value = message; else error.value = message
}
const markAllRead = (): void => {
notices.value.forEach((item: Notice): void => { markRead(item.id) })
} finally { if (request == generation) { loading.value = false; loadingMore.value = false } }
}
function reload() { load(false) }
reloadAfterAccountChange = reload
const more = () => { load(true) }
// 自动触底失败后保留提示,由用户手动重试,避免滚动事件连续覆盖错误。
const autoMore = () => { if (paginationError.value.length == 0) more() }
const changeTab = (type: string) => { if (type == tab.value || marking.value || opening.value.length > 0) return; generation++; reset(); tab.value = type; reload() }
const openNotice = async (item: UserMessage) => {
if (opening.value.length > 0 || marking.value || loading.value || loadingMore.value) return
const token = getSession()?.getString('token') ?? ''; if (token.length == 0) { login(); return }
const request = generation; opening.value = item.id; actionError.value = ''
try {
const detail = await fetchMessage(item.id)
if (!current(request, token)) return
const unread = await readMessage(item.id)
if (!current(request, token)) return
item.isRead = true; unreadCount.value = unread
uni.showModal({ title: detail.item.title, content: detail.item.content + (detail.targetUnavailable ? '\n关联评估已不可用,可从融资档案查看其他记录。' : ''),
showCancel: detail.targetUrl.length > 0, cancelText: '关闭', confirmText: detail.targetLabel.length > 0 ? detail.targetLabel : '知道了',
success: (result) => { if (result.confirm && detail.targetUrl.length > 0 && current(request, token)) uni.navigateTo({ url: detail.targetUrl }) } })
} catch (err) { if (current(request, token)) actionError.value = err instanceof Error ? err.message : '消息读取失败,请重试' }
finally { if (request == generation) opening.value = '' }
}
const markAllRead = async () => {
if (marking.value || opening.value.length > 0 || loading.value || loadingMore.value || snapshotId.value.length == 0 || snapshotId.value == '0') return
const token = getSession()?.getString('token') ?? ''; if (token.length == 0) { login(); return }
const request = generation; marking.value = true; actionError.value = ''
try {
const unread = await readAllMessages(snapshotId.value)
if (!current(request, token)) return
notices.value.forEach((item: UserMessage) => { item.isRead = true }); unreadCount.value = unread
} catch (err) { if (current(request, token)) actionError.value = err instanceof Error ? err.message : '标记失败,请重试' }
finally { if (request == generation) marking.value = false }
}
onShow(() => { visible = true; generation++; reset(); reload() })
onHide(() => { visible = false; generation++; reset() })
onUnload(() => { visible = false; generation++; reset() })
</script>
<style>
@@ -67,6 +119,12 @@
.summary-text { color: #66758a; font-size: 23rpx; }
.read-all { padding: 14rpx 0 14rpx 24rpx; }
.read-all-text { color: #0868f7; font-size: 23rpx; }
.disabled-text { color: #a1adbd; }
.message-state { padding: 56rpx 18rpx; align-items: center; }
.state-title { color: #52657e; font-size: 27rpx; text-align: center; line-height: 42rpx; }
.state-note { color: #8291a5; font-size: 23rpx; margin-top: 18rpx; text-align: center; line-height: 36rpx; }
.state-action { color: #0868f7; font-size: 25rpx; padding: 18rpx; }
.action-error { color: #b64d35; font-size: 23rpx; line-height: 36rpx; padding: 16rpx 0; }
.notice-list { background-color: #ffffff; border-radius: 18rpx; padding: 0 20rpx; }
.notice { padding: 26rpx 0; border-bottom: 1rpx solid #e8eef6; flex-direction: row; align-items: center; }
.last-notice { border-bottom-width: 0; }
+20 -14
View File
@@ -2,7 +2,9 @@
<view class="page page-white" :style="pageStyle()">
<AppHeader title="新闻详情" />
<scroll-view scroll-y :style="contentStyle(96, false)">
<view v-if="available" class="detail-content">
<view v-if="loading" class="empty-content"><text class="empty-copy">正在加载资讯…</text></view>
<view v-else-if="error.length > 0" class="empty-content"><text class="empty-title">{{ error }}</text><button class="primary-button empty-button" @click="reload">重新加载</button></view>
<view v-else-if="available" class="detail-content">
<text class="detail-category">{{ article.category }}</text><text class="detail-title">{{ article.title }}</text>
<text class="detail-source">{{ article.source }}</text><text class="detail-meta">{{ article.date }} · {{ article.readingTime }}阅读</text>
<image class="detail-image" :src="article.image" mode="aspectFill"></image>
@@ -17,21 +19,25 @@
<script setup lang="uts">
import { usePageLayout } from '@/services/layout.uts'
import { newsArticles, findNewsArticle, NewsArticle } from '@/packages/content/services/content.uts'
import { fetchArticle, emptyArticle, NewsArticle } from '@/services/content.uts'
import AppHeader from '@/components/AppHeader.uvue'
const { contentStyle, pageStyle } = usePageLayout()
const article = ref<NewsArticle>(newsArticles[0])
const available = ref(true)
onLoad((options) => {
if (options == null) return
const id = options['id'] == null ? '' : options['id'] as string
const legacyTitle = options['title'] == null ? '' : options['title'] as string
if (id == '' && legacyTitle == '') return
const selected = findNewsArticle(id, legacyTitle)
available.value = selected != null
if (selected != null) article.value = selected
})
const goNews = (): void => { uni.redirectTo({ url: '/packages/content/news/news' }) }
const article = ref<NewsArticle>(emptyArticle()); const available = ref(false); const loading = ref(false); const error = ref('')
let id = ''; let visible = false; let generation = 0
async function reload() {
const request = ++generation; loading.value = true; available.value = false; error.value = ''
try {
const result = await fetchArticle(id)
if (!visible || request != generation) return
article.value = result; available.value = true
} catch (err) { if (visible && request == generation) error.value = err instanceof Error ? err.message : '资讯加载失败,请重试' }
finally { if (request == generation) loading.value = false }
}
onLoad((options) => { id = options?.['id'] ?? '' })
onShow(() => { visible = true; reload() })
onHide(() => { visible = false; generation++ })
onUnload(() => { visible = false; generation++ })
const goNews = () => { uni.redirectTo({ url: '/packages/content/news/news' }) }
</script>
<style>
+37 -9
View File
@@ -15,11 +15,16 @@
</view>
</view>
<view class="lesson-section">
<view class="section-head"><text class="section-title">{{ activeTopic == '' ? '精选课程' : activeTopicTitle }}</text><view class="all-lessons" @click="activeTopic = ''"><text class="lesson-count">{{ activeTopic == '' ? lessons.length + ' 门课程' : '查看全部' }}</text></view></view>
<view class="section-head"><text class="section-title">{{ activeTopic == '' ? '精选课程' : activeTopicTitle }}</text><view class="all-lessons" @click="selectTopic('')"><text class="lesson-count">{{ activeTopic == '' ? total + ' 门课程' : '查看全部' }}</text></view></view>
<view v-if="loading" class="content-state"><text>正在加载课程…</text></view>
<view v-else-if="error.length > 0" class="content-state"><text>{{ error }}</text><text class="content-retry" @click="reload">重新加载</text></view>
<view v-else-if="lessons.length == 0" class="content-state"><text>暂无课程</text></view>
<view class="lesson-card" v-for="item in lessons" :key="item.id" @click="openLesson(item.id)">
<image :src="item.image" mode="aspectFill" class="lesson-card-image"></image>
<view class="lesson-copy"><text class="lesson-title">{{ item.title }}</text><text class="lesson-desc">{{ item.desc }}</text><view class="lesson-meta"><text class="lesson-duration">{{ item.duration }}</text><text class="lesson-tag">{{ item.tag }}</text></view></view>
</view>
<text v-if="moreError.length > 0" class="content-state">{{ moreError }}</text>
<text v-if="hasMore" class="content-retry" @click="more">{{ loadingMore ? '正在加载…' : '加载更多' }}</text>
</view>
</view>
</scroll-view>
@@ -28,7 +33,7 @@
<script setup lang="uts">
import { usePageLayout } from '@/services/layout.uts'
import { financeLessons, FinanceLesson } from '@/packages/content/services/content.uts'
import { fetchLessons, FinanceLesson } from '@/services/content.uts'
import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue'
const { contentStyle, pageStyle } = usePageLayout()
@@ -38,17 +43,40 @@
{ id: 'business', title: '小微经营融资', desc: '梳理经营材料与融资成本', icon: 'building' },
{ id: 'documents', title: '材料准备清单', desc: '按申请场景,逐项检查资料', icon: 'file-text' }
]
const activeTopic = ref('')
const activeTopic = ref(''); const lessons = ref<FinanceLesson[]>([]); const total = ref(0); const hasMore = ref(false)
const loading = ref(false); const loadingMore = ref(false); const error = ref(''); const moreError = ref('')
let generation = 0; let visible = false; let page = 1
const activeTopicTitle = computed((): string => topics.find((item: LessonTopic): boolean => item.id == activeTopic.value)?.title ?? '精选课程')
const lessons = computed((): FinanceLesson[] => {
if (activeTopic.value == '') return financeLessons
return financeLessons.filter((item: FinanceLesson): boolean => item.topics.includes(activeTopic.value))
})
const selectTopic = (id: string): void => { activeTopic.value = activeTopic.value == id ? '' : id }
const openLesson = (id: string): void => { uni.navigateTo({ url: '/packages/content/lesson/lesson?id=' + id }) }
async function load(append: boolean) {
if (!visible || loading.value || loadingMore.value || (append && !hasMore.value)) return
const request = append ? generation : ++generation
if (append) { loadingMore.value = true; moreError.value = '' }
else { loading.value = true; error.value = ''; lessons.value = []; total.value = 0; hasMore.value = false; page = 1 }
try {
const result = await fetchLessons(activeTopic.value, append ? page + 1 : 1)
if (!visible || request != generation) return
lessons.value = append ? lessons.value.concat(result.items) : result.items
total.value = result.total; hasMore.value = result.hasMore
if (append) page++
} catch (err) {
if (visible && request == generation) {
const message = err instanceof Error ? err.message : '课程加载失败,请重试'
if (append) moreError.value = message; else error.value = message
}
} finally { if (request == generation) { loading.value = false; loadingMore.value = false } }
}
function reload() { load(false) }
const more = () => { load(true) }
const selectTopic = (id: string) => { generation++; loading.value = false; loadingMore.value = false; activeTopic.value = activeTopic.value == id ? '' : id; reload() }
const openLesson = (id: string) => { uni.navigateTo({ url: '/packages/content/lesson/lesson?id=' + id }) }
onShow(() => { visible = true; loading.value = false; loadingMore.value = false; reload() })
onHide(() => { visible = false; generation++ })
onUnload(() => { visible = false; generation++ })
</script>
<style>
.content-state { padding: 30rpx 0; color: #66758a; font-size: 25rpx; line-height: 40rpx; }
.content-retry { color: #0868f7; font-size: 25rpx; padding: 22rpx 0; }
.classroom-content { padding: 24rpx 28rpx 48rpx; }
.classroom-hero { border-radius: 20rpx; background-color: #e8f2ff; flex-direction: row; overflow: hidden; align-items: center; }
.hero-copy { flex: 1; padding: 28rpx 12rpx 28rpx 26rpx; }
+144 -11
View File
@@ -2,8 +2,9 @@
<view class="page" :style="pageStyle()">
<AppHeader title="帮助与反馈" />
<view class="help-body" :style="contentStyle(96, false)">
<view class="help-tabs"><view :class="tab == 'faq' ? 'help-tab active-tab' : 'help-tab'" @click="changeTab('faq')"><text :class="tab == 'faq' ? 'tab-text active-text' : 'tab-text'">常见问题</text></view><view :class="tab == 'feedback' ? 'help-tab active-tab' : 'help-tab'" @click="changeTab('feedback')"><text :class="tab == 'feedback' ? 'tab-text active-text' : 'tab-text'">意见反馈</text></view></view>
<scroll-view scroll-y class="help-scroll">
<view class="help-content">
<view v-if="tab == 'faq'" class="help-content">
<text class="help-title">常见问题</text><text class="help-description">关于账户和融资使用,您可能想了解这些</text>
<view v-if="loading" class="help-state"><text class="state-text">正在加载…</text></view>
<view v-else-if="errorMessage.length > 0" class="help-state"><text class="state-text">{{ errorMessage }}</text><button class="retry-button" @click="load">重新加载</button></view>
@@ -18,6 +19,30 @@
</view>
</template>
</view>
<view v-else class="feedback-content">
<template v-if="loggedIn">
<text class="section-title">提交反馈</text>
<textarea v-model="draft" :disabled="sending" :maxlength="255" class="feedback-input" placeholder="请填写遇到的问题或建议" />
<view class="feedback-count"><text class="count-text">{{ draft.length }}/255</text></view>
<text v-if="submitError.length > 0" class="feedback-error">{{ submitError }}</text>
<text v-if="submitted" class="feedback-success">反馈已提交</text>
<button class="primary-button feedback-submit" :disabled="sending || draft.trim().length == 0" @click="send">{{ sending ? '提交中…' : (submitError.length > 0 ? '重试提交' : '提交反馈') }}</button>
<view class="feedback-list-head"><text class="section-title">我的反馈</text><text class="refresh-link" @click="reloadFeedback">刷新</text></view>
<view v-if="feedbackLoading" class="feedback-state"><text class="state-text">正在加载…</text></view>
<view v-else-if="feedbackError.length > 0" class="feedback-state"><text class="state-text">{{ feedbackError }}</text><button class="retry-button" @click="reloadFeedback">重新加载</button></view>
<view v-else-if="records.length == 0" class="feedback-state"><text class="state-text">暂无反馈记录</text></view>
<template v-else>
<view v-for="item in records" :key="item.id" class="feedback-item">
<view class="feedback-meta"><text :class="item.status == 'resolved' ? 'feedback-status replied-status' : 'feedback-status'">{{ statusLabel(item.status) }}</text><text class="feedback-time">{{ item.createdAt }}</text></view>
<text class="feedback-copy" :selectable="true">{{ item.content }}</text>
<view v-if="item.reply.length > 0" class="feedback-reply"><text class="reply-label">处理回复</text><text class="reply-copy" :selectable="true">{{ item.reply }}</text><text class="reply-time">{{ item.updatedAt }}</text></view>
</view>
<text v-if="moreError.length > 0" class="feedback-error">{{ moreError }}</text>
<text v-if="hasMore" class="feedback-more" @click="loadMore">{{ loadingMore ? '正在加载…' : (moreError.length > 0 ? '重试加载' : '加载更多') }}</text>
</template>
</template>
<view v-else class="help-state"><text class="state-text">登录后可提交和查看反馈</text><button class="retry-button" @click="login">去登录</button></view>
</view>
</scroll-view>
<view class="contact-bar" :style="bottomStyle(20)"><button class="primary-button contact-button" @click="contact">联系客服</button></view>
</view>
@@ -27,14 +52,20 @@
import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { helpCategories, HelpQuestion, fetchHelpQuestions, contactCustomerService } from '@/packages/content/services/help.uts'
import { FeedbackItem, feedbackKey, submitFeedback, fetchFeedback } from '@/packages/content/services/feedback.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
type HelpGroup = { category: string, items: HelpQuestion[] }
const questions = ref<HelpQuestion[]>([])
const expanded = ref('')
const loading = ref(false)
const errorMessage = ref('')
let alive = true
const tab = ref('faq'); const loggedIn = ref(false); const draft = ref(''); const sending = ref(false)
const submitError = ref(''); const submitted = ref(false); const records = ref<FeedbackItem[]>([])
const feedbackLoading = ref(false); const loadingMore = ref(false); const feedbackError = ref(''); const moreError = ref(''); const hasMore = ref(false)
let visible = false; let generation = 0; let faqRequest = 0; let listRequest = 0; let page = 0
let owner = ''; let requestKey = ''; let requestText = ''
const groups = computed((): HelpGroup[] => {
const results: HelpGroup[] = []
helpCategories.forEach((category: string) => {
@@ -44,20 +75,100 @@
return results
})
const toggle = (id: string) => { expanded.value = expanded.value == id ? '' : id }
const contact = () => { contactCustomerService() }
const load = async () => {
if (loading.value) return
loading.value = true; errorMessage.value = ''
try { const items = await fetchHelpQuestions(); if (alive) questions.value = items }
catch (_error) { if (alive) errorMessage.value = '暂时无法加载帮助内容,请稍后重试' }
finally { if (alive) loading.value = false }
const statusLabel = (status: string): string => {
if (status == 'pending') return '待处理'
if (status == 'processing') return '处理中'
if (status == 'resolved') return '已回复'
if (status == 'closed') return '已关闭'
return '待核对'
}
onLoad(() => { load() })
onUnload(() => { alive = false })
const contact = () => { contactCustomerService() }
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeHelp=1' }) }
const clearFeedback = () => {
draft.value = ''; records.value = []; submitError.value = ''; submitted.value = false; sending.value = false
feedbackLoading.value = false; loadingMore.value = false; feedbackError.value = ''; moreError.value = ''; hasMore.value = false
requestKey = ''; requestText = ''; page = 0; listRequest++
}
let reloadAfterAccountChange: () => void = () => {}
// 离页或切换账户后,旧提交和列表响应不得回填私有反馈。
function current(ticket: number, token: string): boolean {
if (!visible || ticket != generation) return false
const sessionToken = getSession()?.getString('token') ?? ''
if (sessionToken == token) return true
generation++; clearFeedback(); owner = sessionToken; loggedIn.value = owner.length > 0
if (loggedIn.value) reloadAfterAccountChange()
return false
}
const load = async () => {
if (!visible || loading.value) return
const ticket = ++faqRequest
loading.value = true; errorMessage.value = ''
try { const items = await fetchHelpQuestions(); if (visible && ticket == faqRequest) questions.value = items }
catch (_error) { if (visible && ticket == faqRequest) errorMessage.value = '暂时无法加载帮助内容,请稍后重试' }
finally { if (ticket == faqRequest) loading.value = false }
}
async function loadFeedback(append: boolean) {
if (!visible || !loggedIn.value || feedbackLoading.value || loadingMore.value || sending.value) return
const token = getSession()?.getString('token') ?? ''; const ticket = generation
if (!current(ticket, owner) || token.length == 0 || (append && !hasMore.value)) return
const request = ++listRequest; const nextPage = append ? page + 1 : 1
if (append) { loadingMore.value = true; moreError.value = '' }
else { feedbackLoading.value = true; feedbackError.value = ''; moreError.value = ''; records.value = []; hasMore.value = false }
try {
const result = await fetchFeedback(nextPage)
if (!current(ticket, token) || request != listRequest) return
if (append) { result.items.forEach((item: FeedbackItem) => { if (!records.value.some((old: FeedbackItem): boolean => old.id == item.id)) records.value.push(item) }) }
else records.value = result.items
page = result.page; hasMore.value = result.hasMore
} catch (error) {
if (current(ticket, token) && request == listRequest) {
const message = error instanceof Error ? error.message : '反馈记录加载失败,请重试'
if (append) moreError.value = message; else feedbackError.value = message
}
} finally { if (ticket == generation && request == listRequest) { feedbackLoading.value = false; loadingMore.value = false } }
}
function reloadFeedback() { loadFeedback(false) }
reloadAfterAccountChange = reloadFeedback
const loadMore = () => { loadFeedback(true) }
const changeTab = (value: string) => {
tab.value = value
if (value == 'feedback') reloadFeedback(); else if (questions.value.length == 0) load()
}
const send = async () => {
if (sending.value || draft.value.trim().length == 0) return
const token = getSession()?.getString('token') ?? ''; const ticket = generation
if (!current(ticket, owner)) return
if (token.length == 0) { login(); return }
const content = draft.value.trim()
// 同一段内容的网络失败重试复用请求键;成功后才清空,避免生成重复反馈。
if (requestKey.length == 0 || requestText != content) { requestKey = feedbackKey(); requestText = content }
listRequest++; feedbackLoading.value = false; loadingMore.value = false
sending.value = true; submitError.value = ''; submitted.value = false
try {
await submitFeedback(requestText, requestKey)
if (!current(ticket, token)) return
draft.value = ''; requestKey = ''; requestText = ''; submitted.value = true
} catch (error) { if (current(ticket, token)) submitError.value = error instanceof Error ? error.message : '提交失败,请重试' }
finally { if (ticket == generation) { sending.value = false; if (submitted.value) reloadFeedback() } }
}
watch(draft, () => { if (draft.value.length > 0) submitted.value = false })
onLoad((options) => { if (options?.['feedback'] == '1') tab.value = 'feedback' })
onShow(() => {
visible = true; generation++; const token = getSession()?.getString('token') ?? ''
if (token != owner) clearFeedback()
owner = token; loggedIn.value = token.length > 0; load(); if (tab.value == 'feedback') reloadFeedback()
})
onHide(() => { visible = false; generation++; faqRequest++; listRequest++; loading.value = false; feedbackLoading.value = false; loadingMore.value = false; sending.value = false; records.value = [] })
onUnload(() => { visible = false; generation++; faqRequest++; clearFeedback() })
</script>
<style>
.help-body { overflow: hidden; }
.help-scroll { flex: 1; min-height: 0; }
.help-tabs { flex-shrink: 0; flex-direction: row; height: 88rpx; padding: 0 30rpx; background-color: #ffffff; border-bottom: 1rpx solid #e8eef6; }
.help-tab { flex: 1; align-items: center; justify-content: center; border-bottom: 4rpx solid transparent; }
.active-tab { border-bottom-color: #0868f7; }
.tab-text { color: #66758a; font-size: 28rpx; }
.active-text { color: #0868f7; font-weight: 600; }
.help-content { padding: 32rpx 30rpx 40rpx; }
.help-title { color: #23344e; font-size: 38rpx; line-height: 54rpx; font-weight: 700; }
.help-description { color: #738198; font-size: 25rpx; line-height: 40rpx; margin-top: 12rpx; }
@@ -77,4 +188,26 @@
.retry-button { color: #0868f7; background-color: #e8f2ff; font-size: 26rpx; margin-top: 26rpx; padding: 0 30rpx; height: 76rpx; line-height: 76rpx; border-radius: 12rpx; }
.contact-bar { flex-shrink: 0; padding: 20rpx 30rpx; border-top: 1rpx solid #e8eef6; background-color: #ffffff; }
.contact-button { margin: 0; }
.feedback-content { padding: 30rpx 30rpx 48rpx; }
.section-title { font-size: 30rpx; line-height: 44rpx; color: #29394f; font-weight: 600; }
.feedback-input { width: 100%; height: 220rpx; margin-top: 22rpx; padding: 22rpx; background-color: #ffffff; border: 1rpx solid #dbe3ee; border-radius: 12rpx; color: #29394f; font-size: 27rpx; line-height: 42rpx; }
.feedback-count { align-items: flex-end; padding: 12rpx 0; }
.count-text { color: #738198; font-size: 23rpx; line-height: 34rpx; }
.feedback-submit { margin: 18rpx 0 0; }
.feedback-error { color: #b64d35; font-size: 25rpx; line-height: 40rpx; padding: 12rpx 0; }
.feedback-success { color: #258064; font-size: 25rpx; line-height: 40rpx; }
.feedback-list-head { flex-direction: row; align-items: center; justify-content: space-between; margin-top: 42rpx; padding-top: 26rpx; border-top: 1rpx solid #dbe3ee; }
.refresh-link { font-size: 25rpx; color: #0868f7; padding: 10rpx 0 10rpx 24rpx; }
.feedback-state { padding: 52rpx 16rpx; align-items: center; }
.feedback-item { padding: 26rpx 0; border-bottom: 1rpx solid #dbe3ee; }
.feedback-meta { flex-direction: row; align-items: center; justify-content: space-between; flex-wrap: wrap; }
.feedback-status { color: #9b6c23; font-size: 24rpx; line-height: 38rpx; }
.replied-status { color: #258064; }
.feedback-time { color: #738198; font-size: 22rpx; line-height: 38rpx; }
.feedback-copy { color: #29394f; font-size: 27rpx; line-height: 42rpx; margin-top: 14rpx; }
.feedback-reply { border-left: 4rpx solid #8ebba9; padding-left: 20rpx; margin-top: 22rpx; }
.reply-label { color: #258064; font-size: 24rpx; line-height: 36rpx; font-weight: 600; }
.reply-copy { color: #52657e; font-size: 25rpx; line-height: 42rpx; margin-top: 8rpx; }
.reply-time { color: #738198; font-size: 22rpx; line-height: 36rpx; margin-top: 10rpx; }
.feedback-more { text-align: center; color: #0868f7; font-size: 25rpx; line-height: 40rpx; padding: 26rpx; }
</style>
+62 -36
View File
@@ -2,16 +2,19 @@
<view class="page" :style="pageStyle()">
<AppHeader title="课程详情" />
<scroll-view scroll-y :scroll-into-view="scrollTarget" :style="contentStyle(96, false)">
<view v-if="available" class="lesson-detail-content">
<view v-if="loading" class="empty-content"><text class="empty-copy">正在加载课程…</text></view>
<view v-else-if="error.length > 0" class="empty-content"><text class="empty-title">{{ error }}</text><button class="primary-button empty-button" @click="reload">重新加载</button></view>
<view v-else-if="available" class="lesson-detail-content">
<image class="lesson-cover" :src="lesson.image" mode="aspectFill"></image>
<text class="lesson-detail-title">{{ lesson.title }}</text>
<view class="lesson-detail-meta"><text class="lesson-tag">{{ lesson.tag }}</text><text class="lesson-duration">{{ lesson.duration }} · {{ lesson.chapters.length }} 节图文</text></view>
<text class="lesson-intro">{{ lesson.intro }}</text>
<view class="progress-head"><text class="progress-label">学习进度</text><text class="progress-value">已完成 {{ completedCount }}/{{ lesson.chapters.length }} 节</text></view>
<view class="lesson-progress"><view class="progress-fill" :style="'width:' + progress + '%;'"></view></view>
<view v-if="progressError.length > 0" class="progress-error"><text>{{ progressError }}</text><text class="progress-retry" @click="reload">刷新课程</text></view>
<button class="primary-button start-learning" @click="start">{{ completedCount == 0 ? '开始学习' : (completedCount == lesson.chapters.length ? '回顾课程' : '继续学习') }}</button>
<text class="chapter-heading">课程目录</text>
<view class="chapter-card" v-for="(item, index) in lesson.chapters" :key="item.title" :id="'chapter-' + index">
<view class="chapter-card" v-for="(item, index) in lesson.chapters" :key="item.id" :id="'chapter-' + index">
<view class="chapter-row" @click="toggleChapter(index)">
<view :class="completed[index] ? 'chapter-index completed-index' : 'chapter-index'"><text :class="completed[index] ? 'index-text completed-text' : 'index-text'">{{ index + 1 }}</text></view>
<view class="chapter-copy"><text class="chapter-title">{{ item.title }}</text><text :class="completed[index] ? 'chapter-state completed-state' : 'chapter-state'">{{ completed[index] ? '已完成 · 可再次阅读' : '点击展开阅读' }}</text></view>
@@ -19,7 +22,7 @@
</view>
<view v-if="activeIndex == index" class="chapter-content">
<text class="chapter-body">{{ item.body }}</text>
<button :class="completed[index] ? 'chapter-done chapter-already-done' : 'chapter-done'" :disabled="completed[index]" @click="completeChapter(index)">{{ completed[index] ? '本节已完成' : '我已读完,完成本节' }}</button>
<button :class="completed[index] ? 'chapter-done chapter-already-done' : 'chapter-done'" :disabled="completed[index] || saving" @click="completeChapter(index)">{{ completed[index] ? '本节已完成' : (saving ? '正在保存…' : (loggedIn ? '我已读完,完成本节' : '登录后记录学习进度')) }}</button>
</view>
</view>
</view>
@@ -29,54 +32,77 @@
</template>
<script setup lang="uts">
import { getUserStorage, setUserStorage } from '@/services/storage.uts'
import { getSession } from '@/services/api.uts'
import { usePageLayout } from '@/services/layout.uts'
import { financeLessons, findFinanceLesson, FinanceLesson } from '@/packages/content/services/content.uts'
import { fetchLesson, fetchLessonProgress, completeLessonChapter, emptyLesson, FinanceLesson } from '@/services/content.uts'
import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue'
const { contentStyle, pageStyle } = usePageLayout()
const lesson = ref<FinanceLesson>(financeLessons[0])
const available = ref(true)
const completed = ref<boolean[]>([])
const activeIndex = ref(-1)
const scrollTarget = ref('')
const storageKey = (index: number): string => 'finance.lesson.' + lesson.value.id + '.chapter.' + index
const restoreProgress = (): void => {
completed.value = lesson.value.chapters.map((_item, index): boolean => getUserStorage(storageKey(index)) == true)
const lesson = ref<FinanceLesson>(emptyLesson()); const available = ref(false); const loading = ref(false); const error = ref('')
const completedIds = ref<string[]>([]); const activeIndex = ref(-1); const scrollTarget = ref('')
const progressError = ref(''); const loggedIn = ref(false); const saving = ref(false)
let id = ''; let visible = false; let generation = 0
let reloadAfterAccountChange: () => void = () => {}
const tokenNow = (): string => getSession()?.getString('token') ?? ''
function current(request: number, token: string): boolean {
if (!visible || request != generation) return false
if (tokenNow() == token) return true
// 学习内容公开,但完成记录只属于当前账户;切换后立即清除旧进度。
generation++; completedIds.value = []; saving.value = false; reloadAfterAccountChange()
return false
}
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))
onLoad((options) => {
if (options != null) {
const id = options['id'] == null ? '' : options['id'] as string
const legacyTitle = options['title'] == null ? '' : options['title'] as string
if (id != '' || legacyTitle != '') {
const selected = findFinanceLesson(id, legacyTitle)
available.value = selected != null
if (selected != null) lesson.value = selected
async function reload() {
const request = ++generation; const token = tokenNow()
loading.value = true; available.value = false; error.value = ''; progressError.value = ''; completedIds.value = []; saving.value = false
loggedIn.value = token.length > 0
try {
const result = await fetchLesson(id)
if (!current(request, token)) return
lesson.value = result; activeIndex.value = -1; available.value = true
if (token.length > 0) {
try {
const state = await fetchLessonProgress(id)
if (!current(request, token)) return
if (state.lessonVersion != result.lessonVersion) { progressError.value = '课程已更新,请刷新后继续学习'; return }
completedIds.value = state.completedChapterIds
} catch (err) { if (current(request, token)) progressError.value = err instanceof Error ? err.message : '进度加载失败,请重试' }
}
} catch (err) { if (current(request, token)) error.value = err instanceof Error ? err.message : '课程加载失败,请重试' }
finally { if (request == generation) loading.value = false }
}
restoreProgress()
})
const toggleChapter = (index: number): void => { activeIndex.value = activeIndex.value == index ? -1 : index }
const completeChapter = (index: number): void => {
setUserStorage(storageKey(index), true)
const next = completed.value.slice()
next[index] = true
completed.value = next
reloadAfterAccountChange = () => { reload() }
const completed = computed((): boolean[] => lesson.value.chapters.map((item): boolean => completedIds.value.includes(item.id)))
const completedCount = computed((): number => completedIds.value.length)
const progress = computed((): number => lesson.value.chapters.length > 0 ? Math.round(completedCount.value * 100 / lesson.value.chapters.length) : 0)
const toggleChapter = (index: number) => { activeIndex.value = activeIndex.value == index ? -1 : index }
const completeChapter = async (index: number) => {
if (!available.value || saving.value || index < 0 || index >= lesson.value.chapters.length) return
const token = tokenNow()
if (token.length == 0) { uni.navigateTo({ url: '/packages/account/login/login?resumeLesson=1&lessonId=' + id }); return }
if (progressError.value.length > 0) { uni.showToast({ title: '请先刷新课程和进度', icon: 'none' }); return }
const request = generation; saving.value = true; progressError.value = ''
try {
const state = await completeLessonChapter(id, lesson.value.chapters[index].id, lesson.value.lessonVersion)
if (current(request, token)) completedIds.value = state.completedChapterIds
} catch (err) { if (current(request, token)) progressError.value = err instanceof Error ? err.message : '进度保存失败,请重试' }
finally { if (request == generation) saving.value = false }
}
const start = (): void => {
const start = () => {
let nextIndex = completed.value.findIndex((value: boolean): boolean => !value)
if (nextIndex < 0) nextIndex = 0
activeIndex.value = nextIndex
scrollTarget.value = ''
activeIndex.value = nextIndex; scrollTarget.value = ''
nextTick(() => { scrollTarget.value = 'chapter-' + nextIndex })
}
const goClassroom = (): void => { uni.redirectTo({ url: '/packages/content/classroom/classroom' }) }
const goClassroom = () => { uni.redirectTo({ url: '/packages/content/classroom/classroom' }) }
onLoad((options) => { id = options?.['id'] ?? '' })
onShow(() => { visible = true; reload() })
onHide(() => { visible = false; generation++; completedIds.value = []; saving.value = false })
onUnload(() => { visible = false; generation++; completedIds.value = [] })
</script>
<style>
.progress-error { color: #b64d35; font-size: 24rpx; line-height: 40rpx; margin-top: 16rpx; }
.progress-retry { color: #0868f7; padding: 14rpx 0; }
.lesson-detail-content { padding: 24rpx 28rpx 48rpx; }
.lesson-cover { width: 100%; height: 260rpx; border-radius: 18rpx; }
.lesson-detail-title { color: #172640; font-size: 36rpx; line-height: 50rpx; font-weight: 700; margin-top: 24rpx; }
+43 -12
View File
@@ -2,21 +2,26 @@
<view class="page" :style="pageStyle()">
<AppHeader title="新闻" :back="false" />
<view class="news-tabs">
<view v-for="item in tabs" :key="item" :class="tab == item ? 'news-tab active-tab' : 'news-tab'" @click="tab = item"><text :class="tab == item ? 'tab-text active-text' : 'tab-text'">{{ item }}</text></view>
<view v-for="item in tabs" :key="item" :class="tab == item ? 'news-tab active-tab' : 'news-tab'" @click="changeTab(item)"><text :class="tab == item ? 'tab-text active-text' : 'tab-text'">{{ item }}</text></view>
</view>
<scroll-view scroll-y :style="contentStyle(314, false)">
<view class="news-content">
<view v-if="tab == '推荐'" class="headline" @click="openArticle(headline.id)">
<view class="headline-copy"><text class="headline-tag">政策观察</text><text class="headline-title">{{ headline.title }}</text><text class="headline-date">{{ headline.date }}</text></view>
<view v-if="tab == '推荐' && articles.length > 0" class="headline" @click="openArticle(headline.id)">
<view class="headline-copy"><text class="headline-tag">{{ headline.category }}</text><text class="headline-title">{{ headline.title }}</text><text class="headline-date">{{ headline.date }}</text></view>
<image class="headline-art" :src="headline.image" mode="aspectFill"></image>
</view>
<view class="list-head"><text class="list-title">{{ tab == '推荐' ? '最新资讯' : tab }}</text><text class="list-count">{{ filteredArticles.length }} 篇</text></view>
<view class="list-head"><text class="list-title">{{ tab == '推荐' ? '最新资讯' : tab }}</text><text class="list-count">{{ total }} 篇</text></view>
<view v-if="loading" class="content-state"><text>正在加载资讯…</text></view>
<view v-else-if="error.length > 0" class="content-state"><text>{{ error }}</text><text class="content-retry" @click="reload">重新加载</text></view>
<view v-else-if="articles.length == 0" class="content-state"><text>暂无资讯</text></view>
<view class="article-list">
<view class="article" v-for="(item, index) in filteredArticles" :key="item.id" :class="index == filteredArticles.length - 1 ? 'last-article' : ''" @click="openArticle(item.id)">
<view class="article-copy"><text class="article-title">{{ item.title }}</text><text class="article-summary">{{ item.summary }}</text><text class="article-meta">{{ item.category }} · {{ item.date }}</text></view>
<image class="article-art" :src="item.image" mode="aspectFill"></image>
</view>
</view>
<text v-if="moreError.length > 0" class="content-state">{{ moreError }}</text>
<text v-if="hasMore" class="content-retry" @click="more">{{ loadingMore ? '正在加载…' : '加载更多' }}</text>
</view>
</scroll-view>
<BottomNav active="news" />
@@ -25,21 +30,47 @@
<script setup lang="uts">
import { usePageLayout } from '@/services/layout.uts'
import { newsArticles, NewsArticle } from '@/packages/content/services/content.uts'
import { fetchArticles, emptyArticle, NewsArticle } from '@/services/content.uts'
import AppHeader from '@/components/AppHeader.uvue'
import BottomNav from '@/components/BottomNav.uvue'
const { contentStyle, pageStyle } = usePageLayout()
const tabs: string[] = ['推荐', '企业融资', '个人融资', '政策解读']
const tab = ref('推荐')
const headline: NewsArticle = newsArticles[0]
const filteredArticles = computed((): NewsArticle[] => {
if (tab.value == '推荐') return newsArticles.filter((item: NewsArticle): boolean => item.id != headline.id)
return newsArticles.filter((item: NewsArticle): boolean => item.category == tab.value)
})
const openArticle = (id: string): void => { uni.navigateTo({ url: '/packages/content/article/article?id=' + id }) }
const tab = ref('推荐'); const articles = ref<NewsArticle[]>([]); const total = ref(0); const hasMore = ref(false)
const loading = ref(false); const loadingMore = ref(false); const error = ref(''); const moreError = ref('')
let generation = 0; let visible = false; let page = 1
const headline = computed((): NewsArticle => articles.value.length > 0 ? articles.value[0] : emptyArticle())
const filteredArticles = computed((): NewsArticle[] => tab.value == '推荐' ? articles.value.slice(1) : articles.value)
async function load(append: boolean) {
if (!visible || loading.value || loadingMore.value || (append && !hasMore.value)) return
const request = append ? generation : ++generation
if (append) { loadingMore.value = true; moreError.value = '' }
else { loading.value = true; error.value = ''; articles.value = []; total.value = 0; hasMore.value = false; page = 1 }
try {
const result = await fetchArticles(tab.value == '推荐' ? '' : tab.value, append ? page + 1 : 1)
// 换分类或离页后,旧列表响应不能覆盖新选择。
if (!visible || request != generation) return
articles.value = append ? articles.value.concat(result.items) : result.items
total.value = result.total; hasMore.value = result.hasMore
if (append) page++
} catch (err) {
if (visible && request == generation) {
const message = err instanceof Error ? err.message : '资讯加载失败,请重试'
if (append) moreError.value = message; else error.value = message
}
} finally { if (request == generation) { loading.value = false; loadingMore.value = false } }
}
function reload() { load(false) }
const more = () => { load(true) }
const changeTab = (value: string) => { if (value == tab.value) return; generation++; loading.value = false; loadingMore.value = false; tab.value = value; reload() }
const openArticle = (id: string) => { uni.navigateTo({ url: '/packages/content/article/article?id=' + id }) }
onShow(() => { visible = true; loading.value = false; loadingMore.value = false; reload() })
onHide(() => { visible = false; generation++ })
onUnload(() => { visible = false; generation++ })
</script>
<style>
.content-state { padding: 30rpx 0; color: #66758a; font-size: 25rpx; line-height: 40rpx; }
.content-retry { color: #0868f7; font-size: 25rpx; padding: 22rpx 0; }
.news-tabs { height: 88rpx; flex-direction: row; align-items: center; background-color: #ffffff; border-bottom: 1rpx solid #e8eef6; padding: 0 22rpx; }
.news-tab { flex: 1; height: 88rpx; justify-content: center; align-items: center; border-bottom: 4rpx solid transparent; }
.active-tab { border-bottom-color: #0868f7; }
+26 -10
View File
@@ -22,11 +22,13 @@
<view v-else-if="errorMessage.length > 0" class="state-panel"><text class="state-title">搜索暂时不可用</text><text class="state-copy">{{ errorMessage }}</text><button class="retry-button" @click="search">重新搜索</button></view>
<view v-else-if="results.length == 0" class="state-panel"><view class="empty-symbol"><image class="empty-icon" src="/static/icons/search.svg" mode="aspectFit" /></view><text class="state-title">没有找到相关内容</text><text class="state-copy">试试更简短的关键词,或切换搜索分类</text></view>
<view v-else>
<text class="result-count">找到 {{ results.length }} 条相关内容</text>
<text class="result-count">找到 {{ total }} 条相关内容</text>
<view class="result-card" v-for="item in results" :key="item.type + ':' + item.id" @click="openResult(item)">
<view class="result-copy"><view class="result-heading"><text :class="item.type == 'news' ? 'result-kind' : 'result-kind lesson-kind'">{{ item.type == 'news' ? '新闻' : '融资课堂' }}</text><text class="result-category">{{ item.category }}</text></view><text class="result-title">{{ item.title }}</text><text class="result-summary">{{ item.summary }}</text><text class="result-meta">{{ item.meta }}</text></view>
<image v-if="item.image.length > 0" class="result-image" :src="item.image" mode="aspectFill" />
</view>
<text v-if="moreError.length > 0" class="state-copy">{{ moreError }}</text>
<button v-if="hasMore" class="retry-button" @click="more">{{ loadingMore ? '正在加载…' : '加载更多' }}</button>
</view>
</view>
</scroll-view>
@@ -48,29 +50,43 @@
const errorMessage = ref('')
const results = ref<ContentSearchResult[]>([])
const listVersion = ref(0)
const total = ref(0); const hasMore = ref(false); const loadingMore = ref(false); const moreError = ref('')
let page = 1
let requestVersion = 0
const resetResults = () => { requestVersion++; results.value = []; searched.value = false; loading.value = false; errorMessage.value = ''; listVersion.value++ }
const resetResults = () => { requestVersion++; results.value = []; total.value = 0; hasMore.value = false; loadingMore.value = false; moreError.value = ''; searched.value = false; loading.value = false; errorMessage.value = ''; listVersion.value++ }
const clearSearch = () => { keyword.value = ''; resetResults() }
const search = async () => {
async function load(append: boolean) {
if (append && (loading.value || loadingMore.value || !hasMore.value)) return
keyword.value = keyword.value.trim()
if (keyword.value.length == 0) { clearSearch(); return }
uni.hideKeyboard()
const currentRequest = ++requestVersion
searched.value = true; loading.value = true; errorMessage.value = ''; results.value = []; listVersion.value++
const currentRequest = append ? requestVersion : ++requestVersion
if (append) { loadingMore.value = true; moreError.value = '' }
else { searched.value = true; loading.value = true; loadingMore.value = false; errorMessage.value = ''; moreError.value = ''; results.value = []; total.value = 0; hasMore.value = false; page = 1; listVersion.value++ }
try {
const items = await searchContent(keyword.value, activeType.value)
const result = await searchContent(keyword.value, activeType.value, append ? page + 1 : 1)
// 输入、切换分类或退出页面后,忽略旧请求,避免结果被覆盖。
if (currentRequest == requestVersion) results.value = items
} catch (_error) {
if (currentRequest == requestVersion) errorMessage.value = '请检查网络连接后重试'
} finally { if (currentRequest == requestVersion) loading.value = false }
if (currentRequest != requestVersion) return
results.value = append ? results.value.concat(result.items) : result.items
total.value = result.total; hasMore.value = result.hasMore
if (append) page++
} catch (err) {
if (currentRequest == requestVersion) {
const message = err instanceof Error ? err.message : '请检查网络连接后重试'
if (append) moreError.value = message; else errorMessage.value = message
}
} finally { if (currentRequest == requestVersion) { loading.value = false; loadingMore.value = false } }
}
const search = () => { load(false) }
const more = () => { load(true) }
const selectType = (type: string) => { if (activeType.value == type) return; activeType.value = type; if (searched.value) search() }
const searchWord = (word: string) => { keyword.value = word; search() }
const openResult = (item: ContentSearchResult) => {
const route = item.type == 'news' ? '/packages/content/article/article' : '/packages/content/lesson/lesson'
uni.navigateTo({ url: route + '?id=' + encodeURIComponent(item.id) })
}
onShow(() => { if (searched.value) search() })
onHide(() => { requestVersion++; loading.value = false; loadingMore.value = false })
onUnload(() => { requestVersion++ })
</script>
+1 -104
View File
@@ -1,104 +1 @@
export type ContentSection = { title: string, body: string }
export type NewsArticle = {
id: string, title: string, summary: string, category: string,
date: string, source: string, readingTime: string, image: string, sections: ContentSection[]
}
export type FinanceLesson = {
id: string, title: string, desc: string, duration: string, tag: string,
image: string, intro: string, topics: string[], chapters: ContentSection[]
}
// 新闻与课程独立建模,接入后端时可分别替换为对应接口。
export const newsArticles: NewsArticle[] = [
{
id: 'inclusive-finance', title: '普惠金融政策,企业申请前应关注什么?',
summary: '看清适用主体、申请条件与材料要求,找到适合企业的支持方式。', category: '政策解读',
date: '2026-09-08', source: 'AI融资编辑整理', readingTime: '4 分钟', image: '/packages/content/static/news-city.png',
sections: [
{ title: '先核对政策适用范围', body: '普惠金融支持措施通常对经营主体、企业规模和经营所在地设有适用条件。阅读政策时,先核对发文机构、生效时间与适用范围,再确认企业是否满足基本要求。' },
{ title: '区分贷款、担保与贴息', body: '信贷产品、融资担保和财政贴息的申请渠道与审核流程可能不同。获得政策支持不等于贷款必然获批,额度、利率和期限仍需以金融机构的实际审核结果为准。' },
{ title: '从官方渠道核实并准备资料', body: '建议通过当地政府、金融管理部门或银行官方网站核实细则,准备营业执照、经营流水、纳税记录与资金用途说明。本文提供阅读方法,不代表具体政策承诺。' }
]
},
{
id: 'personal-credit', title: '申请个人信用融资,先核对这三项信息',
summary: '征信记录、收入稳定性与现有负债,是申请前的重点检查项。', category: '个人融资',
date: '2026-09-05', source: 'AI融资编辑整理', readingTime: '3 分钟', image: '/static/mobile/images/news-calculator.png',
sections: [
{ title: '查看信用记录是否准确', body: '通过官方渠道查询个人信用报告,核对账户、还款记录与查询记录。发现错误信息时,应通过正规异议渠道处理,不轻信付费“修复征信”承诺。' },
{ title: '整理可以核验的收入证明', body: '根据职业情况整理工资流水、经营收款、社保或纳税记录,确保填写的收入与材料一致。不同机构认可的材料范围有所不同,请以申请页面或机构说明为准。' },
{ title: '量力确定额度与月供', body: '将现有贷款和信用卡应还金额纳入每月支出,再评估新增还款是否可持续。避免为尝试额度而短期内集中向多家机构提交申请。' }
]
},
{
id: 'loan-comparison', title: '比较融资产品时,别只看宣传利率',
summary: '年化利率、附加费用与还款方式,需要放在一起比较。', category: '个人融资',
date: '2026-09-03', source: 'AI融资编辑整理', readingTime: '3 分钟', image: '/packages/content/static/news-city.png',
sections: [
{ title: '先统一利率口径', body: '日利率、月利率与年化利率并不是同一个口径。比较时应查看机构披露的年化利率及计算方式,尤其注意“低至”利率所附的适用条件。' },
{ title: '将费用计入总成本', body: '核对合同是否包含担保费、服务费等支出,以及提前还款是否有额外费用。应以正式合同、还款计划和实际审核结果为准。' },
{ title: '让还款安排匹配现金流', body: '等额本息、等额本金、先息后本的还款节奏各不相同。除了总利息,还应关注每期应还金额和到期本金,避免因最后一期集中还款造成压力。' }
]
},
{
id: 'business-documents', title: '小微企业融资:常见材料如何提前整理',
summary: '按主体、经营和用途三类整理资料,减少补充沟通。', category: '企业融资',
date: '2026-08-30', source: 'AI融资编辑整理', readingTime: '4 分钟', image: '/packages/content/static/news-business.png',
sections: [
{ title: '主体资料保持一致', body: '核对营业执照、实际经营地址、法定代表人及联系人信息。涉及经营主体或股权变更时,准备相应说明,避免不同材料之间的信息不一致。' },
{ title: '整理持续经营的证明', body: '经营流水、纳税记录、采购或销售合同可以帮助机构了解业务情况。按照时间顺序整理,并区分经营性收入、借款与其他往来款。' },
{ title: '说明资金用途与还款来源', body: '将资金需求对应到采购、周转或设备投入等具体用途,并准备与之匹配的凭证。不同机构要求不同,正式提交前请核对其最新材料清单。' }
]
}
]
export const financeLessons: FinanceLesson[] = [
{
id: 'credit-basics', title: '个人信用申请准备课', desc: '用三步梳理信用、收入与负债', duration: '6 分钟',
tag: '个人融资', image: '/static/mobile/images/news-calculator.png', topics: ['credit', 'documents'],
intro: '从自己的实际情况出发,建立一份清晰的申请前检查清单。完成每一节阅读后,可手动记录学习进度。',
chapters: [
{ title: '读懂自己的信用记录', body: '先通过官方渠道获取个人信用报告,核对账户数量、当前负债与还款记录。\n\n对确有错误的信息,按官方异议流程申请核查;真实的不良记录不能通过付费承诺消除。\n\n本节练习:记录需要核对的账户,并确认最近的还款安排。' },
{ title: '整理收入与还款支出', body: '把稳定收入、必要生活支出及已有月供分别列出,观察每月可用于新增还款的资金是否充足。\n\n奖金、临时收入与不确定的经营回款不宜直接视为稳定收入。预留应急资金也有助于避免还款压力。\n\n本节练习:用最近几个月的实际流水,估算可持续的月供范围。' },
{ title: '核对材料并选择申请渠道', body: '按照目标机构的清单准备身份证明、收入证明及相关授权,检查姓名、工作与收入信息是否一致。\n\n通过银行或持牌机构的官方渠道申请,不向不明个人账户支付所谓“保证通过”的费用。审批结果以机构审核为准。\n\n本节练习:列出已准备和待补充的材料,提交前再核对一次。' }
]
},
{
id: 'business-checklist', title: '小微经营融资准备课', desc: '把经营情况整理成完整材料清单', duration: '8 分钟',
tag: '企业融资', image: '/packages/content/static/news-business.png', topics: ['business', 'documents'],
intro: '围绕真实经营、资金用途和还款来源,分步骤整理企业融资所需的信息。具体材料要求以申请机构为准。',
chapters: [
{ title: '核对企业主体与经营信息', body: '核对营业执照、经营地址、法定代表人与实际控制人信息,确保材料在有效期内。\n\n介绍企业业务时,可以说明主要产品、客户类型和经营年限。信息以真实经营情况为准,无需为了申请夸大规模。\n\n本节练习:核对主体证件与申请表中各项信息是否一致。' },
{ title: '归集流水与纳税记录', body: '按照申请机构要求的时间范围整理经营账户流水、纳税记录和业务合同。将经营收入与借款、股东往来等款项区分开。\n\n对季节性波动或较大的单笔交易,准备可核验的业务说明,帮助机构理解经营现金流。\n\n本节练习:按月份归档资料,标记需要补充解释的收支。' },
{ title: '写清资金用途与还款计划', body: '将需求拆解为采购、订单周转或设备投入等实际用途,并评估资金回笼时间。\n\n融资期限应尽量与经营周期相匹配,避免依赖不确定的下一笔借款偿还到期本金。\n\n本节练习:写下所需金额、预计使用时间与还款来源。' },
{ title: '检查授权范围与提交清单', body: '提交前阅读数据授权说明,确认授权对象、用途、范围与期限。不要将账户密码、短信验证码交给不明中介。\n\n将所有附件与机构清单逐项比对,保留提交记录,后续按机构通知补充材料。\n\n本节练习:完成材料清单核对,并确认使用的是官方申请渠道。' }
]
},
{
id: 'loan-cost', title: '利率与还款方式入门', desc: '看懂总成本,选好还款节奏', duration: '5 分钟',
tag: '基础知识', image: '/packages/content/static/news-policy.png', topics: ['credit', 'business'],
intro: '把利率、期限和还款方式放在一起理解,学会用统一口径比较融资方案。',
chapters: [
{ title: '比较利率先看口径', body: '比较产品时,优先查看正式披露的年化利率及计算方式。日息、月息或“每万元每天几元”的宣传表达不能直接互相比高低。\n\n实际适用利率通常与审核结果相关,页面显示的最低利率不代表每个人都能获得。\n\n本节练习:找到一份产品说明中的年化利率、适用条件与费用说明。' },
{ title: '认识不同的还款节奏', body: '等额本息通常每期还款额相近;等额本金的本金部分相同,随着剩余本金减少,利息逐步降低。\n\n先息后本需要在到期时集中偿还本金。每月压力较小并不意味着到期压力也小,应提前规划本金来源。\n\n本节练习:对照还款计划,找出金额最大的一期。' },
{ title: '把费用与现金流一起算', body: '核对担保费、服务费与提前还款规则,将必要费用计入总成本。比较方案时还要统一金额与期限。\n\n选择能被自身现金流持续覆盖的还款安排,不只追求额度或最长的期限。最终以合同约定和实际审核结果为准。\n\n本节练习:记录总还款额、每期应还金额及提前还款条件。' }
]
}
]
export function findNewsArticle(id: string, legacyTitle: string): NewsArticle | null {
let articleId = id
if (legacyTitle == 'headline' || legacyTitle == '普惠金融新政解读' || legacyTitle == '普惠金融新政解读:小微企业迎来融资利好') articleId = 'inclusive-finance'
if (legacyTitle == '如何提高个人贷款通过率') articleId = 'personal-credit'
if (legacyTitle == '银行最新贷款产品汇总') articleId = 'loan-comparison'
if (legacyTitle == '小微企业融资材料清单') articleId = 'business-documents'
return newsArticles.find((item: NewsArticle): boolean => item.id == articleId || item.title == legacyTitle) ?? null
}
export function findFinanceLesson(id: string, legacyTitle: string): FinanceLesson | null {
let lessonId = id
if (id == '0' || legacyTitle == '个人信用提升' || legacyTitle == '如何提高个人贷款通过率') lessonId = 'credit-basics'
if (id == '1' || legacyTitle == '小微经营融资' || legacyTitle == '材料准备清单' || legacyTitle == '小微企业融资材料清单') lessonId = 'business-checklist'
if (id == '2' || legacyTitle == '读懂贷款利率与还款方式') lessonId = 'loan-cost'
return financeLessons.find((item: FinanceLesson): boolean => item.id == lessonId || item.title == legacyTitle) ?? null
}
export { ContentSection, NewsArticle, FinanceLesson, fetchArticles, fetchArticle, fetchLessons, fetchLesson, emptyArticle, emptyLesson } from '@/services/content.uts'
+31
View File
@@ -0,0 +1,31 @@
import { requestJson } from '@/services/api.uts'
export type FeedbackItem = { id: string, content: string, status: string, reply: string, createdAt: string, updatedAt: string }
export type FeedbackPage = { items: FeedbackItem[], page: number, total: number, hasMore: boolean }
function feedbackView(data: UTSJSONObject): FeedbackItem {
const id = data.getString('id') ?? ''; const content = data.getString('content') ?? ''
if (id.length == 0) throw new Error('反馈响应不完整')
return { id, content, status: data.getString('status') ?? 'pending', reply: data.getString('reply') ?? '',
createdAt: data.getString('createdAt') ?? '', updatedAt: data.getString('updatedAt') ?? '' }
}
export function feedbackKey(): string { return 'feedback_' + Date.now().toString() + '_' + Math.floor(Math.random() * 1000000000).toString() }
export function feedbackStatus(status: string): string {
if (status == 'pending') return '待处理'
if (status == 'processing') return '处理中'
if (status == 'resolved') return '已回复'
if (status == 'closed') return '已关闭'
return '待核对'
}
export async function submitFeedback(content: string, requestId: string): Promise<FeedbackItem> {
const attachmentIds: string[] = []
return feedbackView(await requestJson('/feedback', 'POST', { content, requestId, attachmentIds }))
}
export async function fetchFeedback(page: number): Promise<FeedbackPage> {
const data = await requestJson('/feedback', 'GET', { page: page.toString(), pageSize: '20' })
const rows = data['items'] as UTSJSONObject[] | null
if (rows == null) throw new Error('反馈记录加载失败')
return { items: rows.map((item: UTSJSONObject): FeedbackItem => feedbackView(item)), page: data.getNumber('page') ?? page,
total: data.getNumber('total') ?? 0, hasMore: data.getBoolean('hasMore') ?? false }
}
+1 -13
View File
@@ -1,20 +1,8 @@
import { API_DEMO_ENABLED, API_BASE_URL, requestJson } from '@/services/api.uts'
import { requestJson } from '@/services/api.uts'
export type HelpQuestion = { id: string, category: string, question: string, answer: string }
export const helpCategories: string[] = ['账户使用', '融资评估', '资料与隐私']
const questions: HelpQuestion[] = [
{ id: 'profile', category: '账户使用', question: '如何修改头像和昵称?', answer: '在“我的”页面,点击顶部头像、昵称和手机号所在的区域进入修改资料。更换头像或编辑昵称后,点击“保存资料”即可。昵称不会替代融资档案中的实名信息。' },
{ id: 'password', category: '账户使用', question: '忘记登录密码怎么办?', answer: '可先使用手机号验证码登录,再进入“我的 → 账户安全 → 验证码重设密码”。如果记得当前密码,也可以选择“修改登录密码”。修改成功后需要重新登录。' },
{ id: 'phone', category: '账户使用', question: '如何更换绑定手机号?', answer: '进入“我的 → 账户安全 → 绑定手机号”,填写新手机号,并分别验证当前号码与新号码。换绑成功后使用新号码登录。如果原号码无法接收短信,需要通过官方客服核验身份。' },
{ id: 'assessment', category: '融资评估', question: '如何开始融资评估?', answer: '从首页选择个人或企业融资入口,依次填写基本信息、收入或经营信息、资产与负债情况,再核对并确认。已有资料可在“我的融资档案”中继续完善。' },
{ id: 'result', category: '融资评估', question: '评估结果是否代表贷款已经获批?', answer: '不代表。评估结果用于帮助了解融资需求和方案,不是金融机构的授信或放款承诺。实际额度、利率、审批与放款结果,以相关机构的审核和合同为准。' },
{ id: 'amount', category: '融资评估', question: '资产与负债信息应该怎么填写?', answer: '信用贷款笔数、信用卡张数填写非负整数,没有则填 0。欠款余额、月还款金额、信用卡额度与已使用额度统一以万元填写,最多保留六位小数,最高 99999.999999 万元;房产、车辆按实际情况选择“有”或“无”。' },
{ id: 'files', category: '资料与隐私', question: '选择资料或勾选授权会立即上传、查询吗?', answer: '当前选择的资料仅用于本机预览,勾选授权仅保存您的选择,不会自动上传资料或查询外部数据。使用相关服务前,请确认授权对象、用途和范围。' },
{ id: 'draft', category: '资料与隐私', question: '退出登录后,填写的资料还在吗?', answer: '退出登录会结束当前会话,本机保存的评估草稿仍会保留。个人与企业评估分别保存。清理应用数据、更换设备或卸载应用可能使本地资料丢失,请勿将本机草稿视为云端备份。' }
]
export async function fetchHelpQuestions(): Promise<HelpQuestion[]> {
if (API_DEMO_ENABLED) return questions.slice()
const response = await requestJson('/help/faqs', 'GET', {})
const items = response['items'] as UTSJSONObject[] | null
if (items == null) throw new Error('帮助内容加载失败')
+1 -48
View File
@@ -1,48 +1 @@
import { API_DEMO_ENABLED, API_BASE_URL, requestJson } from '@/services/api.uts'
import { newsArticles, financeLessons, ContentSection } from './content.uts'
export type ContentSearchResult = {
id: string, type: string, title: string, summary: string, category: string, meta: string, image: string
}
const sectionText = (sections: ContentSection[]): string => sections.map((item: ContentSection): string => item.title + ' ' + item.body).join(' ')
const matches = (text: string, words: string[]): boolean => words.every((word: string): boolean => text.toLowerCase().includes(word))
const readText = (item: UTSJSONObject, key: string): string => typeof item[key] == 'string' ? item[key] as string : ''
// type: all / news / lesson。接入后端时返回相同类型的 items,详情沿用内容 ID。
export async function searchContent(keyword: string, type: string): Promise<ContentSearchResult[]> {
const query = keyword.trim().replace(/\s+/g, ' ')
const results: ContentSearchResult[] = []
if (query.length == 0) return results
if (!API_DEMO_ENABLED) {
const response = await requestJson('/content/search', 'GET', { keyword: query, type })
const items = response['items'] as UTSJSONObject[] | null
if (items == null) throw new Error('搜索结果加载失败,请重试')
items.forEach((item: UTSJSONObject) => {
const kind = readText(item, 'type'); const id = readText(item, 'id'); const title = readText(item, 'title')
if ((kind == 'news' || kind == 'lesson') && (type == 'all' || type == kind) && id.length > 0 && title.length > 0) {
const result: ContentSearchResult = { id, type: kind, title, summary: readText(item, 'summary'), category: readText(item, 'category'), meta: readText(item, 'meta'), image: readText(item, 'image') }
results.push(result)
}
})
return results
}
const words = query.toLowerCase().split(' ')
if (type == 'all' || type == 'news') {
newsArticles.forEach((item) => {
if (matches(item.title + ' ' + item.summary + ' ' + item.category + ' ' + sectionText(item.sections), words)) {
const result: ContentSearchResult = { id: item.id, type: 'news', title: item.title, summary: item.summary, category: item.category, meta: item.date, image: item.image }
results.push(result)
}
})
}
if (type == 'all' || type == 'lesson') {
financeLessons.forEach((item) => {
if (matches(item.title + ' ' + item.desc + ' ' + item.tag + ' ' + item.intro + ' ' + sectionText(item.chapters), words)) {
const result: ContentSearchResult = { id: item.id, type: 'lesson', title: item.title, summary: item.desc, category: item.tag, meta: item.duration + ' · ' + item.chapters.length + ' 节', image: item.image }
results.push(result)
}
})
}
return results
}
export { ContentSearchResult, SearchPage, searchContent } from '@/services/content.uts'
+33 -6
View File
@@ -3,8 +3,9 @@
<view class="safe" :style="statusStyle(80)"></view>
<text class="analyzing-title">{{ title }}</text>
<text class="analyzing-sub">正式提交记录 · {{ assessmentId }}</text>
<view class="orbital"><view class="orbit orbit-one"></view><view class="orbit orbit-two"></view><view class="ai-core"><image src="/static/mobile/images/robot-cutout.png" mode="aspectFit" class="ai-core-image"></image></view></view>
<view class="orbital"><view class="orbit orbit-one"></view><view class="orbit orbit-two"></view><view class="scan-ring" :style="scanRingStyle()"></view><view class="ai-core"><image src="/static/mobile/images/robot-cutout.png" mode="aspectFit" class="ai-core-image"></image></view></view>
<text class="status-message">{{ message }}</text>
<view v-if="processing" class="analysis-pulse"><view class="pulse-dot" :style="pulseDotStyle()"></view><text class="stage-label">{{ stageLabel }}</text><text class="pulse-dots" :style="pulseDotsStyle()">···</text></view>
<text v-if="error.length > 0" class="status-message">{{ error }}</text>
<text v-if="canRetry" class="view-now" @click="retry">{{ retrying ? '正在提交…' : '重新分析' }}</text>
<text v-if="succeeded" class="view-now" @click="report">查看分析报告</text>
@@ -20,9 +21,25 @@
const { statusStyle, pageStyle } = usePageLayout()
const title = ref('正在读取评估状态')
const message = ref(''); const error = ref(''); const assessmentId = ref('')
const canRetry = ref(false); const succeeded = ref(false); const retrying = ref(false)
const canRetry = ref(false); const succeeded = ref(false); const retrying = ref(false); const processing = ref(false); const stageLabel = ref('正在准备分析')
let retryKey = ''; let retryTask = ''; let retryOwner = ''
let timer = 0; let visible = false; let generation = 0
let timer = 0; let motionTimer = 0; let visible = false; let generation = 0
// 动效只反映服务端 processing 状态,不用视觉帧推断分析进度或完成时间。
const motionPhase = ref(0)
const phaseRadians = (): number => motionPhase.value * Math.PI * 2
const scanRingStyle = (): string => {
if (!processing.value) return 'opacity:0;transform:scale(.78);'
const phase = phaseRadians()
const progress = (Math.sin(phase) + 1) / 2
return 'opacity:' + (0.12 + progress * 0.62).toString() + ';transform:scale(' + (0.78 + progress * 0.52).toString() + ');'
}
const pulseDotStyle = (): string => 'opacity:' + (0.5 + ((Math.sin(phaseRadians()) + 1) / 2) * 0.5).toString() + ';transform:scale(' + (0.82 + ((Math.sin(phaseRadians()) + 1) / 2) * 0.26).toString() + ');'
const pulseDotsStyle = (): string => 'opacity:' + (0.25 + ((Math.sin(phaseRadians() - Math.PI / 2) + 1) / 2) * 0.75).toString() + ';'
const stopMotion = () => { if (motionTimer != 0) { clearInterval(motionTimer); motionTimer = 0 }; motionPhase.value = 0 }
const startMotion = () => {
stopMotion()
motionTimer = setInterval(() => { if (!processing.value) { stopMotion(); return }; motionPhase.value = (motionPhase.value + 0.035) % 1 }, 80)
}
async function loadStatus(): Promise<void> {
clearTimeout(timer)
if (assessmentId.value.length == 0) { title.value = '缺少评估记录'; message.value = '请从融资档案选择已提交的记录。'; return }
@@ -35,6 +52,9 @@
const result = await fetchAssessmentStatus(assessmentId.value)
if (!visible || request != generation || getSession()?.getString('token') != owner) return
canRetry.value = result.canRetry; succeeded.value = result.status == 'succeeded'
const nextProcessing = result.status == 'processing'
if (nextProcessing != processing.value) { processing.value = nextProcessing; if (nextProcessing) startMotion(); else stopMotion() }
stageLabel.value = result.stage == 'queued' ? '正在排队,分析任务即将开始' : '正在整理资料,生成分析报告'
if (retryTask != result.taskId || retryOwner != owner) { retryKey = ''; retryTask = result.taskId; retryOwner = owner }
title.value = result.statusLabel
message.value = result.message.length > 0 ? result.message : (result.status == 'processing' ? (result.stage == 'queued' ? '资料已保存,等待分析。您可以稍后从档案查看。' : '正在分析自填资料,您可以稍后从档案查看。') : '分析已完成,可查看本次报告。')
@@ -43,7 +63,7 @@
} catch (err) {
if (visible && request == generation && getSession()?.getString('token') == owner) {
// 查询失败不能沿用旧任务的重试/报告按钮,也不能把网络错误当成分析失败。
canRetry.value = false; succeeded.value = false; message.value = '暂时无法获取最新状态,请刷新状态。已提交的资料无需重新填写。'
canRetry.value = false; succeeded.value = false; processing.value = false; stopMotion(); message.value = '暂时无法获取最新状态,请刷新状态。已提交的资料无需重新填写。'
title.value = '状态加载失败'; error.value = err instanceof Error ? err.message : '请稍后重试'
}
}
@@ -70,8 +90,8 @@
const archive = () => { uni.reLaunch({ url: '/packages/finance/archive/archive?assessmentId=' + encodeURIComponent(assessmentId.value) }) }
onLoad((options) => { if (options != null) assessmentId.value = options['assessmentId'] ?? '' })
onShow(() => { visible = true; loadStatus() })
onHide(() => { visible = false; generation++; clearTimeout(timer); canRetry.value = false; succeeded.value = false; message.value = ''; error.value = '' })
onUnload(() => { visible = false; generation++; clearTimeout(timer) })
onHide(() => { visible = false; generation++; clearTimeout(timer); stopMotion(); canRetry.value = false; succeeded.value = false; processing.value = false; message.value = ''; error.value = '' })
onUnload(() => { visible = false; generation++; clearTimeout(timer); stopMotion() })
</script>
<style>
@@ -84,7 +104,9 @@
.orbit-one { width: 350rpx; height: 350rpx; }
.orbit-two { width: 280rpx; height: 280rpx; border-color: rgba(57, 235, 255, .5); }
.ai-core { width: 220rpx; height: 220rpx; border-radius: 50%; background-color: rgba(10, 102, 247, .32); box-shadow: 0 0 70rpx rgba(0, 165, 255, .48); align-items: center; justify-content: center; }
.scan-ring { transition: transform 80ms linear, opacity 80ms linear; }
.ai-core-image { width: 185rpx; height: 185rpx; border-radius: 50%; }
.scan-ring { position: absolute; width: 210rpx; height: 210rpx; border: 2rpx solid rgba(70, 216, 255, .85); border-radius: 50%; opacity: 0; }
.analysis-list { margin-top: 30rpx; width: 390rpx; }
.analysis-item { flex-direction: row; align-items: center; margin-top: 23rpx; font-size: 26rpx; color: #d8e6ff; }
.analysis-label { font-size: 27rpx; color: #d8e6ff; }
@@ -92,6 +114,11 @@
.analysis-check { width: 38rpx; height: 38rpx; border-radius: 50%; border: 2rpx solid #4c73a7; align-items: center; justify-content: center; margin-right: 17rpx; }
.analysis-check.done { background-color: #158aff; border-color: #41d7ff; color: white; }
.status-message { margin: 32rpx; color: #d8e6ff; font-size: 28rpx; line-height: 44rpx; text-align: center; }
.analysis-pulse { flex-direction: row; align-items: center; justify-content: center; margin-top: -12rpx; min-height: 34rpx; }
.pulse-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background-color: #43ddff; box-shadow: 0 0 16rpx rgba(67, 221, 255, .9); }
.stage-label { margin-left: 12rpx; color: #9fc8ff; font-size: 23rpx; }
.pulse-dots { width: 40rpx; margin-left: 2rpx; color: #63dcff; font-size: 24rpx; letter-spacing: 4rpx; }
.estimate { margin-top: 64rpx; color: #8faeda; font-size: 22rpx; }
.view-now { margin-top: 28rpx; color: #62c8ff; font-size: 23rpx; }
.pulse-dot, .pulse-dots { transition: transform 80ms linear, opacity 80ms linear; }
</style>
+1 -9
View File
@@ -79,16 +79,8 @@
type AdviceGroup = { id: string, label: string, loading: boolean, error: string, items: MatchView[] }
const { contentStyle, pageStyle, statusStyle, bottomStyle, navigationTitleStyle, navigationActionStyle } = usePageLayout()
const reportTitleStyle = (): string => {
// 小程序标题须同时避让PDF按钮和右侧胶囊,窄屏也不能覆盖操作入口。
// #ifdef MP-WEIXIN
const windowWidth = uni.getWindowInfo().windowWidth
const capsule = uni.getMenuButtonBoundingClientRect()
const reserved = capsule.left > 0 && capsule.left < windowWidth ? windowWidth - capsule.left + 8 : 104
return 'left:50px;right:' + (reserved + 40) + 'px;'
// #endif
// #ifndef MP-WEIXIN
// 报告标题使用对称左右边距,保持屏幕中心对齐并避开微信胶囊。
return navigationTitleStyle()
// #endif
}
const tabs: ReportTab[] = [{ id: 'overview', label: '评估概况' }, { id: 'matches', label: '匹配结果' }, { id: 'improvements', label: '改善建议' }]
const revisionId = ref(''); const assessmentId = ref(''); const tab = ref('overview'); const loading = ref(false); const error = ref(''); const routeError = ref('')
+35 -4
View File
@@ -35,7 +35,10 @@
<view class="section-head"><text class="section-title">我的融资方案</text><view class="more-link" @click="go('/packages/finance/matches/matches')"><text class="more">查看全部</text><AppIcon name="chevron-right" :size="24" /></view></view>
<view class="plan-card" @click="go('/packages/finance/matches/matches')"><view class="plan-main"><text class="plan-name">信用融资方案</text><text class="plan-meta">了解额度、利率与申请条件</text></view><view class="score"><text class="score-number">3</text><text class="score-label">可查看方案</text></view></view>
<view class="section-head"><text class="section-title">融资课堂</text><view class="more-link" @click="go('/packages/content/classroom/classroom')"><text class="more">进入课堂</text><AppIcon name="chevron-right" :size="24" /></view></view>
<view class="news-preview" @click="go('/packages/content/classroom/classroom')"><view class="news-copy"><text class="news-title">如何提高个人贷款通过率</text><text class="news-meta">信用管理 · 6分钟阅读</text></view><image class="news-art" src="/static/mobile/images/news-calculator.png" mode="aspectFill"></image></view>
<view v-if="contentLoading" class="home-content-state"><text>正在加载课程…</text></view>
<view v-else-if="contentError.length > 0" class="home-content-state" @click="loadRecommendations"><text>{{ contentError }}</text><text class="home-content-retry">重新加载</text></view>
<view v-else-if="recommendedLessons.length == 0" class="home-content-state"><text>暂无推荐课程</text></view>
<view v-else class="news-preview" @click="go('/packages/content/lesson/lesson?id=' + recommendedLessons[0].id)"><view class="news-copy"><text class="news-title">{{ recommendedLessons[0].title }}</text><text class="news-meta">{{ recommendedLessons[0].tag }} · {{ recommendedLessons[0].duration }}</text></view><image class="news-art" :src="recommendedLessons[0].image" mode="aspectFill"></image></view>
</view>
</scroll-view>
<BottomNav active="home" />
@@ -43,7 +46,9 @@
</template>
<script setup lang="uts">
import { getUserStorage } from '@/services/storage.uts'
import { getSession } from '@/services/api.uts'
import { fetchUnreadMessages } from '@/services/messages.uts'
import { fetchContentHome, FinanceLesson } from '@/services/content.uts'
import { usePageLayout } from '@/services/layout.uts'
const { contentStyle, statusStyle, pageStyle, navigationStyle, navigationActionStyle, navigationBrandStyle } = usePageLayout()
@@ -54,13 +59,39 @@
onLoad(() => {
if (consumeInitialLaunch()) uni.reLaunch({ url: '/packages/account/index/index' })
})
const hasUnread = ref(true)
onShow(() => { hasUnread.value = getUserStorage('finance.notice.read.assessment-guide') != true })
const hasUnread = ref(false)
const recommendedLessons = ref<FinanceLesson[]>([])
const contentLoading = ref(false); const contentError = ref('')
let contentGeneration = 0
async function loadRecommendations() {
const request = ++contentGeneration
contentLoading.value = true; contentError.value = ''; recommendedLessons.value = []
try {
const result = await fetchContentHome()
if (request == contentGeneration) recommendedLessons.value = result.lessons
} catch (_) { if (request == contentGeneration) contentError.value = '推荐课程加载失败' }
finally { if (request == contentGeneration) contentLoading.value = false }
}
let unreadGeneration = 0
onShow(async () => {
loadRecommendations()
const request = ++unreadGeneration; const token = getSession()?.getString('token') ?? ''
hasUnread.value = false
if (token.length == 0) return
try {
const unread = await fetchUnreadMessages()
if (request == unreadGeneration && getSession()?.getString('token') == token) hasUnread.value = unread > 0
} catch (_) { /* 首页其他入口仍可用,消息页提供明确的加载错误与重试。 */ }
})
onHide(() => { unreadGeneration++; contentGeneration++; hasUnread.value = false })
onUnload(() => { unreadGeneration++; contentGeneration++ })
const go = (url: string) => { uni.navigateTo({ url: url }) }
const openEntry = (type: string) => { go('/packages/finance/entry/entry?type=' + type) }
</script>
<style>
.home-content-state { padding: 24rpx 0; font-size: 25rpx; line-height: 40rpx; color: #66758a; }
.home-content-retry { color: #0868f7; margin-top: 12rpx; }
.home-header { background-color: #ffffff; }
.safe { }
.home-bar { height: 104rpx; padding: 0 28rpx; flex-direction: row; align-items: center; justify-content: space-between; position: relative; }
+1 -1
View File
@@ -2,7 +2,7 @@
// 小程序体验版连接远程测试服;切回本机联调时改为 development。
export const APP_ENVIRONMENT: string = 'test' as string
const DEVELOPMENT_API_URL = 'http://127.0.0.1/api/v1'
const TEST_API_URL = 'https://aitest.ychysg.cn/api/v1'
const TEST_API_URL = 'https://airz.hqling.cloud/api/v1'
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
+58
View File
@@ -0,0 +1,58 @@
import { API_BASE_URL, requestJson } from '@/services/api.uts'
export type ContentSection = { id: string, title: string, body: string }
export type NewsArticle = { id: string, title: string, summary: string, category: string, date: string, source: string, readingTime: string, image: string, sections: ContentSection[] }
export type FinanceLesson = { id: string, title: string, desc: string, duration: string, tag: string, image: string, intro: string, topics: string[], chapters: ContentSection[], chapterCount: number, lessonVersion: string }
export type ArticlePage = { items: NewsArticle[], total: number, hasMore: boolean }
export type LessonPage = { items: FinanceLesson[], total: number, hasMore: boolean }
export type LessonState = { lessonVersion: string, completedChapterIds: string[] }
export type ContentHome = { lessons: FinanceLesson[], articles: NewsArticle[] }
export type ContentSearchResult = { id: string, type: string, title: string, summary: string, category: string, meta: string, image: string }
export type SearchPage = { items: ContentSearchResult[], total: number, hasMore: boolean }
function imageUrl(value: string): string {
// 随包图片保留本地路径,后台上传的封面从接口站点读取。
if (value.startsWith('/uploads/')) return API_BASE_URL.replace('/api/v1', '') + value
return value
}
function sections(value: UTSJSONObject[]): ContentSection[] {
return value.map((item: UTSJSONObject): ContentSection => ({ id: item.getString('id') ?? '', title: item.getString('title') ?? '', body: item.getString('body') ?? '' }))
}
function articleFrom(data: UTSJSONObject): NewsArticle {
return { id: data.getString('id') ?? '', title: data.getString('title') ?? '', summary: data.getString('summary') ?? '', category: data.getString('category') ?? '',
date: data.getString('date') ?? '', source: data.getString('source') ?? '', readingTime: data.getString('readingTime') ?? '', image: imageUrl(data.getString('image') ?? ''),
sections: sections(data['sections'] as UTSJSONObject[]) }
}
function lessonFrom(data: UTSJSONObject): FinanceLesson {
return { id: data.getString('id') ?? '', title: data.getString('title') ?? '', desc: data.getString('desc') ?? '', duration: data.getString('duration') ?? '', tag: data.getString('tag') ?? '',
image: imageUrl(data.getString('image') ?? ''), intro: data.getString('intro') ?? '', topics: data['topics'] as string[], chapters: sections(data['chapters'] as UTSJSONObject[]),
chapterCount: data.getNumber('chapterCount') ?? 0, lessonVersion: data.getString('lessonVersion') ?? '' }
}
export async function fetchArticles(category: string, page: number = 1): Promise<ArticlePage> {
const data = await requestJson('/content/articles', 'GET', { category, page, pageSize: 20 })
return { items: (data['items'] as UTSJSONObject[]).map((item: UTSJSONObject): NewsArticle => articleFrom(item)), total: data.getNumber('total') ?? 0, hasMore: data.getBoolean('hasMore') ?? false }
}
export async function fetchArticle(id: string): Promise<NewsArticle> { return articleFrom(await requestJson('/content/articles/' + encodeURIComponent(id), 'GET', {})) }
export async function fetchLessons(topic: string, page: number = 1): Promise<LessonPage> {
const data = await requestJson('/content/lessons', 'GET', { topic, page, pageSize: 20 })
return { items: (data['items'] as UTSJSONObject[]).map((item: UTSJSONObject): FinanceLesson => lessonFrom(item)), total: data.getNumber('total') ?? 0, hasMore: data.getBoolean('hasMore') ?? false }
}
export async function fetchLesson(id: string): Promise<FinanceLesson> { return lessonFrom(await requestJson('/content/lessons/' + encodeURIComponent(id), 'GET', {})) }
function stateFrom(data: UTSJSONObject): LessonState { return { lessonVersion: data.getString('lessonVersion') ?? '', completedChapterIds: data['completedChapterIds'] as string[] } }
export async function fetchLessonProgress(id: string): Promise<LessonState> { return stateFrom(await requestJson('/content/lessons/' + id + '/progress', 'GET', {})) }
export async function completeLessonChapter(id: string, chapterId: string, lessonVersion: string): Promise<LessonState> {
return stateFrom(await requestJson('/content/lessons/' + id + '/progress', 'PUT', { chapterId, lessonVersion, completed: true }))
}
export async function fetchContentHome(): Promise<ContentHome> {
const data = await requestJson('/content/home', 'GET', {})
return { lessons: (data['lessons'] as UTSJSONObject[]).map((item: UTSJSONObject): FinanceLesson => lessonFrom(item)), articles: (data['articles'] as UTSJSONObject[]).map((item: UTSJSONObject): NewsArticle => articleFrom(item)) }
}
export async function searchContent(keyword: string, type: string, page: number = 1): Promise<SearchPage> {
const data = await requestJson('/content/search', 'GET', { keyword, type, page, pageSize: 20 })
const items = (data['items'] as UTSJSONObject[]).map((item: UTSJSONObject): ContentSearchResult => ({
id: item.getString('id') ?? '', type: item.getString('type') ?? '', title: item.getString('title') ?? '', summary: item.getString('summary') ?? '',
category: item.getString('category') ?? '', meta: item.getString('meta') ?? '', image: imageUrl(item.getString('image') ?? '') }))
return { items, total: data.getNumber('total') ?? 0, hasMore: data.getBoolean('hasMore') ?? false }
}
export function emptyArticle(): NewsArticle { return { id: '', title: '', summary: '', category: '', date: '', source: '', readingTime: '', image: '', sections: [] } }
export function emptyLesson(): FinanceLesson { return { id: '', title: '', desc: '', duration: '', tag: '', image: '', intro: '', topics: [], chapters: [], chapterCount: 0, lessonVersion: '' } }
+55
View File
@@ -0,0 +1,55 @@
import { getSession, requestJson } from '@/services/api.uts'
export type UserMessage = { id: string, type: string, category: string, icon: string, title: string, content: string, time: string, isRead: boolean }
export type MessagePage = { items: UserMessage[], snapshotId: string, nextBeforeId: string, unreadCount: number }
export type MessageDetail = { item: UserMessage, targetUrl: string, targetLabel: string, targetUnavailable: boolean }
const validId = (value: string): boolean => /^[1-9][0-9]{0,9}$/.test(value)
const count = (data: UTSJSONObject): number => {
const value = data.getNumber('unreadCount')
if (value == null || !Number.isInteger(value) || value < 0) throw new Error('未读数量格式无效')
return value
}
function messageFrom(data: UTSJSONObject): UserMessage {
const id = data.getString('id') ?? ''; const type = data.getString('type') ?? ''
if (!validId(id) || !['system', 'service', 'activity'].includes(type)) throw new Error('消息格式无效')
const categories: UTSJSONObject = { system: '系统通知', service: '服务通知', activity: '活动消息' }
const icons: UTSJSONObject = { system: 'system-bell', service: 'approval', activity: 'gift' }
const date = new Date((data.getString('createdAt') ?? '').replace(' ', 'T'))
const now = new Date(); const pad = (n: number): string => n < 10 ? '0' + n.toString() : n.toString()
let time = '未记录'
if (!isNaN(date.getTime())) time = date.getFullYear() == now.getFullYear() && date.getMonth() == now.getMonth() && date.getDate() == now.getDate()
? pad(date.getHours()) + ':' + pad(date.getMinutes()) : date.getFullYear().toString() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate())
return { id, type, category: categories.getString(type) ?? '', icon: icons.getString(type) ?? '', title: data.getString('title') ?? '',
content: data.getString('content') ?? '', time, isRead: data.getBoolean('isRead') ?? false }
}
export async function fetchUnreadMessages(): Promise<number> {
if (getSession() == null) return 0
return count(await requestJson('/messages/unread-count', 'GET', {}))
}
export async function fetchMessages(type: string, beforeId: string = '', snapshotId: string = ''): Promise<MessagePage> {
const params: UTSJSONObject = { type, limit: 20 }
if (beforeId.length > 0) { params['beforeId'] = beforeId; params['snapshotId'] = snapshotId }
const data = await requestJson('/messages', 'GET', params)
const items = data['items'] as UTSJSONObject[]
return { items: items.map((item: UTSJSONObject): UserMessage => messageFrom(item)), snapshotId: data.getString('snapshotId') ?? '',
nextBeforeId: data.getString('nextBeforeId') ?? '', unreadCount: count(data) }
}
export async function fetchMessage(id: string): Promise<MessageDetail> {
if (!validId(id)) throw new Error('消息编号无效')
const data = await requestJson('/messages/' + id, 'GET', {})
const target = data['target'] as UTSJSONObject | null
let targetUrl = ''; let targetLabel = ''
if (target != null) {
const assessmentId = target.getString('assessmentId') ?? ''; const revisionId = target.getString('revisionId') ?? ''
const type = target.getString('type') ?? ''; const kind = target.getString('kind') ?? ''
if (!validId(assessmentId) || !validId(revisionId) || !['personal', 'enterprise'].includes(type)) throw new Error('关联评估格式无效')
// 只构造白名单路由,通知不能下发任意链接;历史失败也只打开原提交版本。
if (kind == 'report') { targetUrl = '/packages/finance/report/report?assessmentId=' + assessmentId + '&revisionId=' + revisionId; targetLabel = '查看报告' }
else if (kind == 'revision') { targetUrl = '/packages/finance/archive/archive?assessmentId=' + assessmentId + '&revisionId=' + revisionId + '&type=' + type; targetLabel = '查看评估' }
else throw new Error('关联评估格式无效')
}
return { item: messageFrom(data['item'] as UTSJSONObject), targetUrl, targetLabel, targetUnavailable: data.getBoolean('targetUnavailable') ?? false }
}
export async function readMessage(id: string): Promise<number> { return count(await requestJson('/messages/' + id + '/read', 'POST', {})) }
export async function readAllMessages(snapshotId: string): Promise<number> { return count(await requestJson('/messages/read-all', 'POST', { snapshotId })) }