feat: 实现协议全文与同意记录功能

新增协议全文后台管理、版本化发布及本人同意记录功能,支持可撤销授权;完善资料与授权相关边界提示,调整评估提交时的资料与授权校验逻辑,同步更新相关页面文案与路由。
This commit is contained in:
hujun
2026-10-14 14:53:13 +08:00
parent b418969e92
commit c95482a0f3
58 changed files with 3269 additions and 146 deletions
+38 -1
View File
@@ -1,5 +1,42 @@
# AI 融资移动端
新会话接续入口:[最新交接](docs/新会话交接.md)。2026-10-14已将进度同步为2.36/1.46、技术文档v2.04/1.17:后端和管理端已上线,小程序配套已完成、待用户发行反馈;真机/真实模型质量及正式验收仍待。下方保留各轮发布与开发记录。
2026-10-14 最新:按用户本轮授权,后端及管理端已发布20261014-product-ai-r1,14:19:02(Asia/Shanghai)恢复入口/worker。自定义AI条件、第一列手输及推荐/不推荐 `{X}` 模板已配套上线;133项文件摘要、维护期30表一致性、现有6上架产品编译、服务器/公网HTTPS及恢复预检通过,29份私密备份下载核验。产品仍原16字段,无业务迁移;本轮未调用真实模型。小程序由用户从当前源码发行,前轮29页编译及111项展示检查保持,真机与真实模型质量尚未验收。见[交接](docs/新会话交接.md)及[后端发布记录](D:/AAdesk/202609/airz/database/product-ai-release-20261014.md);下方未部署为历史状态。
2026-10-14 最新:按用户确认完成小程序配套展示调整,申请条件及其解析保持,不新增 AI 自填资料来源说明。详情/对比移除有效期,未填写的可选信息隐藏;列表/对比展示实际匹配度和机构 Logo,统一排序文案,区分条件不符、资料不足与待确认。111项针对性检查及微信29页本地编译通过,浏览器验证使用合成数据并拦截外部请求;H5编译有既有layout类型警告。未部署或发行,本轮不改后端、未调用真实模型;此前自定义AI条件及 `{X}` 后端仍待配套发布,小程序由用户发行。见[交接](docs/新会话交接.md);下方保留前轮记录。
2026-10-14 最新:管理端不推荐移除“触发结果”,推荐/不推荐均为三列,模板只用 `{X}`,AI 判断正文适用性并按顺序填充多个位置;第一列继续手输。新配置放原 `other`,旧报告保持,复用现有一次模型请求。真实 Chrome 与隔离假模型保存/读取验证通过,本轮未部署、未调用真实模型或修改小程序运行代码。见[交接](docs/新会话交接.md)及[当前设计](docs/superpowers/specs/2026-10-14-product-custom-ai-design.md);下方为前轮记录。
2026-10-14 最新:按用户纠正,仅准入/加减分的“条件项”和推荐/不推荐的“关联条件”第一列改为普通手输,其他列保留结构控件。隐藏内部英文编号,地区/用途及枚举多选改为勾选;改名保留值、对象与分值,模板按中文名称关联及回显。自定义硬/软条件与独立文字模板接入现有一次 AI 评估,标准规则保持,服务端算分、排序和保存前复算;旧快照不重算。产品仍为原16字段,配置保存在other,本轮无需DDL。代码及隔离Chrome/假模型任务验证通过,尚未部署本轮修改或调用真实模型。 详情见[当前设计](docs/superpowers/specs/2026-10-14-product-custom-ai-design.md)及[交接](docs/新会话交接.md)。
2026-10-13 最新:用户已选择恢复16个原字段名,匹配参数、可选日期和内部版本收纳到原other文本配置;expectation/quota恢复为额度/期限说明。管理端仅产品名称、适用对象为基本必填(新建默认企业),机构/类型/来源/额度/期限/利率/日期等可空,填写后仍校验;资料不足显示待核实,不进入推荐排名。保留InnoDB事务、varchar(100)产品名与状态默认草稿,旧冻结报告不重算。 本轮2120项检查通过:纯转换/发布/编辑/规则/评分、16与28字段实际模型及目录、评估提交/重评/助手/匹配接口、历史报告/PDF、迁移拒绝与回撤、UTS及实际页面;管理端15项、小程序受控H5 8项,1280×900和390×844无应用控制台错误。微信29页编译成功。失败和重复运行不累计,前轮582/980项不另加。 本轮只在专用本机合成MySQL/Redis/HTTP/H5环境验证,不读取正式.env、不访问正式库、不调用真实模型、未部署/提交/推送。正式产品表仍未迁移;发布应配套后端与管理端,不能只覆盖原三个文件。小程序仍由用户发行,真机、正式数据预检和用户验收尚未完成。 见[实施与验证](docs/产品表还原与选填核对-20261013.md)。
2026-10-13 前轮:产品生效/失效日期改为选填,留空不设对应日期边界;发布、目录、匹配可用性和小程序显示同步,582项针对性检查及微信29页重新编译通过。原16字段还原方案已核对,但当前28字段尚未迁移或删除;其他上架必填规则尚未全部放宽。未生产访问或部署,见[原字段与选填核对](docs/产品表还原与选填核对-20261013.md)。下方980项为前轮原型实施验证。
2026-10-13:按客户产品机构库原型完成本地改造,管理端八标签、搜索/筛选/规则/上下架、万元换算、基础分80可配置与软评分、条件模板、资料清单及小程序Logo/匹配度/顾问电话已接通。620项纯检查、22模型保存、13管理端、6列表、11新结果存储、238匹配接口(101本机HTTP)、19任务、14UTS、31分包路由、6H5展示共980项通过,微信29页编译成功;真实指标/Logo/电话待运营核实录入,未知数据不推断。本轮未部署或提交,原融资28项与产品AI回撤保持。见[实施记录](docs/产品机构库原型实施-20261013.md)。
2026-10-13 最新:在线照片长期保存,备份副本滚动7天已确认并落实。唯一服务器的私有目录/权限、每日04:20照片备份和本机DPAPI密钥副本准备完成,实际FPM合成读写、备份边界及独立密钥恢复校验通过;首份为空资料快照,定时触发与实际照片恢复未验收。见[落地记录](docs/照片存储配置落地-20261013.md)。照片/短信仍关闭、正式协议未发布,主体/联系方式暂无;删除后元数据30天仍待确认。进度2.32/1.42、文档v2.00/1.13;用户暂缓验收,小程序用户发行。
2026-10-13 前轮修订:按用户要求,在线照片长期保存,不设置上传起固定到期时间;本人删除、用途结束、撤销授权后的依法处理及注销申请按规则执行。已更新[照片保存规则](docs/协议草案/照片保存与删除规则.md)、隐私/资料协议及[存储方案](docs/私有照片存储与备份方案-20261013.md)。备份7天和物理删除完成后元数据30天当时仍只是建议;没有连接生产或新增到期删除功能,照片/短信继续关闭、正式协议未发布。进度2.31/1.41、文档v1.99/1.12;用户暂缓验收,小程序用户发行。
2026-10-13 前轮只读核对:现有服务器私有照片存储/权限和备份已只读核对,见[配置与备份方案](docs/私有照片存储与备份方案-20261013.md)。推荐public同级的private-documents目录,www:www/0700,符合现有open_basedir;目录尚未创建。每日04:00数据库备份保留3份、最新含三张新表,照片文件备份和独立密钥恢复仍待落实。未改生产配置或启用照片/短信,正式协议未发布,用户暂缓验收;进度2.30/1.40、文档v1.98/1.11,小程序用户发行。
2026-10-13 初稿历史(在线30天建议已由长期保存取代):四份协议草案及照片保存/删除规则已整理,见[草案入口](docs/协议草案/README.md)。主体和联系方式暂无,期限30天/备份7天/删除后元数据30天仅为待确认建议,未正式发布或启用;照片/短信仍关闭。用户先不验收,本轮只做本地草案与格式核对,既有服务器后端部署及小程序用户发行安排保持。
2026-10-12 最新:按“只有现有服务器、直接部署,小程序自行部署”授权,后端已发布 `20261012-agreement-documents-r1`,17:05:07恢复访问(Asia/Shanghai)。29文件、三张空增量表、协议五节点/运营v2模板已部署,旧26表/原角色与绑定保持;27服务器HTTPS、31公网可信TLS、116源码摘要和回退预检通过。短信/照片上传仍关闭,正式协议正文未发布。小程序当前production API为 `https://airz.hqling.cloud/api/v1`,须用户从当前29页源码重新编译发行;不再等待独立测试服务器。见[发布说明](docs/后端协议与照片发布-20261012.md)与[最新交接](docs/新会话交接.md)。下方未部署/测试配置描述保留历史状态。
2026-10-12:第1步本机测试准备完成,前端test地址与已发布服务分开,默认留空;后端新增独立测试配置样例和协议/照片最小部署清单。13项配置/API预检及32项实际前后端H5联调通过,照片在原卡片显示、恢复和删除;所属合成环境已清理。远程测试站点、微信真机与正式启用仍待;见[测试部署清单](docs/协议与照片测试部署清单-20261012.md)。
2026-10-12:私有照片上传、本人预览和删除内部实现完成,真实上传默认关闭、正式目录未配置;照片未核验、暂不参与评估。隔离HTTP、受控页面及29页微信/Android编译通过,未上线。正式用途/期限/目录及真机仍待;详见[开发记录](docs/私有资料上传开发-20261012.md)与[最新交接](docs/新会话交接.md)。
2026-10-12:资料上传方案已按用户要求简化为上传照片、本人查看和删除,复用现有界面/鉴权/图片校验,仅补私有保存与归属校验。原云存储扩展层、上传租约/命令账本和自动清理引擎不再作为实施要求。融资填写项不扩展,本轮仅改方案,尚未实施上传;见[简化方案](docs/superpowers/specs/2026-10-12-private-documents-design.md)与[最新交接](docs/新会话交接.md)。
2026-10-12:协议全文、后台审核/不可变版本和本人同意/可选撤销记录已实现,新增两页共29页,63任务编号保持。后端1730项隔离检查、前端30项源码/31项分包及最终29页Android/微信编译通过,最终H5专项48项与初轮78项分别记录。融资28项填写内容、私有上传/外部查询及同事产品库未改,产品AI回撤保持。正式协议正文未提供、未发布;两张新表只在本机隔离库安装,正式权限未迁移、代码未部署,短信仍默认关闭。见[协议全文与同意记录开发](docs/协议全文与同意记录开发-20261012.md)及[最新交接](docs/新会话交接.md)。
2026-10-12:短信登录/首次注册、验证码改密及原/新手机号双验证换绑后端代码和隔离检查已完成。供应商、审核签名及模板暂缺,默认关闭、不发送真实短信、未部署;四套894项/200次本机HTTP通过,所属环境已清理。现有小程序契约复用,融资录入内容和同事产品库未改。见[短信账户开发记录](docs/短信账户功能开发-20261012.md)与[最新交接](docs/新会话交接.md)。
2026-10-10:产品AI条件分析与后台人工复核已按用户要求撤回,试点及其余20条接续取消;小程序修正和既有产品/匹配功能保持。见[回撤记录](docs/产品条件分析功能回撤记录-20261010.md)。
最新设备反馈(2026-10-09):用户确认小程序1.0.7已发行。已在前端修正缴纳时长排序/简写、基数万元重复和概况重复原因;本批修正版需重新发行,后端保持profile-grade-r4。45受控H5/32报告适配/31分包检查及微信/Android27页编译通过;发行时间/包摘要与修正版真机复核仍待。见[交接](docs/新会话交接.md)顶部,下方“待用户发行”为前轮状态。
@@ -144,7 +181,7 @@ $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`,当前test环境体验版地址为 `https://airz.hqling.cloud/api/v1`。共享请求已统一Bearer、FastAdmin响应解包及401处理;模拟开关关闭,production地址仍空,切换production前须明确配置HTTPS地址。
- API 环境配置在 `services/config.uts`,当前默认production,正式地址为 `https://airz.hqling.cloud/api/v1`;development地址为 `http://127.0.0.1:18580/api/v1`,test地址留空,未知环境不回退。共享请求统一Bearer、FastAdmin响应解包及401处理,模拟开关关闭;小程序由用户重新编译发行。
## 当前前端交互
@@ -0,0 +1,12 @@
# 短信账户功能实施计划
依据:[已授权范围与设计](../specs/2026-10-12-account-sms-design.md)。本轮在现有两仓库定向修改,不创建提交/推送/生产发布;同事产品模块不改动。用户已明确先写代码,无需再次选择执行方式。
- [x] 验证码引擎:新增SmsVerification及供应商接口/默认不可用实现和sms_verification配置;先执行缺功能红测试,再验证真实隔离Redis中的发送、限流、摘要及原子单/双码核销。
- [x] 账户接入:新增AccountSms,将Session的短信登录/发送/验证码改密接入,并增加安全发送和手机号换绑路由。复用Auth的注册/直接登录/改密与Token撤销;不迁移表。
- [x] 隔离验证:新建私密ROOT/MySQL/Redis/bootstrap,运行验证码及HTTP新测试、原api_session/api_profile回归,核对结构及模型屏蔽,清理仅归属本轮的资源。
- [x] 文档与复审:独立只读代码复审;记录未配置关闭状态、测试结果及供应商接入要求,更新交接/进度;PHP语法与两仓diff检查。
执行记录只保存在本轮私密归档,历史证据不覆盖。测试发送替身不提供线上配置入口或固定验证码。
结果:[开发记录](../../短信账户功能开发-20261012.md)。四套最终测试894项/200次本机HTTP通过,12PHP语法检查及独立只读复审完成,所属隔离环境已清理;真实供应商接入、目标端及正式验收仍待,默认关闭、未部署。
@@ -0,0 +1,17 @@
# 协议全文与同意记录实施计划
> 本轮用户已授权继续前两项,直接在现有会话执行,不创建提交/推送。三个互不覆盖的子任务按dispatching-parallel-agents处理;共享数据库测试由主控顺序执行。
**目标:** 内容后台能够发布不可变协议版本,小程序可读全文,服务端保存本人可追溯的同意与可选撤销记录。
**架构:** fa_acticle继续承载草稿,新增agreement_revision保留正式版本,user_consent保存幂等同意及撤销命令,首次撤销时间保持。公开全文/鉴权同意API与现有登录契约兼容,短信仍关闭,正式正文不编造。技术栈沿用PHP7.4/ThinkPHP5/MySQL5.7/Redis、uni-app x/UTS。
**设计:** [已授权范围](../specs/2026-10-12-agreement-consents-design.md)。不改融资28项输入/产品模块、不接生产/真实短信/模型,不执行生产迁移。
- [x] 协议发布:AgreementRegistry及后台Agreements使用内容CSRF和权限,红测试先证明服务缺失;冻结(code,version)唯一快照、正文修订须新版本,current与发布同顺序锁定,旧入口不能绕过。提供database/agreement-revision-schema.sql,纯校验/发布测试通过。
- [x] 同意服务:先编写tests/agreements.php并在新隔离环境取得红测试;ConsentLedger执行新同意当前版校验、旧键原回执、账户锁/会话重核、双用途撤销及严格字段/分页校验;新增api/v1/Agreements控制器及完整匹配路由,提供database/user-consent-schema.sql并更新期望schema。
- [x] 前端:新增services/agreements.uts、匿名全文页和本人记录页,现有登录/隐私/资料链接接通。所见版本勾选、认证后保存、未知原键手动确认、账户/代次守卫;参数/守卫红绿测试完成,30项纯源码、31项微信分包通过,最终29页Android 12:21:51/微信12:22:35编译成功。初轮78项与最终复审48项受控页面检查分开保留。
- [x] 隔离验证:全新所属MySQL/Redis/ROOT,初始26表到两表定向安装后的28表基线。按顺序执行协议HTTP/事务/权限/历史/并发、原会话/资料/短信账户回归;测试前后核结构/触发器/空表/零键/进程归属/模型屏蔽。
- [x] 收口:独立只读复审关闭全部明确P1/P2,14项PHP语法和两仓diff通过;源码/测试回执、开发说明与交接已更新。最终前端回执final-return-receipt.json绑定10文件摘要及最新两端编译,初轮与复审证据不累计。精确清理本轮数据库/服务及夹具,保留私密证据;清理末端检查失败已通过后续只读复核补证,具体原因未确认。未发布正文/未部署/真实业务未验收明确记录。
每项只改所属文件,已有短信代码、旧候选和历史发布证据保持。前端新增两页面预计总29页,63业务任务编号不扩增;正式协议、资料留存及真实渠道条件不擅自批准。
@@ -0,0 +1,9 @@
# 私有资料上传实施计划
按已批准的[简化方案](../specs/2026-10-12-private-documents-design.md)直接实施,本轮不重新设计、不提交代码。
- [x] 后端:`PrivateDocuments.php`、`v1/Documents.php`、`extra/private_documents.php`、三条路由及单表 SQL。复用 Auth/Token、Secret、documents 同意记录;默认关闭且私有目录留空。先写隔离 HTTP 测试,再实现。
- [x] 前端:新增 `services/documents.uts`,复用现有资料卡片及协议服务,区分本机待上传与服务端 ID;手动重试、本人鉴权下载/删除、会话及离页迟到回包防护;复用同意读取恢复全部本人ID,照片直接显示在原卡片,通过卡片菜单管理,不加下方列表。
- [x] 评估:资料单独保存、暂不绑定或参与分析;确认页明示此边界,仅已取得合法 ID 的资料可以绕过旧“上传未开放”提示,外部授权仍拒绝;服务端继续拒绝非空评估附件。
- [x] 验证:全新本机合成 MySQL/Redis/私有目录;上传、读取、删除、同意及归属、大小/格式、故障重试;纯 UTS 会话/页面测试、Android/微信编译、拦截全部 API 的受控 H5 页面。
- [x] 收尾:核验合成数据及所属服务清理,更新进度、交接及验证记录。用途、期限、正式私有目录、正式协议和真机验收留待启用前确定;生产、短信和模型全程不连接。
@@ -0,0 +1,22 @@
# 协议与照片独立测试环境实施计划
> 按用户“推进第 1 步”授权在当前会话实施;不创建提交、推送、PR或远程部署。
**目标:** 分开当前源码的测试与生产目标,准备最小配套部署清单,并在新建本机合成环境接通协议与照片流程。
**架构:** 前端默认测试地址留空,独立联调副本显式指向本机API。后端测试配置、ROOT、MySQL数据目录、Redis实例、照片目录和密钥全部独立,复用现有隔离工具,不读取仓库`.env`。本机只使用合成账户、协议和图片,模型客户端阻断、短信关闭,不启动worker。
**技术栈:** uni-app x / UTS、ThinkPHP 5 / PHP 7.4、MySQL 5.7、Redis、现有HBuilderX与Playwright。
**依据:** `docs/私有资料上传开发-20261012.md`及已确认的第1步;融资28项、29页面、63任务保持。
## 实施与验收
- [x] 检查两仓状态、当前与父级指令、配置加载及协议/照片/短信依赖,保留既有改动。
- [x] 修改`services/config.uts`:test与production地址留空;本机副本使用development;未知环境也返回空地址。
- [x] 新建后端`.env.test.sample`:独立数据库/Redis/命名空间,模型关闭且指向本机无服务端口,照片默认关闭,密钥和目录空值必须另行配置。
- [x] 在`docs/协议与照片测试部署清单-20261012.md`记录完整前后端配套文件、三个新增表、权限增量、部署顺序、回退和真机条件。
- [x] 在仓库外新建本轮归属环境;不复跑已清理runner。只在该合成空库安装完整schema,正式/已有库只列出三份增量SQL。
- [x] 使用实际本机协议与照片API验证同意、上传、原卡片缩略图、重新进入恢复、撤销后禁止上传及本人删除;记录请求与配置审计。
- [x] 执行针对性的配置/源码检查和`git diff --check`;记录截图及剩余微信真机条件,不把H5视为真机验收。
- [x] 停止本轮所属服务,清理合成库/Token/照片并保存回执;更新README及交接,不改生产状态。
@@ -0,0 +1,16 @@
# 私有照片存储与备份落地计划
用户已授权继续前述服务器存储步骤,并确认在线照片长期保存、备份副本滚动保留7天。主体及删除联系方式暂无,照片保持关闭;不发布占位协议、不增加在线照片到期删除。
使用现有服务器、宝塔维护通道和本机DPAPI。运行工具保存于Web外root-only目录;不安装依赖、不提交/推送,按步骤在本会话执行。
- [x] 重新核对版本、关闭状态、表计数、目录、FPM、路径限制、服务、时区和配置摘要;保存精确恢复材料。
- [x] 创建public同级私有目录(www:www/0700)和备份目录(root:root/0700);仅写入照片directory,enabled保持false。以www及现有open_basedir验证合成文件读写并清理。
- [x] 现有共享AES密钥经临时RSA公钥封装后传回本机,立即转换为DPAPI文件,仅当前用户/SYSTEM访问;明文只在内存,删除临时私钥。备份包不带密钥或.env。
- [x] 准备并验证每日04:20备份:PDO只读一致快照取得三表结构/元数据及对应文件清单,复制不可变密文,缺文件/链接/摘要漂移则失败;只备份已入表的活动照片,不删除在线文件或孤立密文。定向清理超过7天的本工具备份,未知文件拒绝清理。
- [x] 先在合成临时目录验证备份与到期清理边界,再安装Web外root-only工具及独立cron文件,保持现有cron/数据库备份不改。执行一次当前空资料备份,校验归档摘要和从DPAPI恢复密钥的合成GCM解密能力;不恢复生产库。
- [x] 核对服务、配置变更范围、关闭状态、目录权限、cron、备份及密钥恢复回执;宝塔退出关闭,清理临时材料,更新记录。技术验证不计用户功能验收。
完成证据:[落地记录](../../照片存储配置落地-20261013.md)。生产最终核对为10:55:12,回执final-verification-retry.json;本地草案/源码摘要/文档链接/UTF-8及私密ACL核对见D:/AAdesk/202609/airz-backups/private-storage-setup/20261013-01/local-verification.json。照片/短信仍关闭,首份为空资料快照,定时首次触发及实际照片恢复未验收;无异地照片副本,DPAPI换机前需受控迁移。
恢复:仅撤去本轮cron/工具,保留密钥和备份;.env只能在核对本轮after摘要、仍关闭且无新照片后恢复服务器before。已有照片或配置漂移时先保留现场,不覆盖后续数据、不删密钥/整库。空目录仅在确认本轮所有权且仍为空时可移除,禁止递归清理在线目录。
@@ -0,0 +1,10 @@
# 准入条件统一录入
目标:移除独立“地区与用途”,统一经“添加条件”录入;条件名称支持选择或手输入。用户已明确授权本地实施,不增加审批、提交或部署。
实现:地区/用途列表行继续映射现有地区和用途配置,默认不添加空行;已配置内容回显。已知条件名称映射现有规则,自定义名称与文字阈值保存到原other内展示配置的可选manualConditions列表,仅提示待核实,不进入推荐排名。无新增列、融资项或AI功能;保留旧冻结快照。
- [x] 先补纯内存保存/回显、校验、自定义unknown、适用对象及旧快照测试,确认因未实现失败。
- [x] 修改六个已有后端/管理端文件;保护旧规则、对象范围、重复地区/用途及并发保存。
- [x] 专用本机合成库验证实际管理端选择/手输、保存回显、删除、旧规则重建和窄屏;运行相关纯逻辑与16/28列回归。
- [x] 核对源码摘要、停止所属测试进程、记录结果和交接。不得初始化默认.env、访问正式库、迁移或部署。
@@ -0,0 +1,13 @@
# 产品原字段与文本配置实施计划
用户已选择恢复原字段并把匹配配置放入原文本字段,当前会话顺序实施,不委派、不提交、不部署。规格为 `docs/产品表还原与选填核对-20261013.md`,此选择取代其中“等待范围选择”的状态。
目标:16个原字段名恢复,配置和内部版本放在other,保留InnoDB与100字产品名;管理端仅名称/对象必填,其他缺失显示未提供,实际配置的条件仍严格校验。旧快照不重算、原融资28项不扩展。
- [x] 编码与迁移:先写tests/product_storage.php,覆盖旧28字段→16字段→逻辑数据往返、HTML原文、金额精度、版本、损坏JSON及TEXT字节上限,再实现ProductStorage::decode/pack/encode、新表DDL和显式预检/备份/转换工具。转换保留旧表,未授权不操作生产。
- [x] 读写:Product模型按实际物理列适配,保存持事务行锁、版本校验和部分更新合并;目录日期/地区在服务端读取配置筛选,ID游标按实际扫描位置推进;匹配捕获和当前可用性解码新旧记录。对比转换前后的规则与旧结果。
- [x] 选填:ProductPublication保留名称/对象和格式校验,放宽银行/类型/来源、额度/期限/利率、日期/用途;空参数不生成规则,不补默认值。未知地区与仅原文条件保留待核实提示,不能静默宣称已满足;editor支持原额度/期限说明,新建企业对象按原表默认。UTS兼容新旧DTO及单边范围显示。
- [x] 验证:独立本机合成MySQL真实16字段表验证迁移、模型/表单/上下架/游标/匹配/回滚,原28字段库验证过渡兼容;纯回归、PHP/JS语法、微信29页编译与必要页面检查。保留证据,停止本轮专用服务。
- [x] 文档:更新新库schema、迁移说明、交接与实施结果,明确正式库仍为未迁移,发布必须配套后端及小程序。
实施结果:2120项专项检查与微信29页编译通过;所属四服务停止、端口释放,合成库/回撤表和证据保留。正式库未迁移、未部署,完整说明见docs/产品表还原与选填核对-20261013.md及后端database/product-original-migration.md。
@@ -0,0 +1,17 @@
# 产品机构库原型实施计划
> 采用 superpowers:executing-plans 在当前会话顺序执行;用户已授权实施,不创建提交或委派任务。
目标:接通原型的产品规则配置与真实匹配展示。
架构:保留现有Product CRUD、发布校验、冻结目录和UTS数据服务,添加ProductPresentation纯配置/评分/模板服务,现有other列承载版本化扩展配置。兼容旧说明和旧快照,不新增表。
规格:[实施范围](../specs/2026-10-13-product-prototype-design.md)。技术栈:PHP7.4/ThinkPHP/FastAdmin/RequireJS/uni-app x UTS。
- [x] 配置及评分:新增后端tests/product_presentation.php纯测试,覆盖80基础分、正负分、上下限、unknown/verified来源、模板引用和旧文本;实现ProductPresentation::normalize/view/evaluate/render,ProductEditor::_presentation和万元字段转换。
- [x] 管理端:改add/edit为共用八标签form,所有回显htmlentities,object多选与_base_version;改backend/product.js为稳定规则编辑、资料/模板表、发布预检、保存、上下架;列表加银行/产品切换和筛选。
- [x] 匹配:ProductMatchRules扩展原型指标白名单,ProductPublication设企业范围;ProductMatching固定新版本/评分降序,与旧v1冻结快照兼容;MatchRecords和ProductCatalog投影资料清单、Logo、电话;运行既有规则/发布/编辑纯测试及新增冻结回归。
- [x] 小程序:改matches.uts规范化扩展字段和评分,保持归属与快照检查;改product.uvue头部摘要、分数/资料清单、真实顾问拨号与既有办理准备路径;UTS mock API回归。
- [x] 联调:启动独立本机合成数据服务,真实HTTP保存/编辑/上下架检查,浏览器桌面与窄屏核对;HBuilderX编译微信与H5,路由回归。记录结果和尚未提供的真实指标/顾问渠道;更新交接文档,不部署。
执行结果:980项专项检查、微信29页最终编译、PHP/JS语法与相关diff空白检查通过;截图/证据及限制见实施记录。无提交、部署或代理任务。
@@ -0,0 +1,30 @@
# 条件第一列手输与自定义条件 AI 匹配实施计划
**Goal:** 第一列手输、保留其余结构,自定义条件由 AI 判断准入/评分/关联模板结果。
**Architecture:** 保留原16字段和既有配置,other 中新增可选手输软条件与文字模板。标准规则保持;AI 自定义判断并入现有评估调用,服务端合并及写入前复核。旧报告保持。
**Tech Stack:** ThinkPHP 5、PHP 7.4、jQuery/Bootstrap、现有 Playwright/Chrome。
**Spec:** ../specs/2026-10-14-product-custom-ai-design.md
**Constraints:** 保留两仓既有改动;不用默认 .env;无正式写入/发布/真实模型/提交;不新增融资表单字段;不恢复产品原文 AI 整理。
- [x] 撤回本任务误扩大的整页文字化,并将废弃文件存入仓库外归档;既有未提交改动保留。
- [x] `product.js`、`product/form.html`:四个表格第一列纯手输、隐藏内部编号、多选值勾选、关联名称保存/回显;桌面/窄屏验证。
- [x] `ProductPresentation`/`ProductPublication`:自定义硬/软条件和关联文字模板配置校验及兼容。
- [x] `ProductAiConditions`:白名单事实、完整判断校验、三态、身份及引用边界、输入摘要。
- [x] `ProductMatching`、`AssessmentNeedsReport`、`AnalysisTasks`、`Assessment` 接入:冻结、既有调用复用、合并/保存前重算、旧快照兼容。
- [x] 假模型实际隔离任务与读取/历史回归;针对性 PHP/JS 检查、记录证据和清理所属测试进程。
- [x] 更新交接,说明本地完成状态、正式发布范围及未覆盖真实模型行为。
验证证据:D:/AAdesk/202609/airz-backups/product-first-column-20261014。未部署或调用真实模型;清理状态以cleanup.json为准。
## 后续要求:推荐与不推荐统一 `{X}`
- [x] 管理端移除不推荐触发结果,三列表格、旧变量转换及新模板保存回显。
- [x] 新 `ProductAiTemplates` 与评估/匹配/保存链路接入,复用一次模型调用并保留旧冻结语义。
- [x] 多槽顺序、正文适用性、金额单位、资料不足、身份/摘要/事实引用及预算边界验证;实际隔离任务和最终独立审查。新模板纯测试73项、Chrome表单51项、实际假模型任务49项通过。
- [x] 更新验证回执、当前交接及所属隔离服务清理记录。HTTP/MySQL/Redis所属进程已精确停止,62175/62176/62177端口关闭,合成数据与证据保留。
本轮证据:D:/AAdesk/202609/airz-backups/product-template-x-20261014;仅本地实施,未部署或调用真实模型。
@@ -0,0 +1,12 @@
# 正式产品表还原与配套发布计划
用户已明确授权“正式服务器也一起处理并部署”。目标为airz.hqling.cloud、/www/wwwroot/xctai.cn、127.0.0.1:3306/airz,仅转换fa_product及发布产品库配套代码;小程序由用户发行。不提交、不推送、不委派,不调用真实模型或导入合成业务。
- [x] 固定产品库运行文件和依赖摘要,迁移工具增加显式解除旧日期限制的选项;默认转换仍无损。解除日期的产品版本递增,原值保存在旧表及备份。
- [x] 本机隔离MySQL演练29列(含空bank_logo)转换16列、清除日期、非空Logo拒绝/无损默认、精确回退;回归当前产品保存/发布/匹配。
- [x] 服务器只读核对代码差异、环境摘要、表结构、产品计数、日期使用及活动任务/队列。正式凭据只在服务器读取,不下载.env。
- [x] 固定发布清单,备份旧源码、站点版本、Nginx、产品完整数据及整库灾难材料;维护窗口暂停外部写入、worker与FPM,再转换产品表并原子替换配套源码。旧表保留。
- [x] 核对16列、全部产品内容/配置/版本、非产品表一致、历史快照及服务;29项服务器HTTPS只读检查和精确回退预检通过后恢复入口/worker,再检查31项公网及日志。
- [x] 37个备份文件下载核验,清理临时凭据和传输缓存,退出宝塔;更新发布/交接记录,明确小程序和真实业务验收状态。
恢复:重新暂停写入,先保护发布后新增数据,核对源/表/版本摘要;使用本次迁移回执的双表RENAME恢复原产品表并恢复本次before源码/资源版本。不得整库覆盖,不删除原表、真实产品或用户数据。
@@ -0,0 +1,17 @@
# 短信登录与账户安全内部实现
用户已授权先写相关代码;供应商、已审核签名及模板暂缺。本轮只实现本地代码并使用全新隔离存储验证,不能发送真实短信、部署、提交或修改生产。产品机构库由同事维护,不触及产品代码;不恢复已回撤的产品AI分析,不改融资评估28项输入。
## 接口与行为
沿用手机号登录/注册、验证码改密、手机号双验证换绑页面和JSON契约。发送成功返回retryAfter/expiresIn;未配置短信始终503/SMS_NOT_CONFIGURED,不产生可用验证码或用户。供应商接口只定义send(phone,code,purpose)及可用性,默认不可用,测试替身仅放tests。未来真实适配需明确启用、审核签名/各用途模板及服务端凭据。
验证码六位、安全随机,Redis仅保存带服务端秘密的摘要,手机号/上下文键使用摘要;60秒同号码冷却、号码/IP限流、300秒过期、最多5次错误。验证码绑定用途、号码和账户当前手机号;多挑战同一次Lua原子核销,错误不能消耗另一个正确挑战,重复/并发核销最多成功一次。发送失败/未知不激活,预留失败次数仍算限流。不能在响应、业务日志或持久测试回执中保存明文码。
短信登录支持已存在正常账户及首次注册;随机不可知初始密码、邮箱null、默认昵称和零积分,沿用已有密码哈希与Token。现有唯一手机号约束防并发重复注册,账户行锁与Auth::direct串行防换绑后旧号码登录。注册Hook或存储失败不返回Token。禁用账户不登录。
安全发送/改密/换绑须认证,账户行锁内再次检查会话有效性与当前手机号。验证码改密只支持已登录本人;不新增匿名找回密码流程。换绑同时验证原号码和新号码,校验占用及唯一约束,事务写入当前账户手机号,撤销本人全部会话,不重写历史融资快照。验证码先原子消费,后续业务失败不恢复验证码,须明确重新发送,避免跨Redis/SQL补偿导致重放。
## 验证与发布边界
测试使用全新本机MySQL/Redis、独立ROOT及显式bootstrap,真实模型屏蔽、供应商发送只在测试中注入。覆盖用途/账户隔离、过期/限流/错误上限、发送失败、双码、注册并发、Token撤销、禁用、Hook及事务故障;回归现有密码登录与资料。无渠道配置时上线仍不可发送,本轮不视为短信业务已上线或真实渠道验收。
@@ -0,0 +1,25 @@
# 协议全文与同意记录设计
用户已同意先实施协议全文展示及版本化同意记录。本轮复用内容后台和既有登录/隐私/资料授权入口;不新增融资28项填写内容、不开发私有上传或外部查询,不涉及同事产品机构库,不恢复产品AI条件分析。两仓原有短信未提交改动保留,短信仍默认关闭;本轮不提交、推送、部署或接生产。
## 协议与不可变版本
四种code固定对应scope:terms/account-terms、privacy/account-privacy、document-use/documents、data-authorization/data。fa_acticle作为协议草稿源,只有本轮结构化kind=agreement正文经运营确认已审核并正式发布后才成为可用协议;旧type2/3内容不自动转为正式版本。正式正文未知,本轮不编造、不填正式内容、不发布。
发布同事务保存fa_agreement_revision,唯一(code,version),冻结标题、正文、scope、SHA-256和UTC生效/发布时间。正文为纯文本,最多60000字节,保留换行,不执行HTML。版本ASCII字母数字点横线下划线、1至64字符,标题最多50字。未来生效版本不作为current;同版本不能改正文,修订须新版本。下架不抹除已发布历史,指定版本始终返回原文;新增/编辑/批量/旧入口不得绕过审核与版本约束。沿用后台权限及内容CSRF,不自动绑定正式角色。
## 同意与撤销
新增fa_user_consent保留本人同意的协议code/version/快照ID与摘要、服务端grantedAt、requestId。grant行保存首次revokedAt,每个已受理撤销请求追加event_kind=revoke命令并指向原grant;同意与撤销共享(user_id,request_id)唯一空间,新键重复撤销也不可被后续操作复用。服务端在事务与账户行锁内重核有效会话;grant读取当前已发布版并锁定,与发布锁顺序兼容。新请求必须scope匹配且版本当前,旧键同内容返回已有记录、异内容409,不因后续新版本而破坏旧键补确认。
GET /agreements/:code公开,version可选;GET /consents仅本人、可scope过滤和有界分页;POST /consents要求scope/agreementCode/agreementVersion/requestId;POST /consents/:id/revoke仅本人、重复保留首次时间、同键异操作拒绝。协议使用确认account-terms/account-privacy不当成可撤销的数据查询授权;仅documents/data提供撤销,撤销不删除历史证据。所有本地同意记录均不代表第三方授权或已经查询外部数据。
## 前端与登录
主包新增可匿名协议全文页,账号分包新增本人同意记录页。现有链接跳转全文,具备加载/未发布/失败重试和旧响应守卫;记录页可查看当时版本、手动撤销可选授权。复用现有登录勾选,加载terms/privacy后绑定所见版本;认证成功后使用当前会话保存两项同意,确认成功才跳转。登录已验证但保存未知时手动原键重试,不重复发送短信/验证密码;切换账户和离页不能回填。
后端登录契约和旧客户端兼容,本轮不启用强制协议登录策略;协议未发布时不产生同意证据,原有账户密码登录可继续,真实网络失败不能当未发布。正式正文/留存期限仍需业务确认。融资资料步骤的原选填行为保持,附件和外部查询仍未开放。
## 验证与迁移边界
新两表SQL只在全新私密本机MySQL隔离库安装,生产/schema迁移仅提供文件,不直接执行;独立Redis DB15、ROOT、命名空间和模型屏蔽。TDD先缺功能红测试,再测试发布不可变、历史精确读取、并发/幂等、跨账户、撤销、版本竞争、CSRF及旧内容/短信账户回归。前端纯测试、本地微信/Android编译与合成API受控渲染;不得通过默认H5入口访问生产API。全部完成后精确清理所属环境,真实渠道和正式正文验收不预填通过。
@@ -0,0 +1,42 @@
# 资料上传简化方案
日期:2026-10-12。状态:按用户“还是不设计这么复杂”收缩方案;内部代码与隔离验证已完成,真实上传默认关闭、未上线,见[开发记录](../../私有资料上传开发-20261012.md)。本文件替代此前复杂提案,后续按此范围推进。
## 要做的功能
沿用小程序现有资料选图,只补三个操作:上传照片、本人查看、本人删除。资料继续选填,不增加融资28项填写内容。
复用项目已有登录鉴权、图片校验方式和现有界面。通用Upload类会写public/uploads,因此私有资料不直接调用该公开保存分支;只补必要的私有保存逻辑,不重做通用上传系统。
## 最小实现
- 使用当前服务器的一处私有目录,放在Web可访问目录之外;服务端生成随机文件名。
- 新增一张小型资料表,保存附件ID、所属用户、资料类别、存储位置、格式/大小、同意记录及创建/删除信息,不放入公开附件列表。
- 上传使用现有Bearer登录和documents同意记录;查看、删除校验所属用户,其他用户不能访问。撤销同意后禁止新的资料上传/使用,本人仍可管理自己的资料。
- 首版接收JPG/PNG照片,单张最多5MB;检查真实图片格式、尺寸并重新编码,沿用既有Secret加密保存,不建立新的密钥体系。
- 同步完成保存与删除。文件或数据库明确失败则报告失败并处理本次文件;保存结果未知提示手动重试,不假报成功,不自动重复发送。
- 删除先使资料不可读,再删除文件;只有文件确认删除后才显示成功,失败可以对同一个附件重试。
只提供三个业务接口,沿用现有响应格式:
| 接口 | 用途 |
| --- | --- |
| POST /api/v1/documents | multipart上传一张照片和已有资料类别/同意记录,返回documentId |
| GET /api/v1/documents/:id/content | 鉴权后读取本人的照片,不返回公开文件地址 |
| POST /api/v1/documents/:id/delete | 删除本人指定附件,重复删除安全返回 |
前端继续使用现有资料卡片,上传后在原位置显示照片缩略图,通过卡片菜单查看与删除,不在下方新增独立管理列表。复用已有GET /consents?scope=documents附加本人资料引用,跨离页恢复,不新增业务接口;同类多张全部可选择管理。受保护图片用带Bearer的下载请求取得临时图片再预览;不把Token塞入URL。退出或换账户时废弃迟到响应,清理本次页面持有的临时图片引用。
## 这轮不扩展的内容
取消原提案的云存储替换层、上传命令账本/租约、专门原键查询接口、多阶段状态机、独立清理worker及一组生命周期配置。普通上传允许用户明确重试,不承诺网络回执丢失时绝不多存一份文件。
不做OCR、自动核验或外部数据查询。现有评估仍按自填资料生成报告,附件ID绑定评估时另做最小集成;不能仅移除当前拒绝附件的分支便宣称已接通,也不把上传成功当成已核验。
## 启用与验证
代码可先在全新隔离环境实现。正式协议用途、保存期限和实际私有目录在正式启用前确定;不在本轮指定上线期限,不新增一套自动保留/删除引擎。
验证集中在正常上传/本人预览/删除、非法图片和超限、未登录/跨用户拒绝、失败提示和换账户后的迟到响应。公开头像、现有融资填写及同事产品库保持;短信仍默认关闭,产品AI回撤保持。
设计简化时仅修改方案与台账;后续内部实施及验证见开发记录。FILE-001正式存储/隐私条件仍待,FILE-002/004内部代码完成、正式未验收;真实上传默认关闭,不连接生产/迁移/发布/提交/推送,文档检查不计为功能验收。
@@ -0,0 +1,13 @@
# 产品机构库原型实施范围
用户已同意前轮十项建议,要求尽量以客户原型为准。2026-10-13 确认基础分默认80、每产品可配置,满足软条件时加减分,未知不计分,范围0—100;硬条件不符不推荐。
复用 FastAdmin 产品库、准入编译器、冻结快照及小程序三标签详情。管理端采用银行/产品列表、搜索筛选、头部机构信息及八个规则标签;修复保存、版本保护、金额单位、回显和上下架。银行视图按机构归组展示产品,不新增独立机构表或机构级发布操作。
准入条件使用稳定字段、条件编号和来源要求。补充原型指标仅支持规范配置;没有采集/核验来源时为unknown,不把经营月数当成立年限或营收当开票额,不增加原融资28项采集。理由模板按条件编号与pass/fail/unknown绑定,有限占位符由服务端渲染,再供现有单产品AI解释使用。资料清单和机构Logo/真实顾问电话等展示配置保存为版本化JSON于现有other列;原有文本说明保留。
新匹配快照版本固定评分配置与排序口径;旧快照继续按旧版本执行、旧报告不补分。仅eligible参加排名,按分数降序、同分产品ID升序;unknown/ineligible无推荐排名。
小程序显示真实Logo、额度利率、条件匹配度、资料清单与已有理由分组;顾问电话配置后才拨打,未配置提示联系平台。办理入口复用已有申请准备页;不编造银行办理网址/顾问。保留账户归属、revision与快照一致性检查。
本轮不提交、不部署、不访问正式数据库或真实模型。使用独立本机合成数据验证保存、发布、匹配、旧快照兼容和页面;保留两仓既有改动。覆盖前三文件和相关待改源码已备份至仓库外product-prototype-20261013目录。
@@ -0,0 +1,21 @@
# 条件第一列手输与自定义条件 AI 匹配
用户已纠正范围:仅第一列改为手输,包括准入/加减分的条件项、推荐/不推荐的关联条件。运算符、值、适用对象与分值保持原结构。用户随后要求不推荐移除“触发结果”,推荐与不推荐模板统一只使用 `{X}`,填充内容及正文是否适用由 AI 判断。此前已批准自定义条件由 AI 判断,标准条件继续确定性规则。产品原文 AI 整理仍不恢复。
保留八标签及既有表格。第一列使用普通 input,无下拉、datalist;中文已知字段仍映射标准规则,任意名称保存自定义。关联条件手输名称,自动关联已有唯一名称;同名须明确命名,未关联已有条件的文字保存为独立条件描述,仅决定对应模板是否展示。隐藏内部规则编号。地区/用途和已知枚举多选值改为直接勾选,保存参数保持不变。基础分默认80可配置,加减分继续独立整数分值输入。
新增可选 manualSoftConditions 和 textTemplates,保持 manualConditions 原结构。自定义条件与白名单自填事实作为不可信数据并入既有评估请求,返回满足、不满足、资料不足和有效资料引用。自定义硬条件不满足则不推荐,未知则不排名;软条件满足才按配置分值加减;理由模板仅按关联结果展示,不升级为新硬门槛。分数限制0—100,标准硬不符不能被 AI 覆盖。
模型判断和输入摘要随评估结果保存,写入前依据冻结目录重新校验。复用现有一次模型调用、预算、任务租约和用量记录,输出容量按实际条件数约束。旧目录/报告保持原语义,不补算。失败、截断、遗漏、重复或越界判断不能当作满足。继续使用现有任务失败重试流程。
只在显式本机隔离环境和假模型中验证;不访问正式库、不部署、不调用真实模型、不提交代码。发布必须同步所有配套后端文件,不能只更新管理端 JS。
实施边界:同产品相同文字模板条件共用判断;一次最多32个不同判断、48KB条件描述,超范围明确未判断并保持未知。输出预算按数量调整至4096—8192,真实供应商返回质量和预算支持尚未付费验证。
## 后续要求:推荐与不推荐统一 `{X}`
推荐与不推荐均为“关联条件、模板内容、操作”三列。关联条件继续手输,唯一名称可绑定已有准入或加减分条件;模板允许多个 `{X}`,AI 按出现顺序返回各槽内容。编辑旧模板时,将旧六种变量转换为 `{X}`,旧不符合/资料不足模板均放入不推荐页,由 AI 分别判断正文是否适用;历史报告及未编辑的旧冻结目录保持旧语义。
新增可选 `aiTemplates`,每项精确五键 `id`、`ruleId`、`condition`、`kind`、`text`,`kind` 为 `recommend` 或 `caution`。绑定现有规则时 `ruleId` 非空、`condition` 为空;独立文字关联反之。新编辑保存清空旧 `templates`/`textTemplates`,不新增产品表列。每模板最多8槽、旧新模板合计最多100条,单次 AI 判断及渲染工作合计32条、总32槽、实际关联描述48KB;超出范围明确未评估。
渲染合入既有一次评估模型调用,输出 `templateRenderings` 的产品ID/版本/模板ID/模板摘要、三态 `conditionOutcome`、`applicable`、顺序 `values` 及 `evidenceRefs`。只有正文适用且结果符合服务端最终规则时展示;标准规则推荐仅允许满足,不推荐允许不满足或资料不足。自由关联相同条件共用判断,正文适用性分别判断;模板不新增准入门槛或改变分数。未适用模板返回空槽值,适用模板的槽数必须精确匹配,事实引用及金额单位须校验;资料不足不能写成确定不符合,保存前重新验证冻结输入和结果。
@@ -0,0 +1,25 @@
# 产品机构库原型实施记录
2026-10-13:按用户确认的十项建议及客户原型实施,本地代码和隔离联调完成,尚未部署。用户确认基础分默认80、每个产品可调整,满足软条件时加减分,未知不计分,最后限制0—100;硬条件不符仍不推荐。原型的银行参数、92%和示例日期没有导入正式数据。
管理端保留现有框架,提供银行/产品视图、银行或产品名称搜索、状态和实际产品类型筛选,行编辑/规则/上下架。两个视图均展示产品行,分别按机构或产品名称排序;不存在机构级统一上架。编辑页以机构Logo、产品名、类型/对象、真实来源和更新时间为头卡,下设基本信息、准入条件、加分/减分项、额度期限、资料要求、推荐模板、不推荐模板、其他设置八个标签。条件行可直接编辑和删除。
修复新增/编辑对象多选、保存按钮、版本保护、旧文本回显和RequireJS入口;万元输入由后端精确转换为元,最低/最高额度、期限、利率和有效期均沿用现有字段。发布前检查、单条及批量上下架进入既有服务端校验和事务。原condition/grade/information/recommendation/deprecated说明保留在对应标签,不自动作为规则执行。
补充原型指标的规范字段:企业成立月数、年开票金额、连续纳税月数、纳税等级、企业信用状态、年营收增长率、负债率、流水稳定性、行业类别。管理端标注“待接数据”;目前没有冻结事实来源时为unknown,不通过AI、照片或其他近似字段推断。经营月数与成立月数、年营收与开票金额保持不同含义;融资28项采集不扩展。
扩展配置由ProductPresentation验证,以product-presentation-v1 JSON保存于既有other TEXT列,无新增表或迁移。内容为基础分、软条件、按条件编号/pass/fail/unknown关联的理由模板、资料清单、条件说明、Logo、顾问电话、还款/渠道/审批说明和旧其他说明。模板只接受actual/expected/field/unit/product/bank六种文字占位符,不执行表达式;删除已被模板引用的条件必须同时处理模板。
新目录采用product-matching-v2与product-score-descending-v1。先判硬条件,再根据已满足的软条件加减分;eligible按分数降序、同分按产品ID升序获得排名。unknown/ineligible没有推荐排名。评分配置、参数与理由随报告冻结,产品后续修改不会影响旧结果;v1快照仍按ID顺序执行,旧报告保留null分数。来源不足时不能获得对应软条件分值。分数表示条件匹配程度,不是获批概率。
小程序继续使用AI解释/产品详情/申请条件三标签,增加真实Logo、头部额度/年化利率、eligible匹配度、结构化资料清单及还款/渠道/审批说明。顾问按钮只拨打配置电话,未配置时提示联系平台;申请办理复用原申请准备页,不向银行提交申请。未符合、待核实或已下架产品保持原有核对路径,账户归属、revision和快照一致性检查保留。
验证使用独立本机MySQL/Redis/HTTP、合成账户与假模型,不读取项目正式.env或接触正式数据库;H5所有业务接口均拦截,使用真实规则执行生成的合成响应。纯规则/发布/编辑/新配置620项、模型保存22项、实际管理端13项、筛选/上下架6项、新存储读取11项、匹配记录238项(101个本机HTTP请求)、任务19项、UTS服务14项、微信分包路由31项、H5展示6项共980项通过。微信29页最终编译成功;PHP与JS语法、相关差异空白检查通过。浏览器无页面/控制台错误,390px窄屏检查通过。
预览截图:[管理端准入页](D:/AAdesk/202609/airz-backups/product-prototype-20261013/rules-desktop.png)、[评分页](D:/AAdesk/202609/airz-backups/product-prototype-20261013/score-desktop.png)、[小程序产品页](D:/AAdesk/202609/airz-backups/product-prototype-20261013/product-mobile.png)、[资料清单](D:/AAdesk/202609/airz-backups/product-prototype-20261013/materials-mobile.png)。这些截图仅含合成数据。
修改前三文件及相关源码已先备份到D:/AAdesk/202609/airz-backups/product-prototype-20261013/before,SHA256清单与测试回执在同目录。保留两仓之前的改动,无提交或推送。部署时须同时发布新的ProductPresentation、匹配/DTO代码、管理端模板/脚本,并由用户重新编译发行小程序;只覆盖同事原三文件不足以发布本轮功能。
仍需运营录入并核对真实产品出处、条件、资料要求、机构Logo和顾问电话;尚无来源的指标在接入核验数据前会持续待核实。正式照片/短信开关、协议状态及长期照片/7天备份规则保持既有安排。本轮技术验证不等同于用户验收。
本轮专用MySQL、Redis、HTTP、H5及浏览器已按进程归属核对后停止,正式和其他本机服务未操作;测试证据和合成库留档,不包含正式数据。
@@ -0,0 +1,83 @@
# 产品原字段还原与选填实施记录
## 正式发布:2026-10-14 原16字段已生效
用户已授权正式服务器一起处理并部署,版本20261014-product-original-r1。产品表实际29列,其中bank_logo31条均为空;现恢复为原16字段,15个扩展列移除,仍使用的匹配配置保留在原文本字段,expectation/quota恢复说明。31产品及状态保持,6旧日期边界解除并递增版本;其余28业务表和历史报告保持。17配套文件已上线,入口与worker恢复,小程序由用户发行。
本地613项迁移/产品回归与14项发布工具边界通过,正式131项源码/依赖摘要、产品内容/版本和其他业务表一致性、HTTPS与回退预检通过;没有真实模型调用、合成业务导入或提交。完整旧表、源码和私密备份留存于/root/airz-product-original-20261014及D:/AAdesk/202609/airz-backups/production-release/airz-product-original-20261014。恢复必须保护后续数据并使用本轮回执,不能整库覆盖。下方未部署、未转换、仅编辑解除日期为历史记录。
## 后续调整:移除管理端生效与失效日期
2026-10-13:按用户要求删除新增/编辑页的生效日期、失效日期输入,产品开放状态通过上架/下架控制。保存及发布预检由控制器统一显式设置两日期为NULL,防止旧日期隐藏后继续生效,也不接受客户端补传日期恢复自动到期。旧产品下一次编辑保存时解除限制;本轮不批量改写旧数据,不删除内部兼容字段,不重算历史冻结报告。旧规则保护、版本冲突与16/28字段兼容保持。
622项本地检查通过:565项纯内存编辑/发布/存储/匹配回归,新16/旧28字段实际事务保存各12项,Chrome桌面与窄屏交互33项;覆盖旧过期/未来日期解除、预检与保存一致、手动上下架、补传日期覆盖及旧窗口拒绝。截图:[移除日期后的基本信息](D:/AAdesk/202609/airz-backups/product-remove-dates-20261013/admin-no-dates-desktop.png)。修改前备份、测试日志、源码摘要及清理回执保存在D:/AAdesk/202609/airz-backups/product-remove-dates-20261013。未部署、正式库访问、模型调用或提交;下方日期选填描述为历史记录。
## 后续调整:地区、用途统一到条件列表
2026-10-13:按用户最新要求移除独立“地区与用途”,经“添加条件”选择或手输入名称;已有地区/用途在条件列表回显,删除后更新对应配置。已知名称仍使用固定规则;自定义名称、比较方式、文字要求、对象及说明保存到原other内的可选manualConditions,不增加表列或融资项。自定义条件按对象提示待核实、不自动满足、不计软评分、不进入推荐排名;原硬条件不符仍不推荐。旧冻结报告不重算。
本轮1013项检查通过:管理端实际选择/手输/多选回显/保存/删除/重复及空行拦截/旧规则保护与重建42项,纯自定义29项、实际假模型任务和已保存报告12项,现有编辑/发布/匹配/评分/规则/选填/存储及16/28字段兼容715项,历史报告/PDF215项。桌面1280×900、窄屏390×844页面及交互通过,无应用控制台错误/警告。PHP/JS语法和差异空白检查单独记录;失败或重复运行不累计,不与下方前轮2120项相加。
源码及页面证据:D:/AAdesk/202609/airz-backups/product-condition-input-20261013;[空条件界面](D:/AAdesk/202609/airz-backups/product-condition-input-20261013/admin-empty-desktop.png)、[已添加条件](D:/AAdesk/202609/airz-backups/product-condition-input-20261013/admin-conditions-desktop.png)。仅本地修改后端/管理端,未部署、迁移正式库、提交或调用真实模型。两仓既有改动保留;资源清理见cleanup.json。
2026-10-13,用户明确选择“表字段尽量恢复原版,匹配配置存入原有文本字段”。本节取代前轮范围待选状态,下方保留日期选填阶段历史。
用户已选择恢复16个原字段名,匹配参数、可选日期和内部版本收纳到原other文本配置;expectation/quota恢复为额度/期限说明。管理端仅产品名称、适用对象为基本必填(新建默认企业),机构/类型/来源/额度/期限/利率/日期等可空,填写后仍校验;资料不足显示待核实,不进入推荐排名。保留InnoDB事务、varchar(100)产品名与状态默认草稿,旧冻结报告不重算。
本轮只在专用本机合成MySQL/Redis/HTTP/H5环境验证,不读取正式.env、不访问正式库、不调用真实模型、未部署/提交/推送。正式产品表仍未迁移;发布应配套后端与管理端,不能只覆盖原三个文件。小程序仍由用户发行,真机、正式数据预检和用户验收尚未完成。
## 存储和管理端
新库产品表使用全部16个原字段名;ProductStorage包装额外参数到other的product-storage-v1 JSON,同时保留other原文或原型评分配置,condition/grade/information/recommendation/deprecated原文保持。旧28字段表兼容读取/保存;切换前独立列优先,切换后读配置。金额仍为元十进制字符串,管理端数值以万元录入;原额度/期限说明不推算数值。未提供的上下限不生成规则,单边值显示最低/最高。
生效空值不限制开始日期,失效空值不自动到期;明确填写的格式、顺序、未来/过期逻辑保持。地区/来源待核实时允许保存上架展示,但结果为unknown、rank为空;仅原准入说明不能当作结构化条件已满足。基础分80可配置、满足软条件加减分且限制0—100、硬条件不符不推荐保持。融资28项不扩展,产品原文AI分析不恢复。
模型保存统一事务/行锁、合并局部更新、递增内部版本和表单冲突检测。迁移工具默认只读,显式目标/私密配置;超长、损坏、未知列、目标不符均拒绝,stage逐字段核对后双表切换并保留原表,不直接DROP列。仅合成2条产品完成真实转换和回撤演练,正式库全量预检未执行。完整指令见[迁移说明](D:/AAdesk/202609/airz/database/product-original-migration.md)。
## 验证
本轮2120项检查通过:纯转换/发布/编辑/规则/评分、16与28字段实际模型及目录、评估提交/重评/助手/匹配接口、历史报告/PDF、迁移拒绝与回撤、UTS及实际页面;管理端15项、小程序受控H5 8项,1280×900和390×844无应用控制台错误。微信29页编译成功。失败和重复运行不累计,前轮582/980项不另加。
证据根:D:/AAdesk/202609/airz-backups/product-original-storage-20261013,verification.json绑定源码摘要,migration-qa.json记录11项演练,admin-qa.json/mobile-qa.json与截图记录页面验证。合成迁移保留before原表与精确回撤SQL;工具不能自动暂停写入,生产转换须另按目标、备份和维护窗口授权。
- 纯逻辑:40存储、122发布、232编辑、151旧匹配、20展示、7选填、64硬规则。
- 真实存储:新16/旧28字段各31,目录46,发布模型22,匹配接口238(101 HTTP)、提交514(158 HTTP/5假调用)、重评58、助手165、报告持久化75、历史/PDF215、任务19、展示持久化17。
- 其他:UTS19、迁移11、管理端15、受控H5 8;PHP/JS语法、差异空白检查及微信编译单独记录,不计入2120。
浏览器技能不在当前工具目录,使用已有Playwright/Chrome,不安装依赖。管理端实测最少录入→保存→上架→恢复说明→回显→旧窗口拒绝→下架/再上架;H5业务API全部拦截,用真实规则输出的合成DTO,验证未提供/待核实、空日期、文字说明和单边范围。截图证据:[管理端额度与期限](D:/AAdesk/202609/airz-backups/product-original-storage-20261013/admin-quota-desktop.png)、[小程序选填展示](D:/AAdesk/202609/airz-backups/product-original-storage-20261013/product-original-mobile.png)。
源码修改前备份及SHA保存在before目录,两仓既有未提交改动保留。测试库及回撤表/证据保留,所属服务清理情况以cleanup.json为准;不得重跑setup.py/prepare-qa.py创建或覆盖环境,不得把默认.env用于测试。
---
## 前轮历史:原字段核对与日期选填
2026-10-13:用户提供原始 `fa_product`,要求尽可能恢复原字段,并减少生效、失效日期等录入要求。本轮先核对两种表结构,完成日期选填的共同改动;物理表还原范围已通过异步问题征询,尚未收到选择。没有执行DDL、生产写入或部署。
## 表结构核对与建议
原表有16个字段;当前有28个字段。原 `expectation`(额度)与 `quota`(期限)被结构化额度和期限取代,额外存在金额上限、期限上限、年化利率、年龄、经营月数、地区、准入规则、生效/失效日期及资料版本字段。这些字段涉及 Product 模型保存、发布编译、公共目录SQL、冻结匹配快照和当前可用性查询,不能仅删除列或覆盖CREATE TABLE。
若选择尽量恢复物理字段,建议恢复全部16个原字段名,把机器匹配参数、地区、准入规则、可选日期和内部版本收纳到原 `other` 的版本化JSON配置,由服务端适配器解码为原有内部接口。额度/期限的原文使用 `expectation`/`quota` 保存,明确数值参数保留在配置中;不从任意说明文字猜测金额。评分、资料和理由模板沿用现有配置,并保留原说明。对TEXT容量、旧JSON格式、金额精度和模板引用分别校验,不截断数据。
建议保留两处与原DDL不同的技术设置:InnoDB继续提供事务、行锁和批量失败回滚;产品名称保留varchar(100),避免已经允许保存的长名称被截断。名称收缩到20字必须先核对已有数据。内部版本移入配置以后仍要保留并发保存检测,不让运营手工维护。
另一种选择是只简化管理端而保留28个物理字段。它无需数据迁移、读写适配或切换产品表,功能验证范围更小;数据库字段多并不意味着每个字段都必须由运营填写。
完整物理还原的必要步骤是:显式配置只读预检 → 产品完整备份 → 在新表转换每条产品并核对额度、条件、说明和版本 → 验证新旧保存/匹配与旧报告一致 → 在停止写入的窗口切换并保留旧表以供定向回撤。历史评估快照不重算,不运行早期要求空业务表的 `database/migrate.py`,不直接DROP新增列。此路径尚未实现或执行,不能当作可直接上线的迁移脚本。
## 本轮已实施:有效期选填
- 生效日期空值:不限制开始日期;失效日期空值:不设自动到期。
- 保存时空字符串转为SQL NULL;发布预检与编译接受缺失、NULL或空字符串。
- 填写的日期仍须有效,两日期均存在时仍检查先后顺序;已到期产品仍不能发布,未来生效产品仍不进入当前目录。
- 公共目录、评估冻结目录和匹配记录可用性同步接受可空边界;下架产品仍不可用。
- 管理端说明选填语义;小程序分别显示“未设置有效期”“有效至…”“…起生效”或完整起止日期,不显示空的“至”。
其他字段的必填规则尚未放宽:保存草稿要求产品名称及表单对象/配置格式有效;上架仍要求产品类型、机构、数值额度/期限/利率、已确认地区及规则来源。日期选填不代表这些字段已全部改为选填。下一步应根据表结构选择统一简化,保留真实条件和来源状态,不编造日期、利率或核验记录。
## 验证与证据
发布155项、编辑234项、旧匹配151项、UTS显示18项、实际隔离MySQL模型/目录/匹配24项,共582项通过;7个PHP文件语法与差异空白检查通过。实际SQL覆盖四种空/非空日期组合、未来生效、过期、下架和事务回滚,原有合成产品逐行保持。微信29页编译结果见仓库外 `mp-dates.log`。
本轮修改前源码备份、SHA256与测试日志存于 `D:/AAdesk/202609/airz-backups/product-original-20261013`。测试使用前轮专用合成MySQL目录和新的本机端口、独立ROOT/ENV_PREFIX,不读取正式.env、不调用模型。测试进程已停止,端口释放;不改变正式或其他本机服务。本轮没有提交、推送、生产数据迁移或小程序发行。
@@ -0,0 +1,90 @@
# 协议与照片独立测试部署清单(2026-10-12)
更新:用户随后明确只有现有服务器并授权直接部署,后端已于2026-10-12发布agreement-documents-r1;当前前端默认production并填写既有正式API,test地址仍空。无需新增独立测试服务器。下表及后文保留本机测试阶段快照,当前发布/启用边界以[本轮发布说明](后端协议与照片发布-20261012.md)为准。
本清单落实“第1步”:先分开测试与生产配置,在独立测试环境接通协议与照片,仅用合成资料,短信及真实模型关闭。远程测试主机/域名尚未指定,本轮只做本机准备与联调;不代表远程部署或微信真机验收。
## 环境配置
| 环境 | 前端配置 | 后端配置与数据 |
| --- | --- | --- |
| 当前源码默认 | `APP_ENVIRONMENT='test'`,`TEST_API_URL=''`,请求明确失败 | 仓库`.env`原样保留;不能直接启动它进行测试 |
| 本机联调副本 | `development`,`http://127.0.0.1:18580/api/v1`;端口以本轮回执为准 | 新建独立ROOT、空MySQL数据目录/库、Redis实例、Web外目录和随机密钥 |
| 微信远程测试 | `test`,填写独立测试HTTPS域名的`/api/v1` | 单独站点/库/Redis命名空间/密钥,合成账号与协议,不使用生产库或产品数据 |
| 正式发行 | `production`,正式地址需要发行时明确填写 | 正式用途、正文、期限、目录/ACL、备份恢复及发行验收另行完成 |
`services/config.uts`原先的test地址指向已发布服务,已清空。未知环境字符串同样返回空地址,不自动切换到本机或其他环境。`API_DEMO_ENABLED=false`保持。
后端样例:[.env.test.sample](D:/AAdesk/202609/airz/.env.test.sample)。ThinkPHP默认仅读取ROOT下`.env`,样例文件名不会自动切换环境;它只能用于**独立测试站点**的配置,不能覆盖当前仓库或生产`.env`。本轮隔离入口使用独立ENV_PREFIX注入并核对生效值,ROOT内不放`.env`。
测试样例库名为`airz_product_test_2026101201`,MySQL3345、Redis6425/DB15;这些只是独立实例样例,使用前确认归属和端口空闲,本轮临时实例实际采用空闲端口。`airz_test`不是已有账户,需由测试库管理员配置仅限该库的账号和密码。不得在共享Redis上执行FLUSHDB;每轮另设命名空间。生成32字节随机测试密钥,不复用生产密钥;私密配置不提交或共享。
`deepseek.enabled=false`、`api_key=''`、`base_url='http://127.0.0.1:1'`;不启动分析worker。`application/extra/sms_verification.php`硬编码关闭,当前没有真实供应商配置入口;添加环境开关不会启用短信。本机合成账号走密码登录,不绕过正式短信流程或使用真实号码。
## 最小配套源码
后端以当前已发布profile-grade-r4及既有鉴权/Secret/内容CSRF代码为基线。以下是当前未部署功能的配套集合,不能只上传Documents控制器;同步时先对目标源码做差异核对,尤其`AssessmentRecords.php`存在已审阅线上变体,不得整文件覆盖丢失它。
| 功能 | 后端相对路径 |
| --- | --- |
| 路由与账户 | `application/route.php`、`application/api/controller/v1/Session.php`、`application/common/library/Auth.php` |
| 默认关闭的短信配套 | `application/common/library/AccountSms.php`、`SmsVerification.php`、`smsverification/Provider.php`、`smsverification/UnavailableProvider.php`、`application/extra/sms_verification.php` |
| 协议与同意API | `application/api/controller/v1/Agreements.php`、`application/common/library/AgreementRegistry.php`、`AgreementPublication.php`、`ConsentLedger.php` |
| 协议后台 | `application/admin/controller/Agreements.php`、`application/admin/model/Agreements.php`、`application/admin/view/agreements/{index,form,add,edit}.html`、`public/assets/js/backend/agreements.js` |
| 内容后台配套 | `application/admin/library/BusinessPermissions.php`、`application/admin/library/traits/ContentEditor.php`、`ContentModel.php` |
| 私有照片与评估边界 | `application/api/controller/v1/Documents.php`、`application/common/library/PrivateDocuments.php`、`application/extra/private_documents.php`、`application/common/library/AssessmentRecords.php`中的边界文案 |
前端以当前完整29页项目重新编译,不向小程序局部拷贝UTS。新增`pages/agreement/agreement.uvue`、`packages/account/consents/consents.uvue`;配套`pages.json`、账户login/profile、融资assessment/confirm/services/records、`services/api.uts`、`assessment.uts`、`agreements.uts`、`agreements-route.uts`、`documents.uts`、`config.uts`。具体路径及当前文件SHA-256见本轮源码清单,若继续修改需重新生成;它不是上传包摘要。
复用现有vendor/extend依赖,不安装或升级系统/生产依赖。PHP至少7.4,现有pdo_mysql/redis/GD/openssl/json/fileinfo及mbstring等项目扩展需齐全;GD必须可解码JPG/PNG。Nginx/宝塔保留原API路由与Bearer头转发,Web根为public;私有目录无公开映射。应用原图上限5MiB,设置`upload_max_filesize>=5M`、`post_max_size>5M`和Web请求体上限覆盖multipart开销;不能以提高PHP限额替代应用像素/边长检查。
## 数据库及后台权限
只在**新建合成空库**安装完整`database/schema.sql`。对已有测试库或今后授权目标库,先只读确认现状、备份、逐份执行缺少的增量;禁止用完整schema覆盖现有库,禁止重复建表:
1. `database/agreement-revision-schema.sql`:`fa_agreement_revision`。
2. `database/user-consent-schema.sql`:`fa_user_consent`。
3. `database/private-document-schema.sql`:`fa_private_document`。
使用InnoDB及现有REPEATABLE READ约定;三份SQL不修改旧表、不导入正文、账号、产品或正式同意记录。存量`fa_acticle`协议HTML不会自动成为正式快照,须通过协议后台逐份审核发布。测试正文明确标注“仅供合成联调”,不得迁到正式站点。
协议后台五节点:`agreements`、`agreements/index`、`agreements/add`、`agreements/edit`、`agreements/multi`;独立`业务模板-运营-v2`新增一个模板,旧运营v1、客服v2、技术v5保持。沿用`database/migrate_admin_permissions.php`对**明确独立测试目标**只读预检,实际增量以输出为准;应用、备份和账号绑定按明确目标授权进行。照片API沿用本人Bearer鉴权,不新增管理端照片权限。当前账号/权限未迁移或绑定。
## 执行顺序与验收
1. 明确独立站点、HTTPS域名、空库、Redis实例、Web外目录、运行账号和备份位置;先核对与生产不同。后端和前端都记录源码摘要,已有目标则备份配套文件/结构/数据/配置,私密备份不整包共享。
2. 先部署后端完整配套源码,照片保持关闭;安装缺少的三表,按需要预检/安装协议后台节点。运行账号仅对私有目录读写,Web无权限公开读取;目录预先存在且不能与public重叠、成为其祖先或链接。
3. 在独立测试库通过审核发布合成`terms`、`privacy`、`document-use`协议;如需读取数据授权再单独发布合成`data-authorization`,不能把它当第三方查询授权。
4. 核对实际生效数据库/Redis/密钥、短信false、模型false/无供应商密钥;仅测试配置显式开启照片并填写目录。运行本人同意→multipart上传→Bearer下载→恢复→删除。另验未登录/跨账号拒绝、撤销后拒绝新上传、本人仍可读删、关闭上传时明确提示。
5. 前端测试副本显式使用该API,重新编译29页;照片须在原卡片显示,通过卡片菜单管理,离页后恢复。确认评估仅提交自填信息,附件/consentIds仍为空;本轮不执行真实评估/模型任务。
6. 保存请求计数、配置审计、源码清单、截图及清理回执。本机合成环境测试后停止所属API/H5/MySQL/Redis,清理所属账号/同意/协议/照片/Token和精确合成库;不触碰其他服务或共享数据。
关闭照片功能会使上传、下载、删除不可用,资料引用返回空,但不删除已保存照片;已有测试环境退回前先让本人完成删除及核对私有目录。源码回退先恢复前端兼容版、关闭新上传/入口、保留新增表/协议快照/同意及密钥,不反向DROP有数据的表。需要恢复整个测试站点时使用同批备份,避免密文、表与密钥不匹配。
## 微信真机的下一步
本机127.0.0.1是手机自身,不能作为真机API;微信真机需要独立可信HTTPS测试域名。小程序后台把相同测试域名加入request、uploadFile、downloadFile合法域名,使用体验版测试配置重新编译上传。开发者工具“忽略域名校验”只能辅助模拟器,不能作为真机通过证据。
在微信/Android真机验证选择照片、原卡片缩略图、Bearer下载、原卡片菜单删除、重新进入恢复、换账号及临时文件清理;记录设备/系统/微信版本、体验版版本、源码/上传包摘要和北京时间。正式照片用途、正文、保留期限、撤销后处理、目录/ACL、密钥恢复和孤立文件运营处理仍需确认;本机合成联调不解除FILE-001或正式验收阻塞。
## 本轮验证与回执
本机准备与联调已完成,远程测试站点和微信真机尚未执行。
| 检查 | 本轮结果 |
| --- | --- |
| 默认/production/未知环境/显式development配置 | 未配置目标均返回空地址,只有显式development使用本机18580 |
| 配置与真实本机API预检 | 13项通过;非本机DB、模型开启、无效密钥、ROOT中隐式.env均在加载框架前拒绝 |
| 前端既有专项 | 26组照片、30项协议检查通过 |
| 实际前后端H5联调 | 32项通过;20次页面本机API请求,0页面/控制台错误、0未知外呼 |
| 界面及流程 | 390×844、1280×900;同意→真实上传→原卡片缩略图/菜单预览→离页恢复→撤销后禁止新上传但本人仍可恢复/删除→删除后引用为空;跨账户读取404 |
| 生效配置审计 | 63份bootstrap审计,真实模型加载、短信开启、生产.env加载均false;未提交评估或启动worker |
| 归属清理 | 29表零行、Redis零键、私有目录零文件;精确合成库已删除,所属API/H5及子进程/MySQL/Redis停止,端口释放 |
本轮API为`http://127.0.0.1:18580/api/v1`,H5为`http://127.0.0.1:5182`,现已停止。四份合成协议和两个合成账号仅在该全新空库生成,使用真实密码登录API;没有真实短信、正式正文或产品数据导入。PHP所需pdo_mysql/redis/GD/openssl/json/fileinfo/mbstring实测存在,未安装依赖。
证据:[配置检查](D:/AAdesk/202609/airz-backups/test-configuration/1791793388934/configuration-verification.json)、[页面/API联调](D:/AAdesk/202609/airz-backups/test-configuration/1791793388934/ui-verification.json)、[配置审计](D:/AAdesk/202609/airz-backups/test-configuration/1791793388934/audit-summary.json)、[清理回执](D:/AAdesk/202609/airz-backups/test-configuration/1791793388934/cleanup.json)、[源码清单](D:/AAdesk/202609/airz-backups/test-configuration/1791793388934/source-manifest.json)。源码清单绑定14个前端文件、30个后端运行/DDL/配置文件;正式发布前需与目标基线核对,后续改动需重新绑定,不是发行包摘要。合成私密配置不整包共享,已清理环境不能直接重新start。
Browser plugin not available,使用现有Playwright/Chromium及非持久浏览器,网络只允许本机H5/API。截图:[原卡片上传](D:/AAdesk/202609/airz-backups/test-configuration/1791793388934/uploaded-390.png)、[重新进入恢复](D:/AAdesk/202609/airz-backups/test-configuration/1791793388934/recovered-390.png)、[宽屏恢复](D:/AAdesk/202609/airz-backups/test-configuration/1791793388934/recovered-1280.png)、[删除完成](D:/AAdesk/202609/airz-backups/test-configuration/1791793388934/deleted-1280.png)。本轮前端修改仅配置,不重复微信/Android全量编译;前轮29页编译证据保持,正式测试域名配置后再编译体验版。
临时工具首次启动的H5归属校验缺少工作目录识别,停止所属服务后另建新环境;最终按PID创建时间、完整命令及工作目录核验。H5首次缺少`HX_APP_ROOT`导致现有内置组件定位失败,仅重启本轮预览并补HBuilderX环境后通过;共享layout.uts既有类型警告保留。配置拒绝测试修正PHP错误输出通道/CRLF匹配;页面恢复检查等待异步缩略图后最终通过。失败轮不计通过数,不改产品逻辑。
@@ -0,0 +1,57 @@
# 协议全文与同意记录开发
2026-10-12,用户已授权实现协议全文、后台正文和版本管理、本人同意记录及可选撤销。正式正文尚未提供,本轮不编造或发布正式协议。代码尚未部署;短信仍默认关闭。融资评估28项填写内容、私有资料上传、外部查询及同事维护的产品机构库不在本轮范围,产品AI条件分析回撤保持。
## 实现
| 协议code | 用途scope | 可撤销 |
| --- | --- | --- |
| terms | account-terms | 否,保留账户使用确认 |
| privacy | account-privacy | 否,保留隐私政策确认 |
| document-use | documents | 是 |
| data-authorization | data | 是 |
后台新增独立“协议正文与审核发布”入口。草稿仍保存在fa_acticle,正文为纯文本,换行统一LF;正式发布须明确勾选已审核。fa_agreement_revision保存标题、精确正文、SHA-256、scope和UTC时间,(code,version)唯一;已发布版本修订必须使用新版本号。旧HTML协议不会自动成为正式版本,旧Acticle入口不能修改或删除结构化协议。
未来生效版本可审核发布,但尚未生效时不进入当前版。最新有效版被下架或删除来源后不回退旧版;指定历史版本仍返回冻结原文。提前发布未来版本应新建来源,保留仍有效的旧来源。公开读取在InnoDB REPEATABLE READ下用同一读事务;同意与发布采用一致的版本/来源锁顺序。
fa_user_consent保存grant记录及撤销命令,同意和撤销共享(user_id,request_id)唯一空间。每个受理撤销键追加revoke命令并指向原grant,历史列表只展示grant;重复撤销保持首次时间。新同意必须匹配当前版,旧键同内容可以补确认,不受后续版本更新、下架或撤销影响;异内容或异操作返回409。本人会话在事务和账户行锁内再次核对,跨账户不可查看或撤销。
小程序新增主包协议全文页及账户分包“隐私与授权”记录页,共29页面,63业务任务编号保持。支持加载失败重试、历史版本、可选授权确认及撤销。登录勾选绑定所见terms/privacy版本和摘要,认证后保存两项确认,确认成功才跳转;记录未知时使用原键手动补确认,无需再次验证密码或发送短信。只有明确404/AGREEMENT_NOT_AVAILABLE才允许现有账户密码登录且不生成同意证据;网络或503失败不能当未发布。后端登录契约保持,本轮不启强制同意策略。
待确认键仅在同会话页面内保存;离页返回保留,页面卸载、完整刷新或进程重启不提供持久恢复,不缓存协议正文或新增Token副本。本地同意不等同已上传资料、征信查询或第三方授权。资料步骤原有选填方式和融资输入保持。
## 接口与安装文件
API前缀为/api/v1,JSON请求,记录接口须已有Bearer会话;ID为十进制字符串,时间为UTC ISO秒格式。
| 方法/路径 | 请求 | 行为 |
| --- | --- | --- |
| GET /agreements/:code | 可选version | 当前或指定历史全文,公开可读 |
| GET /consents | 可选scope、page、pageSize | 仅本人grant,page≤1000,pageSize≤50 |
| POST /consents | scope、agreementCode、agreementVersion、requestId | 所见版本确认;requestId为16–64位ASCII字母数字、下划线或横线 |
| POST /consents/:id/revoke | requestId | 仅本人documents/data;共享幂等空间,首次撤销时间不变 |
后端在D:/AAdesk/202609/airz。database/agreement-revision-schema.sql和user-consent-schema.sql仅新增两表,database/schema.sql同步期望结构;本轮只在所属全新隔离库安装,生产未执行。离线权限定义新增五节点及独立运营v2;旧运营v1、客服v2、技术v5和已有账号绑定保持,未迁移正式权限。正式安装与角色分配须使用目标环境的新预检及明确发布授权。
后续发布应先配套部署后端协议接口与两表,再发行依赖这些接口的小程序;缺失接口的普通404不等于明确AGREEMENT_NOT_AVAILABLE,不会触发“未发布”密码登录兼容。运营v2是新模板,已有运营账号不会自动获得权限。发布或回退计划须核对当时真实状态和新增同意数据,不使用整库覆盖。
## 验证与边界
后端最终六套隔离入口:协议351项/86HTTP、后台500/94、会话284/70、资料268/76、短信账户271/54、内容服务56/0,共1730项/380次本机HTTP。协议纯校验40项与离线权限106项另列;失败、中途重复及前轮短信71项不累计。
覆盖精确历史正文、审核/不可变版本、未来/下架、后台角色与撤权、CSRF及旧入口绕过、分页/跨账户、原键补确认、两worker重复请求只一次、SQL故障回滚和会话失效。公开读取与同源发布交错的误404已先复现红测试,再修为同一读快照;验证当次旧版、下一次新版及旧历史保持。后台首轮夹具未关闭合成登录图片验证码,修正仅测试wrapper配置后通过;纯协议首轮输出为JSON被runner误判,精确匹配输出格式后通过,保留原回执。
前端最终结果与复审收口见本轮私密verification.json。受控H5使用导航前全网络拦截,响应只在进程内合成,Browser插件未提供,使用既有Playwright;手机390×844与桌面1280×900。故意注入的404/503/丢回执console与真实运行错误分开记录,合成HTML文字仅测试前端按文本显示,并不代表后台接受HTML正文。
独立审查发现并已通过红绿测试修正:撤销成功后废弃在途列表,防止刷新/筛选/分页的旧GET恢复未撤销状态;同意丢回执后在记录页被撤销,返回全文页的原键可确认“此前已撤销”,清除待确认命令,只有再次明确点击才用新键同意。正常同意成功后去记录页撤销再返回,也恢复显式同意按钮,不自动发送新同意;未知结果仍保留原键。返回记录页时保留离场渲染树,仅使请求代次失效及停止计时,避免退栈过渡时清空记录导致VDOM挂载错误;返回/账户变化仍清理并重新读取,迟到响应不能回填。最终独立只读复审未发现剩余明确P1/P2。
初轮H5完整78项/37合成请求/8POST覆盖全文、历史、记录、登录补确认、404与503区分和账户隔离;该轮是复审修正前源码,不作为最终源码全量验证。最终复审后H5专项48项/25合成请求/9POST覆盖刷新/筛选/分页旧响应、真实返回、已撤销原键收口、正常成功后撤销再返回及显式新同意,0运行错误、1条故意丢回执console、0未知外呼。中间41项及编译回执单独保留,不计入最终数。30项纯源码检查通过,分包及编译分别记录,不跨轮累计。
最终修正后的29页Android class编译于2026-10-12 12:21:51成功,微信小程序编译于12:22:35成功(Asia/Shanghai),日志为android-final-return.log/mp-final-return.log。30项协议源码检查与31项微信产物分包检查均通过,主控已独立复核。最终源码绑定source-manifest.final-return.json、48项页面回执review-final-verification.json与收尾回执final-return-receipt.json,旧12:16编译回执不替代本轮结果。所属H5预览已停止、5176释放,临时浏览器退出,原5174保留;这里只验证开发编译及受控页面,不等同真机或正式发行验收。
后端证据:D:/AAdesk/202609/airz-backups/agreement-consents/1791775959310。前端证据:C:/Users/JUN/AppData/Local/Temp/airz-agreements-ui-20261012及后续专项回执。源码摘要限定本轮相关文件,不是整仓候选或发行包。归档包含私密配置与诊断,不整包分享。
后端清理前28合成表零行、原26表结构及8触发器保持、Redis DB15零键;本轮精确库删除,所属两个服务退出,52786/52787释放,原Python字节码及27份工作树备份保持。692份退出屏蔽回执真实模型/Provider/生产.env加载为0。清理最后归属检查曾失败,但未改脚本的后续只读复核通过,确认精确库目录不存在、所属进程退出与私密ACL正确;原详细stderr未保存,不能确定具体失败点。原检查存在关闭监听后立即核PID的时间窗口,私密检查器仅补同归属进程的15秒退出等待,并保存修改前脚本与摘要。未重跑DROP/服务关闭或启动新服务,cleanup回执已补齐。
FILE-003内部协议与本地记录功能已实现,正式全文、资料用途/保存期限/撤销后资料处理、外部授权凭据、目标端真机及正式运营验收仍待。FILE-001/002/004与真实渠道依各自条件推进;不预填63正式用例通过,不恢复产品AI或自动推进上传/外部查询。
+58
View File
@@ -0,0 +1,58 @@
# 协议草案与资料规则
编写日期:2026-10-13(Asia/Shanghai)。状态:本地草案,待运营信息和业务规则定稿,未写入后台、未正式发布。用户已说明暂时没有对外名称、运营主体和联系方式,本轮保留明确待填位置;按“先不验收”要求,只整理正文和规则,不进行功能验收或开启生产上传。
2026-10-13修订:用户已明确希望照片长期保存,并确认备份副本滚动保留7天。在账户正常使用且仍需平台保管照片期间,不设置上传起30天等固定到期时间;本人删除、用途结束、撤销授权后的依法处理及注销申请仍按明确规则执行。现有服务器私有目录、每日04:20照片备份及独立密钥副本已落实,见[落地记录](../照片存储配置落地-20261013.md);照片仍关闭。物理删除完成后元数据30天仍只是未确认建议,与在线照片和备份期限分别说明。
四份正文使用 UTF-8 无 BOM 纯文本,可在定稿后复制到现有后台“协议正文与审核发布”的完整正文栏。正文中的草案状态、待填和待确认项必须处理完再发布;本目录不提供自动导入或发布脚本。
| 文件 | 协议 code | scope | 内容 |
| --- | --- | --- | --- |
| [用户协议](用户协议.txt) | terms | account-terms | 账户、服务范围、AI和产品结果边界、争议处理 |
| [隐私政策](隐私政策.txt) | privacy | account-privacy | 信息类别、处理用途、AI服务、保存与个人权利 |
| [资料使用协议](资料使用协议.txt) | document-use | documents | 选填照片、敏感内容、本人管理、撤销与删除 |
| [数据授权协议](数据授权协议.txt) | data-authorization | data | 外部查询尚未开放;同意记录只是意向,不产生第三方授权 |
| [照片保存与删除规则](照片保存与删除规则.md) | 内部规则 | 不发布为协议 | 已实现行为、建议期限、备份和清理的最小安排 |
## 需要定稿的信息
| 项目 | 草案标记或建议 | 当前状态 |
| --- | --- | --- |
| 对外平台名称 | 【待填写:平台对外名称】 | 用户暂未提供 |
| 运营公司全称 | 【待填写:运营主体全称】 | 用户暂未提供,不能用技术域名或开发者姓名代替 |
| 运营主体地址 | 【待填写:运营主体联系地址】 | 待提供 |
| 隐私和删除申请渠道 | 【待填写:隐私与删除申请联系方式】 | 须能实际受理;可用专用邮箱或电话,不编造 |
| 版本、更新和生效日期 | 【待确认:正式版本号】、【待确认:更新日期】、【待确认:生效日期】 | 草案日期不等于生效日期;后台 UTC 生效时间另外填写 |
| 账户、评估、对话、反馈、同意及日志的期限 | 隐私政策逐类列出待确认项 | 不从照片期限推导,不宣称当前数据库有自动清理 |
| 照片在线保存规则 | 账户正常使用且仍需本人保管时长期保存,不设固定到期时间 | 用户已确认长期保存;保留本人删除、用途结束、撤销和注销申请的处理要求 |
| 照片备份期限 | 每日备份、滚动保留7天 | 用户已确认并落实;每次成功备份清理超过7天的有效旧包,故障可能延迟清理,需及时处理 |
| 删除后照片元数据期限 | 建议完成文件删除后30天,再删除或去标识化 | 仅建议,现有代码保留元数据,不自动清理 |
| 服务商及存储信息 | 腾讯云 TokenHub 调用 DeepSeek;实际服务主体、存储地域、供应商留存等待核对 | 代码可确认调用路径,不能确认供应商合同或“绝不训练”承诺 |
## 与现有功能一致的边界
照片继续选填,只供本人保存、查看和删除。身份证、银行卡、收入证明、营业执照、银行流水和经营证明照片可能含敏感个人信息;目前照片不进入评估快照、模型请求、OCR或外部查询。普通头像使用公开图片路径,不能用于保存私有证件。
资料授权撤销会阻止使用该同意记录继续上传,不会自动删除已经保存的照片;本人仍可查看和逐份删除。删除先使资料不可读,再移除在线文件,失败保留同一资料 ID 供重试。授权和版本证据、资料元数据以及备份副本不因在线文件删除而自动消失,不能写成“一键清除所有数据”。
上述是代码现状,不是撤回后任意继续保存的依据。仅依赖同意保存的照片,在同意撤回时需依法处理删除;现有接口没有联动自动删除,正式启用前须落实运营处理,不能把用户未再次点击删除当作无限留存许可。
`data-authorization` 保持现有外部查询意向的含义,不能改称已获得银行、征信或第三方凭证,也不能用它冒充现有自填评估或 AI 外发的强制门禁。首次实际外部查询须另行明确渠道、具体数据、目的、费用和有效范围,再落实实际授权机制。
自填评估含身份和财务信息。定稿时需核对其必要性、敏感个人信息的告知与单独同意,以及 AI 请求的处理依据;不能仅发布四份正文就把所有运行流程标为已具备这些条件。本轮未修改评估字段或账户流程。
## 定稿后的后台填写
依次选择对应 code,填写正式标题、版本、UTC 生效时间和处理完所有待填项的完整正文。先保存草稿;实际审核完成后再选择正式发布并勾选审核确认。涉及正文、标题或生效时间的后续修改使用新版本,已发布版本保留。未来生效版本应新增来源并保留当前有效来源,避免提前替换导致当前全文不可用。
外部查询尚未接入时,数据授权正文只描述关闭状态和意向记录;后续开通渠道需重新告知并取得对应范围的有效授权,不能沿用本草案产生无限授权。私有目录/权限和备份已准备;照片服务在主体、有效申请渠道、协议及其余必要规则定稿前继续关闭。
启用照片时需同步核对正文里的关闭状态描述,按当时实际功能修订;正文有变更须使用新版本,不能开启服务后仍展示过时状态。数据查询继续保持未开放,不能随照片启用一同改成已接入。
## 核对依据
主要事实来自现有代码和既有部署记录:[协议实现](../协议全文与同意记录开发-20261012.md)、[照片实现](../私有资料上传开发-20261012.md)、[生产发布](../后端协议与照片发布-20261012.md)、[AI 调用来源](D:/AAdesk/202609/airz/application/common/library/DeepSeekClient.php)、[自填报告外发白名单](D:/AAdesk/202609/airz/application/common/library/AssessmentNeedsReport.php)、[对话上下文与脱敏](D:/AAdesk/202609/airz/application/common/library/AssistantChats.php)。初稿及长期保存修订只在本地完成;服务器只读核对见[存储与备份方案](../私有照片存储与备份方案-20261013.md),随后按用户授权实施的配置和备份见[落地记录](../照片存储配置落地-20261013.md)。本次草案同步只做本地格式校验,不访问生产或调用模型。
联网参考:[个人信息保护法](https://www.cac.gov.cn/2021-08/20/c_1631050028355286.htm)第十九条(最短必要保存时间)、第二十八条/第二十九条(敏感个人信息及单独同意)、第四十七条(删除条件),以及[生成式人工智能服务管理暂行办法](https://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)第十一条(输入和使用记录保护及个人信息处理)。搜索服务未成功,政府门户入口也未成功;改从网信办官方网页取得上述正文作为草案参考。该核对不替代实际主体、业务活动和服务商合同的定稿核对,也不声称已完成截至2026-10-13的全部法规修订检索。
本目录是可审阅的业务草案,尚未正式发布。服务器配置准备已完成,但照片/短信继续关闭;小程序由用户发行,验收继续暂缓。
+42
View File
@@ -0,0 +1,42 @@
数据授权协议
草案状态:未定稿、未生效。编写日期:2026-10-13。
正式版本:【待确认:正式版本号】
更新日期:【待确认:更新日期】
生效日期:【待确认:生效日期】
一、当前状态
【待填写:平台对外名称】由【待填写:运营主体全称】运营,联系地址为【待填写:运营主体联系地址】,咨询和删除申请渠道为【待填写:隐私与删除申请联系方式】。
外部征信或其他第三方数据查询目前尚未开放。平台当前报告以用户自填资料及已配置规则为依据,没有通过本协议开通银行、征信、公积金、社保、税务或其他外部查询。
二、同意记录的含义
您主动确认本协议时,平台仅保存一项可选的本地意向记录,包括所见协议版本、范围、正文摘要、确认及后续撤销时间和请求标识,用于说明您当时阅读和确认了什么。
该记录不等于已取得第三方授权凭据,不产生任何实际查询,不代表已核实身份或资料,不授权平台代表您提交贷款申请,也不授权平台进行不限定渠道、数据、次数或期限的查询。
本协议不替代自填评估的个人信息告知、AI服务所需的处理说明或敏感个人信息的单独同意;这些处理应按实际功能另行满足相应要求。照片的保存与管理使用独立《资料使用协议》,不因本协议确认而获得额外用途。
三、实际查询前必须明确的内容
未来开放实际查询时,应在相应页面逐项说明:实际提供和接收数据的主体、查询产品和渠道、要查询的具体信息、查询目的、本人或企业授权关系、处理方式、费用和扣费条件、查询次数及授权有效范围、结果保存期限,以及撤回和删除方式。
涉及敏感个人信息、向其他独立处理者提供信息或其他依法需要单独同意的处理,应另外履行相应告知与同意要求。渠道自身需要登录、身份核实、签署授权文件或其他凭据时,平台不得以一项本地勾选代替。
您应能在实际查询前作出明确选择。只有对应渠道和授权机制就绪、具体范围明确并取得有效授权后,才可发起该次查询;目前尚未接入,不会因保存本意向记录启动查询或计费。
四、拒绝与撤销
您可以不确认此项可选意向,继续使用不依赖外部查询的自填服务。未查询的数据保持未知,不能被解释为零、良好、已验证或符合申请条件。
您可在“我的同意记录”撤销该意向。撤销后的旧记录保留必要的版本和时间证据,不得用于新的查询。再次取得本地意向也不等于自动满足实际渠道的授权要求。
若未来已有实际查询,撤销对后续处理的影响、已经发生的费用、结果删除和依法必要留存,应在对应渠道规则中预先说明;本草案不承诺尚未接入渠道能够退费、即时撤回已完成请求或删除渠道全部副本。
五、记录保存与权利
本地协议和意向、撤销证据的保存期限:【待确认:协议与同意证据保存期限】。具体个人信息权利和申请方式见《隐私政策》;本意向记录不扩大其他信息的保存范围或期限。
未来渠道、目的或信息范围变化时,需要重新告知并取得对应范围的有效授权,不以本版意向自动涵盖。对记录或处理有疑问,可通过上述联系方式申请说明、更正、撤销或依法删除。
+41
View File
@@ -0,0 +1,41 @@
{
"ok": true,
"documents": 4,
"drafts": [
{
"code": "terms",
"title": "用户协议",
"scope": "account-terms",
"bodyBytes": 5656,
"contentHash": "d10054e46f220b7dc0f65a5b32b1606662a64ba32f49b63cd43dbc56f996ba41"
},
{
"code": "privacy",
"title": "隐私政策",
"scope": "account-privacy",
"bodyBytes": 10683,
"contentHash": "c97bea31c07ba46ed06eeb9573e1ed205ba3501eebfd618c831a0fe96ff617c6"
},
{
"code": "document-use",
"title": "资料使用协议",
"scope": "documents",
"bodyBytes": 6097,
"contentHash": "4c003378814087b097a4a4e5927afd7a5b38d829e2fb4de279c26d6cca14fb60"
},
{
"code": "data-authorization",
"title": "数据授权协议",
"scope": "data",
"bodyBytes": 3600,
"contentHash": "5c273ef7bd0bf5edd7f769d069662318843fffa68f9b1f6c360c1c2eb2932f55"
}
],
"validationTimeIsFixture": true,
"validationVersionIsFixture": true,
"frameworkLoaded": false,
"productionConfigRead": false,
"databaseCalls": 0,
"networkCalls": 0,
"published": false
}
@@ -0,0 +1,61 @@
# 照片保存与删除规则(待定稿)
2026-10-13。本文件区分已确认、已实施及待定稿规则,供本批协议定稿使用。私有目录、每日备份和独立密钥副本已按授权准备,见[落地记录](../照片存储配置落地-20261013.md);照片生产配置仍关闭,正式协议未发布。
同日修订:用户已明确要求照片长期保存,备份副本滚动保留7天。在线照片不采用原建议的上传起30天到期;删除后元数据30天仍只是待确认建议。
## 首版用途与收集范围
照片仅用于按原资料类别保存用户主动选定的文件,供本人查看和删除。资料选填,不进入自填评估、匹配、模型、OCR或外部查询;不得把“上传”解释成审核。类别为身份证正反面、银行卡、收入证明、营业执照、银行流水和经营证明。
身份证和金融资料的收集必要性须与这个用途相匹配。仅本人保管而没有进一步审核用途时,应评估是否确有必要保存完整证件、账户或流水;建议引导遮盖无关敏感字段。不能仅因为存在类别入口就要求每类必传,也不能用头像公开路径保存私有照片。
## 现有操作规则
| 操作 | 目前已实现的行为 |
| --- | --- |
| 上传 | 有效本人会话和未撤销的资料同意记录;单张真实 JPG/PNG,原文件和处理后文件不超过5MiB;边长不超过4096、像素总量不超过400万;重新编码后加密保存 |
| 显示 | 上传后在原卡片显示,卡片菜单查看/删除;同类多份逐份选择,离页后从本人服务端记录恢复 |
| 读取 | 本人登录鉴权后读取;撤销同意不影响本人查看既有照片;没有公开文件地址 |
| 撤销授权 | 禁止基于该同意记录继续上传;不自动删除既有照片,保留本人查看/删除入口 |
| 删除 | 先提交不可读标记,再移除在线文件;文件确认不存在才返回成功;物理删除失败可对原 ID 重试 |
| 删除后记录 | 保留资料元数据和同意关联;不自动清理元数据、授权记录或备份 |
| 未知上传回执 | 手动重试可能另存一份;已提交入表的资料可重新读取,未入表的孤立密文不会自动进入本人卡片 |
| 关闭服务 | 拒绝上传/下载/删除并返回空资料引用;不会自动删除磁盘已有密文。因此已经启用后不能用关闭开关代替退场处理 |
后端所有本人管理接口均要求资料服务可用。因此发生安全事件需要暂时关闭时,应提供有效申请渠道并由授权人员处理本人删除请求,不能把用户长期困在无法删除的状态。
“撤销不自动删除”只是接口现状。仅基于同意保存的照片在同意撤回时需依法处理删除,正式启用前应安排运营人员处理撤销后的删除;不能把用户未再次点击删除当作无限期保留的授权。依法有独立留存依据的,单独限制范围并向用户说明。
## 在线长期保存与其他副本期限
在线照片长期保存和备份滚动7天均已由用户确认,目录与备份任务已准备。完成物理删除后元数据30天仍只是建议,不实施元数据清理或当作已生效期限。
| 数据 | 规则或建议 | 清理方式及目前缺口 |
| --- | --- | --- |
| 照片在线文件 | 账户正常使用且仍需平台为本人保管时长期保存,不设固定到期时间;本人删除、用途结束、撤销授权后的依法处理及注销申请按规则执行 | 原代码没有固定到期删除,符合长期保存方向;落实本人管理和必要删除申请处理,不新增照片到期清理功能 |
| 照片备份副本 | 每日备份,滚动保留7天,用户已确认 | 每次成功备份后清理创建时间超过7天且校验有效的本工具旧包。停机、失败或损坏包可能延迟清理,需及时处理;在线删除不即时清除旧副本 |
| 照片删除后元数据 | 在线文件确认删除后最多30天,再删除或去标识化 | 仅为失败恢复、处理申请和核对删除;现有表没有此项清理,不将剩余记录无限期保留 |
| 同意和撤销证据、账户及报告 | 按独立的最短必要期限定稿 | 不照搬照片期限;需结合实际目的及适用保存义务明确,当前没有自动删除规则 |
长期保存用于持续的本人保管和查看,不代表永久保存承诺或撤销后任意留存。7天备份只涉及滚动备份副本,副本过期不删除在线照片;删除后元数据30天也不适用于仍在使用的照片。首份手动备份为空资料快照,定时触发和实际照片恢复尚未验收;不能将配置准备描述为全部恢复能力已验收。
## 已落实配置与后续处理
私有目录已设为public同级、www:www/0700,实际FPM合成读写通过;备份目录root:root/0700,备份每日04:20运行、有效旧包按7天滚动清理。现有共享密钥另存本机DPAPI CurrentUser副本,独立进程已通过指纹及合成解密校验。照片包只含密文、对应元数据及协议/同意证据,不含密钥或.env;当前没有异地照片副本。具体范围和恢复边界以[落地记录](../照片存储配置落地-20261013.md)为准。
以下保留完整处理顺序,目录、备份与密钥准备已完成,申请渠道、责任人和其余期限仍待定稿:
1. 按已确认的在线长期保存方向,定稿其他副本期限和申请渠道,明确负责删除申请及处理失败的人。
2. 检查并选用 Web 公开目录以外的私有目录;运行用户只取得必要权限,加密文件采用0600,不映射公开下载。目录路径和密钥不写入对外协议。
3. 核实密钥与数据的恢复关系、容量,以及现有备份实际范围。备份同样使用访问控制,密钥按既有密钥体系受控保管;不把私密归档当作无限备份。
4. 形成本人删除、撤销后处理、用途结束和注销申请清单,核对账户/资料 ID/准确文件范围。通过与业务相同的归属和删除状态规则处理,保留最小结果记录;不得只删除数据库行,也不得对未解析目录进行递归删除。
5. 核查未知回执产生的孤立密文:先与完整资料表及正在处理的上传核对,明确归属和安全时间边界,再针对确认的文件清理;不能因暂时查不到行就立即删除正在上传的文件。
6. 按最终确认的期限清理过期备份副本,保留仍在正常使用的在线照片。恢复旧备份时,将备份后的删除和撤销记录作为保护条件,不能让已删除或应处理删除的照片恢复为可读资料。
7. 协议和实际安排对应后,才按明确目标配置和启用。维护或退场时先处理待删除照片和申请,保留必要密钥与管理能力;不能直接关开关后丢弃密钥。
本轮只实施存储、备份和密钥准备,没有业务数据清理、生产恢复或照片启用;不创建新的清理worker或复杂配置层。后续启用时以真实状态核对正文与申请处理安排。
## 已确认方向与待落实条件
在线照片按用户要求长期保存,不设固定到期时间;备份7天已确认并落实。完成物理删除后的资料元数据30天仍只是待确认建议,其余数据期限分别定稿。主体、联系方式和删除申请处理责任暂无;目录/权限、备份及密钥准备已完成,见[落地记录](../照片存储配置落地-20261013.md)。草案、技术检查及配置准备不等于照片已经启用或正式验收。
+60
View File
@@ -0,0 +1,60 @@
用户协议
草案状态:未定稿、未生效。编写日期:2026-10-13。
正式版本:【待确认:正式版本号】
更新日期:【待确认:更新日期】
生效日期:【待确认:生效日期】
一、平台与服务
【待填写:平台对外名称】由【待填写:运营主体全称】运营,联系地址为【待填写:运营主体联系地址】,联系方式为【待填写:隐私与删除申请联系方式】。本协议说明您使用本平台服务时双方的权利和责任。
本平台提供融资相关信息展示、自填资料整理、报告生成、按已配置规则进行的产品条件匹配、产品对比、AI问答、学习内容及反馈等功能。实际可用功能以页面说明为准;尚未开放的功能不因本协议中的说明而自动开通。
当前资料照片仅供本人保存、查看和删除,暂不参与评估;外部征信或其他第三方数据查询尚未开放。平台当前不代您向银行提交贷款申请,不代银行作出审批、授信或放款决定。
二、账户与信息
请使用您有权使用的手机号和账户,妥善保管密码及登录状态。不要向他人提供密码、短信验证码或登录凭据。账户异常或发现他人使用您的账户时,请及时通过上述联系方式与我们联系。
您应在有权提交的范围内提供资料,并根据实际情况填写。为企业填写信息时,应具有相应授权;不得冒用他人身份、提交无权处理的个人信息或篡改证明材料。信息不完整或不准确可能影响报告和匹配结果。
上传照片是可选操作。您可以只填写页面要求的自填信息,不上传照片;拒绝或撤销可选资料授权不会被视为已完成上传、核验或第三方查询。
三、报告、等级和产品信息
报告依据您提交的自填信息、当次保存的规则及可用产品资料生成。资料完整度、准备状态和平台自定的融资综合等级用于解释当前信息状况,不是征信等级、银行授信评级、审批概率或可贷额度。
“符合已知条件”表示满足系统已经掌握的条件,不等于银行接受申请或批准贷款;“待核实”表示依据不足,不能视为符合。展示顺序不构成收益、成本、适合程度或获批可能性的排名。
产品信息、额度范围、利率和期限可能变化,实际条件、费用和合同应向相关机构通过正规渠道核实。历史报告保留当次信息与结果,不因当前产品变化或资料修改自动重新计算。需要新的结果时,请按页面流程修改资料或重新评估。
四、AI服务
AI用于整理和解释已知信息,可能出现遗漏、理解偏差或错误。请结合原始信息核对,涉及贷款合同、费用或其他重要决定时,以合法有效的机构文件和实际核实结果为依据。
平台通过腾讯云TokenHub调用DeepSeek模型服务。为提供报告或问答,平台会按《隐私政策》说明处理必要的结构化自填事实、所选报告和产品上下文,以及您的提问和相关对话。照片目前不作为模型输入。
请勿在问答中输入密码、验证码、完整银行卡号或与问题无关的敏感资料。AI回复不代表平台或任何金融机构已经为您办理业务,不构成授信、获批、放款或结果保证。
五、合理使用与服务变化
请勿利用平台实施违法活动、干扰服务、尝试读取他人资料或绕过访问限制。平台可就账户异常、明确违规或安全事件采取必要的限制措施,并提供问题反馈渠道;依法应当承担的义务和责任不因此免除。
您提交的内容不因上传而转让所有权或其他法定权利。平台仅在提供您所请求的服务及履行法定义务所必需的范围内处理,不取得对照片、身份资料进行无关传播或使用的授权。
功能可能因维护、依赖服务故障或渠道未配置而暂时不可用。页面明确显示失败或未开放时,不代表业务已经完成。对于没有明确幂等保障的上传,请先核对已保存记录再决定重试,避免重复保存。
涉及新的收费服务时,应在使用前另行说明项目、价格、计费条件和退费规则,并取得相应确认。本协议不授权自动扣款,也不代表已经开通第三方收费查询。
六、隐私、删除与停止使用
个人信息的处理范围、保存规则和行使权利方式见《隐私政策》。照片的使用和撤销说明见《资料使用协议》;外部查询意向及其边界见《数据授权协议》。可选授权与账户使用确认分别记录。
您可停止使用服务,并通过【待填写:隐私与删除申请联系方式】申请更正、删除、账户注销或说明相关处理情况。账户注销及全部历史数据删除目前没有自助入口,申请由运营人员核实并依法处理;不以清除本机缓存或退出登录代替服务器数据处理。
七、协议变更与争议处理
协议发生变化时,平台将提供更新后的版本及生效日期。需要重新取得同意的处理活动,应另行告知和取得有效确认;不能仅凭继续使用或旧版记录扩大可选授权范围。已经确认的协议版本和记录按必要保存规则留存。
对协议或服务有疑问,请先通过上述联系方式沟通。无法协商解决的,依照中华人民共和国适用法律,由依法有管辖权的法院处理。本协议不排除或限制您依法享有的权利,也不免除平台依法应承担的责任。
+146
View File
@@ -0,0 +1,146 @@
{
"status": "local-draft-only",
"authoredOn": "2026-10-13",
"timeZone": "Asia/Shanghai",
"formalPublished": false,
"reviewed": false,
"effectiveAt": null,
"documents": [
{
"file": "用户协议.txt",
"code": "terms",
"scope": "account-terms",
"title": "用户协议",
"bytes": 5656,
"sha256": "d10054e46f220b7dc0f65a5b32b1606662a64ba32f49b63cd43dbc56f996ba41",
"unresolvedFields": [
"平台对外名称",
"更新日期",
"正式版本号",
"生效日期",
"运营主体全称",
"运营主体联系地址",
"隐私与删除申请联系方式"
]
},
{
"file": "隐私政策.txt",
"code": "privacy",
"scope": "account-privacy",
"title": "隐私政策",
"bytes": 10683,
"sha256": "c97bea31c07ba46ed06eeb9573e1ed205ba3501eebfd618c831a0fe96ff617c6",
"unresolvedFields": [
"AI服务提供方全称及受托或其他处理关系",
"AI服务方保存期限及处理用途",
"AI服务方有效隐私说明链接",
"协议与同意证据保存期限",
"反馈阅读记录保存期限",
"平台对外名称",
"平台数据存储地域、基础设施服务商和跨境处理情况",
"更新日期",
"服务适用年龄及未成年人处理安排",
"正式版本号",
"照片元数据保存期限",
"生效日期",
"评估报告和对话保存期限",
"账户资料保存期限和注销后处理",
"运营主体全称",
"运营主体联系地址",
"运行记录保存期限",
"隐私与删除申请联系方式"
]
},
{
"file": "资料使用协议.txt",
"code": "document-use",
"scope": "documents",
"title": "资料使用协议",
"bytes": 6097,
"sha256": "4c003378814087b097a4a4e5927afd7a5b38d829e2fb4de279c26d6cca14fb60",
"unresolvedFields": [
"协议与同意证据保存期限",
"平台对外名称",
"更新日期",
"正式版本号",
"照片元数据保存期限",
"生效日期",
"运营主体全称",
"运营主体联系地址",
"隐私与删除申请联系方式"
]
},
{
"file": "数据授权协议.txt",
"code": "data-authorization",
"scope": "data",
"title": "数据授权协议",
"bytes": 3600,
"sha256": "5c273ef7bd0bf5edd7f769d069662318843fffa68f9b1f6c360c1c2eb2932f55",
"unresolvedFields": [
"协议与同意证据保存期限",
"平台对外名称",
"更新日期",
"正式版本号",
"生效日期",
"运营主体全称",
"运营主体联系地址",
"隐私与删除申请联系方式"
]
}
],
"recommendedRetentionDaysNotConfirmed": {
"deletedPhotoMetadata": 30
},
"currentProductionEnabledChanged": false,
"functionalAcceptancePerformed": false,
"backendContractValidation": "格式核对.json",
"frontendRuntimeFilesUnchanged": 14,
"pageCount": 29,
"taskCount": 63,
"supportingFiles": [
{
"file": "README.md",
"sha256": "6aa20826b78d2b372b8d5bf5c58524f3c5f893a61340192f824daf03d07bc7ec"
},
{
"file": "照片保存与删除规则.md",
"sha256": "efd567f527bdcb3494d1b887b0102b688a19bf56a8aa8f524f56943924cc1433"
},
{
"file": "格式核对.json",
"sha256": "ea5055037c00fc8a0af1441c303cde4c8f38e8cfb1c32573f00547f93710bfef"
}
],
"onlinePhotoRetention": {
"userConfirmed": true,
"mode": "long-term",
"fixedExpiryDays": null,
"automaticAgeBasedDeletion": false,
"condition": "账户正常使用且仍需本人保管",
"deletionTriggers": [
"本人删除",
"用途结束",
"撤回作为保存依据的同意后依法处理",
"账户注销申请后的相应处理"
]
},
"photoBackupRetention": {
"userConfirmed": true,
"mode": "rolling",
"days": 7,
"scheduleLocal": "04:20",
"timeZone": "Asia/Shanghai",
"preparedInProduction": true,
"cleanupAfterSuccessfulBackup": true,
"cleanupUsesCreationTime": true,
"onlyValidatedToolArchivesRemoved": true,
"outagesMayDelayCleanup": true,
"onlinePhotosDeletedByBackupExpiry": false,
"firstManualBackupPhotoCount": 0,
"scheduledTriggerVerified": false,
"actualPhotoRestoreVerified": false
},
"productionStoragePrepared": true,
"productionPhotosEnabled": false
}
+61
View File
@@ -0,0 +1,61 @@
资料使用协议
草案状态:未定稿、未生效。编写日期:2026-10-13。
正式版本:【待确认:正式版本号】
更新日期:【待确认:更新日期】
生效日期:【待确认:生效日期】
一、谁处理资料
【待填写:平台对外名称】由【待填写:运营主体全称】运营。联系地址为【待填写:运营主体联系地址】,资料处理和删除申请渠道为【待填写:隐私与删除申请联系方式】。
本协议只说明您主动上传照片后的保存、本人查看和删除。照片功能目前尚未开放;正式开放前,平台应提供有效正文并落实所列保存和删除规则。
二、照片是选填资料
上传完全由您选择。您可以不上传照片,继续使用不需要照片的自填评估功能;拒绝上传或撤销资料授权不代表已经核验资料,也不会自动产生第三方查询。
可选类别包括身份证正面、身份证反面、银行卡、收入证明、营业执照、银行流水和经营证明。平台实际收到的信息以您选定照片的可见内容为准,可能包含姓名、证件号码、金融账户、收入和交易信息,以及您或他人的其他个人信息。
照片可能包含敏感个人信息,泄露或不当使用可能影响身份或财产安全。仅提交您有权处理且与保存目的有关的资料;可以遮盖无关号码、交易或第三人的信息。不要提交密码、支付密码、短信验证码、完整安全校验码或没有必要的他人资料。
三、用途和处理方式
当前照片用途限于按类别保存您主动选择的文件,供您本人在资料卡片查看、管理和删除。平台不从照片提取数据,不进行OCR、真实性核验,不将照片加入评估快照或模型输入,也不向银行、征信机构或其他查询渠道发送照片。
上传成功仅代表文件已保存,不表示资料真实、已审核、已完成征信查询或已取得贷款申请资格。照片不改变自填报告和既有匹配结果,也不形成授信、获批或放款承诺。
平台接收JPG或PNG图片,单张不超过5MB,并检查真实图片格式、尺寸和解码结果。图片会重新编码后加密保存在Web公开目录以外,因此保存文件可能与您选择的原始文件字节不同。平台不保留用户原文件名作为公开地址。
四、本人查看与删除
上传后的照片直接显示在原上传卡片,通过卡片菜单查看或删除;同类多份照片可逐份管理。读取和删除需要有效登录且资料属于当前账户,不提供可供他人直接打开的公开地址。
上传回执未知时,请先重新查看已保存资料,再决定是否手动重试;重试可能多保存一份照片。平台不把网络超时显示成保存成功,也不保证普通上传具有永久去重能力。
删除会先使指定照片不可读取,再尝试移除在线文件;确认在线文件已移除后才显示删除成功。删除未完成时,可对同一照片重试或请求协助。删除不修改既有自填评估及报告,因为照片目前没有绑定评估。
五、保存和备份
照片在线保存规则:为供您持续保管和查看,在账户正常使用且您仍需平台提供照片保管服务期间长期保存,不设置自上传起计算的固定到期时间。您可主动删除;保管用途结束、撤回作为保存依据的同意或申请注销时,按相应删除和申请规则处理。依法确需保留的部分另行说明依据和范围。
备份副本:每日备份并滚动保留7天,每次成功备份后检查并清理创建时间超过7天的旧副本。在线删除不会立即清除历史备份;备份仅用于受控恢复,恢复时排除已删除或应按撤销、用途结束、注销申请处理删除的照片。停机或任务失败可能延迟清理,恢复运行后应及时处理;备份过期不会删除仍在使用的在线照片。
删除后资料标识、类别、所属账户、同意关联和删除时间等最小元数据的保留规则:【待确认:照片元数据保存期限】。
同意与撤销证据的保留规则:【待确认:协议与同意证据保存期限】。
上述备份、元数据和证据规则需在正式开放前明确并落实。长期保存不影响您的删除和撤回权利。在线删除不代表备份副本和所有关联记录立即同时删除;备份应受同样的用途和访问限制,在明确期限内清理,恢复时不得将已删除照片重新开放读取。
六、撤销与后续用途
您可以在“我的同意记录”撤销资料使用授权。撤销后,不能使用该同意记录继续上传;在删除处理完成前,此前保存的照片仍可由本人查看和删除。现有撤销入口不会联动自动删除历史照片,您可在原卡片删除,或通过申请渠道要求处理。
仅基于您的同意保存的照片,在该同意撤回时,运营人员应依法处理删除,不以您尚未再次点击删除作为继续保存的无限授权。依法仍需保留的信息应限制在必要范围,并说明相应依据和处理方式。
如果您再次主动同意当前有效版本,可使用新的有效同意记录进行后续上传;平台不会因打开页面或返回资料卡片自动替您重新同意。
今后需要把照片用于其他目的、识别或外部核验时,须先说明具体目的、必要照片范围、处理方和保存规则,并依法取得有效授权。本次同意不包括这些未来用途。
七、确认的范围
当您阅读本协议并主动确认时,仅对本协议说明的照片保存和本人管理范围作出选择。平台记录所见版本、范围、正文摘要和确认时间;每次上传仍需您主动选定照片。确认本协议不代表已经上传、已经使用任何证件、进行贷款申请或授权第三方查询。
其他个人信息权利和申请方式见《隐私政策》。本协议不排除您依法享有的删除、撤回或其他权利。
+91
View File
@@ -0,0 +1,91 @@
隐私政策
草案状态:未定稿、未生效。编写日期:2026-10-13。
正式版本:【待确认:正式版本号】
更新日期:【待确认:更新日期】
生效日期:【待确认:生效日期】
一、我们是谁
【待填写:平台对外名称】的运营主体为【待填写:运营主体全称】,联系地址为【待填写:运营主体联系地址】。关于个人信息、更正、删除、撤销和账户注销的申请,可通过【待填写:隐私与删除申请联系方式】提出。
本政策说明平台实际使用的信息及功能边界。照片上传目前关闭,外部数据查询尚未开放;相关条款用于说明这些功能开放前需要告知的范围,不表示平台已经开展这些处理。
二、我们处理哪些信息
1. 账户与登录:手机号、账户标识、密码验证信息和登录状态,用于识别账户、验证登录和保护会话。密码验证信息使用散列方式保存;请勿向我们提供与登录无关的密码或验证码。短信渠道当前未接入,不会因本政策而开始发送短信。
2. 可选账户资料:您设置的昵称和头像,用于本人资料展示。头像通过普通图片地址提供,不能上传身份证、银行卡或其他私有证明材料作为头像。
3. 自填评估资料:按个人或企业页面的实际字段,可能包括姓名、身份证号码、联系电话、城市、职业、婚姻状况、任职或企业信息、统一社会信用代码、收入或营收、经营时长、社保和公积金情况及基数、房车和其他资产状况、负债、贷款月还款及信用卡相关金额和数量。这些信息用于保存您提交的评估资料、生成报告及执行已配置的产品条件规则。
身份证号码及金融相关信息可能属于敏感个人信息;不当处理可能影响身份或财产安全。平台应以具体目的和必要范围处理,在需要单独同意的活动发生前另行告知并取得有效同意,不能以一般政策确认取代该步骤。
4. 报告及服务记录:提交记录、资料版本、报告、规则结果、所选产品快照、生成状态,以及您主动发送的AI提问和相关对话,用于提供本人历史、上下文问答、失败恢复及定位服务问题。
5. 可选照片:在照片功能开放且您另行同意《资料使用协议》并主动上传后,处理您选择的照片内容、资料类别、所属账户、资料标识、关联同意记录及上传和删除时间。照片可能包含身份证、银行卡、收入证明、营业执照、银行流水或经营证明中的信息,具体以您实际选择的内容为准。
6. 反馈和阅读记录:您提交的反馈内容、处理回复、站内通知,以及主动标记的课程阅读状态,用于回复问题、展示通知和记录学习进度。
7. 同意和运行记录:所见协议版本、正文摘要、确认范围、确认和撤销时间、请求标识,以及服务所需的访问时间、网络地址、错误状态和调用用量记录,用于证明操作范围、保护账户和排查问题。我们不要求您在反馈或日志中填写密码、验证码或模型密钥。
三、AI处理与服务提供方
平台通过腾讯云TokenHub调用DeepSeek模型生成文字。报告请求使用必要的结构化数值、枚举和规则依据;当前报告外发路径不包含照片、完整身份字段或任意自由文本说明。上下文问答会发送所选报告和产品的必要信息、您的问题及相关历史对话。对话中部分常见身份号码会作格式脱敏,但不能保证识别所有敏感表达,请勿主动输入无关隐私资料。
模型调用不等于征信查询、银行审批或照片核验。现有照片不发送给模型,不做OCR或自动核验。
实际AI服务提供方和处理关系:【待确认:AI服务提供方全称及受托或其他处理关系】。
AI服务方隐私说明与数据使用规则:【待确认:AI服务方有效隐私说明链接】。
服务方对输入、输出和调用记录的保存及处理限制:【待确认:AI服务方保存期限及处理用途】。
我们不会用本政策给予服务方将您的资料用于无关目的的无限授权;是否存在训练用途或其他处理,应按实际合同和服务规则核对并明确告知,不能作未经核实的承诺。涉及向其他独立处理者提供、敏感个人信息或其他依法需要单独同意的情形,须在相应处理发生前履行适用的告知和同意要求。
照片目前不提供给贷款机构、征信机构或AI服务方。未来开通新的渠道或增加新的处理用途时,应另外说明接收方、具体信息、用途和权利方式,依法取得相应确认后才开展处理。
四、设备权限与拒绝的影响
更换头像或选择照片时,由您主动调用系统的照片选择能力;使用拍照时可能由系统提示相机权限。是否需要系统权限以及权限范围,以实际客户端和系统提示为准。拒绝后相应选图或拍照操作可能不可用,仍可使用不依赖该权限的功能。
平台不会将上传私有照片作为完成自填评估的必选条件。拒绝可选照片授权时,不会视为您已上传、已查询或已授权第三方处理。撤回相机或相册权限不会自动删除已提交到服务器的文件。
五、保存位置与保护措施
账户和服务记录保存于平台的服务环境。实际平台数据存储地域、基础设施服务商及适用的跨境情况:【待确认:平台数据存储地域、基础设施服务商和跨境处理情况】。不得在未核实前声称所有数据始终在同一地域或绝不出境。
私有照片开放后,使用服务器Web公开目录以外的目录加密保存;读取和删除校验登录状态及所属用户,不提供公开文件地址。普通头像和私有照片使用不同存储方式。照片加密不代表账户、自填评估、对话和全部数据库字段均使用同样加密措施。
我们采取与具体处理活动相适应的访问控制和运维措施,并限制无关访问。安全措施不能保证任何系统绝无风险;出现影响您权益的事件时,我们将依法采取补救和告知措施。
六、保存期限与删除
信息保存以实现明确目的所需的最短必要期限为原则;法律另有保存要求的,按适用要求处理。各类信息的具体规则应在正式发布前填写,不能用以下空项作为无限期保存依据:
账户与登录资料:【待确认:账户资料保存期限和注销后处理】。
评估资料、报告及对话:【待确认:评估报告和对话保存期限】。
反馈与阅读记录:【待确认:反馈阅读记录保存期限】。
私有照片在线文件:为供您持续保管和查看,在账户正常使用且您仍需平台提供照片保管服务期间长期保存,不设置自上传起计算的固定到期时间。您主动删除、照片保管用途结束、撤回作为保存依据的同意或申请注销时,按相应删除和申请规则处理;依法确需保留的部分另行说明依据和范围。
照片备份副本:每日备份并滚动保留7天,每次成功备份后检查并清理创建时间超过7天的旧副本。在线删除不会立即清除历史备份;备份仅用于受控恢复,恢复时排除已删除或应按撤销、用途结束、注销申请处理删除的照片。停机或任务失败可能延迟清理,恢复运行后应及时处理;备份过期不会删除仍在使用的在线照片。
照片删除后的元数据:【待确认:照片元数据保存期限】。
协议版本、同意与撤销证据:【待确认:协议与同意证据保存期限】。
访问、安全和调用记录:【待确认:运行记录保存期限】。
您可通过原资料卡片逐份删除照片。系统先使该照片不可读取,再移除在线文件;在线文件确认移除后才显示删除成功。失败时可对同一资料重试,或通过联系方式申请协助。在线文件删除不代表历史同意记录、资料元数据和备份副本同时清除;这些副本按已明确的期限和删除规则处理,不得用于恢复继续提供已撤销的服务。
退出登录、清除本机缓存或删除客户端不会自动注销服务器账户,也不会自动删除评估历史和照片。需要处理服务器信息时,请使用相应删除入口或向我们提出申请。
七、您的权利与申请方式
您可以依法了解、查阅、复制、更正、补充、删除相关个人信息,限制或拒绝相应处理,撤回基于同意的可选授权,以及申请账户注销。您可以通过本人资料、历史、同意记录和照片卡片使用已有入口,其他申请可通过【待填写:隐私与删除申请联系方式】提出。
“我的同意记录”可以撤销资料和数据的可选授权。用户协议和隐私政策的账户使用确认在该页不提供撤销按钮,这不限制您通过申请渠道行使法定权利,也不等于所有信息处理的同意永远不可撤回。
撤回可选资料授权会停止基于该授权的新上传;在删除处理完成前,本人仍可查看和删除此前保存的照片。现有撤回和删除是不同操作,不会自动同时完成。仅基于您的同意保存的照片,在该同意撤回时由运营人员依法处理删除,不以您尚未再次点击删除作为无限留存依据。撤回不影响撤回前已经依法开展的处理,其他有独立合法依据而必须保存的记录仅在必要范围内保留,并说明相应依据和范围。
我们会核实申请与账户的关系,核实只使用必要信息,不要求为普通申请额外上传整套证件。无法立即删除、需要补充核实或依法不能处理某项请求时,将说明原因、处理范围和可继续采取的方式。
八、未成年人、变更与联系
服务面向能够独立使用融资信息服务的用户。未成年人应避免提交身份、财务或证件资料;如您或监护人发现有关不当处理,可通过上述渠道申请处理。适用的年龄范围和未成年人处理安排:【待确认:服务适用年龄及未成年人处理安排】。
处理目的、信息类别、服务方或权利方式发生重大变化时,我们会提供更新说明;依法需要重新取得同意的,不以旧版政策确认自动覆盖。旧版正文和必要的确认记录用于解释您当时所见内容,不作为无限扩大用途的依据。
@@ -0,0 +1,45 @@
# 协议、账户与照片后端发布(2026-10-12)
用户明确要求“就一个服务器,没有专门的测试服务器,直接部署吧,小程序我自己部署”。复用既有`https://airz.hqling.cloud`、后端`/www/wwwroot/xctai.cn`、PHP7.4、MySQL `127.0.0.1:3306/airz`及已保存宝塔维护通道;不再等待独立测试服务器。
## 本次范围与执行计划
- [x] 只读核对线上91基线文件、26张表、三项服务、活动评估/调用及队列。当前版本profile-grade-r4,三服务active、活动工作和队列为0,三个新表不存在。
- [x] 固定候选29文件(26运行、3增量DDL;7已有替换、22新增)和87保护文件。已有源码与本地HEAD规范化后相符,生产CRLF保持。原.env与模型配置保持;小程序源码改为production并填写既有HTTPS API,用户自行发行。
- [x] 本地23 PHP语法、14发布守卫、26照片/30协议源码检查通过,生产PHP扩展齐全。前轮168照片HTTP/351协议HTTP、32实际前后端H5与29页目标端编译为已有证据,不跨轮累计。
- [x] 上传私有发布包并核验,预检权限增量只允许协议五节点与独立运营v2模板,已有角色及账号绑定保持。
- [x] 临时阻断外部入口、停止空闲worker/FPM;备份本批before源码、site/Nginx、26表灾难dump及数据/结构/权限摘要,备份核验后才替换。
- [x] 定向发布29文件,安装三张空新表和六项权限元数据,仅更新资源版本;没有正式正文/账号/产品/同意记录导入。短信及照片上传默认关闭,现有模型业务设置保留,本次烟测零模型调用。
- [x] 维护内核验116源码/保护文件、26旧表数据及结构、3空新表、原权限/绑定、27项HTTPS接口/静态资源及精确回退干跑。
- [x] 移除维护入口并恢复FPM/worker,31项公网可信TLS检查和worker心跳通过;临时传输/凭据已清理,37个私密备份文件下载核验,宝塔退出并关闭。Nginx仅本站增加错误响应保留配置,详见下方结果。
## 生效边界
本次部署协议管理与同意记录、照片接口及默认关闭的短信配套,不生成正式协议或合成正文。正式terms/privacy/document-use/data-authorization须由后台逐份填写、审核发布。正式资料用途、保留期限、撤销后处理、私有目录/ACL及密钥恢复仍待确认,所以照片上传不在本次开启;短信供应商/签名/模板未接入,短信登录和验证码改密/换绑不可用,既有密码登录保持。
小程序由用户从当前29页源码重新编译和发行;当前配置为production、`https://airz.hqling.cloud/api/v1`。本轮不代上传微信或发布H5,微信真机未签收。融资28项、63任务、同事产品库及产品AI回撤保持。
## 回退
维护期间只能按本批before恢复7已有文件,新文件按当前摘要核对后移回私密归档;恢复原site资源版本和Nginx。权限回退只在没有新绑定且新增元数据未变时删除本次精确新增ID,不改旧角色或账号绑定。三张空增量表保留,不以整库dump覆盖用户/内容/产品或后续业务数据。恢复上线后若有新数据,需重新审阅定向恢复条件,不直接重跑旧脚本。
## 发布结果
后端版本`20261012-agreement-documents-r1`已在[现有服务器](https://airz.hqling.cloud)部署完成。以下时间均为2026-10-12、Asia/Shanghai:
| 阶段 | 时间 | 结果 |
| --- | --- | --- |
| 维护/备份完成 | 17:00:39 | 原7源码、site/Nginx、26表快照和灾难dump核验 |
| 后端更新 | 17:00:58 | 29文件、三空表、五权限节点/运营v2模板及资源版本 |
| 维护内验证 | 17:04:36 | 116源码、旧26表/原权限保持、27项HTTPS通过 |
| 恢复外部访问 | 17:05:07 | 三服务active、维护入口移除 |
| 最终服务器核验 | 17:10:26 | worker ready/idle、心跳0秒、活动工作/队列0,环境/非版本site保持 |
| 服务端清理 | 17:13:38 | 归档root/700/600、传输缓冲/临时凭据清理,三服务active |
首次维护内验证失败的原因是生产全局`fastcgi_intercept_errors on`将协议404 JSON替换为Nginx HTML错误页。已仅在本域名server增加`fastcgi_intercept_errors off`,全局配置不变;Nginx语法检查和27服务器/31公网可信TLS检查通过,四协议均明确`AGREEMENT_NOT_AVAILABLE`。敏感路径仍404,匿名同意/私有内容401,短信未配置503且未发送真实短信。维护标记已移除,Nginx只有这项本站修正,不能描述为完全原样恢复。
本次没有业务数据导入、账号绑定、生产评估提交或模型烟测调用。旧26表数据/结构及原权限逐项核验;新三表为空,只追加协议五节点和独立运营v2模板。新旧账号仍按原绑定访问后台,正式角色分配未实施。
服务器私密归档:`/root/airz-agreement-documents-release-1791794619319`。本机[发布回执](D:/AAdesk/202609/airz-backups/production-release/airz-agreement-documents-1791794619319/release-receipt.json)及`production-backup`保留37个逐项SHA核验的备份文件,包括完整26表dump、7个before源码、site/Nginx、权限快照及阶段回执;只允许当前Windows用户与SYSTEM访问,不含生产.env副本。失败校验和修正证据另行保留,不能重跑已完成维护脚本。
前端14个相关文件摘要已冻结,`APP_ENVIRONMENT=production`、正式API为`https://airz.hqling.cloud/api/v1`,test为空、demo关闭。用户须从当前完整29页源码重新编译发行,并确认微信request/uploadFile/downloadFile合法域名;本轮未代发微信/H5,也未重新编译目标端。正式协议正文、短信渠道及照片正式配置仍待,代码部署不等于照片可用或正式签收;此前本机测试证据不代替真机验收。无提交、推送或PR。
+119 -3
View File
@@ -1,6 +1,122 @@
# AI 融资平台开发文档
> 版本:v1.90 · 基线日期:2026-09-22 · 更新日期:2026-10-10 · 状态:产品AI条件分析与后台人工复核已回撤,见1.03及进度2.22/1.32;小程序1.0.7已确认发行、三项前端反馈修正见开发进度2.05/1.15与本文件0.86(修正版待重新发行);万元统一与profile-grade-r4配套发布见开发进度2.04/1.14(后端已发布、小程序待用户发行);信用贷生产r2与社保/公积金开发见开发进度2.03/1.13;综合等级/匹配/PDF优化见开发进度2.02/1.12;搜索竞态/反馈持久恢复前端组合检查见开发进度2.01/1.11;候选源码/验收用例/阻塞收口见开发进度2.00/1.10;微信账号分包模块缺失修复见开发进度1.99/1.09;security-r3后端与后台已发布见开发进度1.98/1.08;后台内容写入CSRF防护见开发进度1.97/1.07;内容发布/整篇课程已读组合回归见开发进度1.96/1.06;反馈处理/站内消息组合回归见开发进度1.95/1.05;核心权限矩阵与后台组合回归见开发进度1.94/1.04;会话撤销/跨模块权限组合回归见开发进度1.93/1.03;核心主流程组合回归/独立Linux路线取消见开发进度1.92/1.02;反馈/消息、无PHP管理端入口及后台/内容r2已配套生产发布,真实Linux隔离条件/执行方案准备见开发进度1.91/1.01,合成告警链统一预检/证据汇总见开发进度1.90/1.00,dead对账/人工恢复审计见开发进度1.89/0.99,outbox调度/租约退避/模拟接收幂等见开发进度1.88/0.98,告警原子存储/崩溃恢复见开发进度1.87/0.97,告警状态处理见开发进度1.86/0.96,Linux/systemd守护与告警离线准备见开发进度1.85/0.95,部署恢复手册/只读证据核验见开发进度1.84/0.94,导入中断/耗时开发证据见开发进度1.83/0.93,队列生命周期开发证据见开发进度1.82/0.92,密钥恢复开发证据见开发进度1.81/0.91,r2隔离恢复见1.80/0.90,生产发布记录见1.79/0.89、交接和后端deployment-baota.md。8条FAQ已首次导入,正式权限模板已新增但未绑定账号,课程整篇阅读/一次已读保持;本批零模型烟测通过。小程序用户自行发行,最新确认1.0.7已发行,实际发行时间/上传摘要仍待绑定;此前1.0.6的2026-10-09 09:35为历史记录,暂不正式验收;H5未部署、真机/多设备及正式运营验收待办。积分默认关闭、DEC-POINT待确认、原表未迁移,供应商/真实资料依各自条件推进。下方日期段落保留历史契约和开发快照。
2026-10-14 当前状态:产品原16字段已经正式还原,后端/管理端20261014-product-ai-r1已配套发布;自定义AI条件及 `{X}` 模板已上线。小程序申请条件保持,不新增AI来源说明,详情/列表/对比展示已同步,微信29页编译通过,用户自行发行。最新技术范围见1.17与[新会话交接](新会话交接.md);下方“未部署/未迁移”为历史状态,不作为当前待办。
2026-10-14 最新:推荐/不推荐均为“关联条件、模板内容、操作”三列,不推荐无触发结果。模板只支持 `{X}`,AI 判断正文是否适用并按顺序返回槽值;新 `aiTemplates` 五键配置与 `templateRenderings` 八键输出复用既有评估调用,服务端复核事实、数值单位、规则结果及保存前重算。配置仍放原 `other`,历史冻结结果不重算。本轮未部署或调用真实模型;详见[设计补充](superpowers/specs/2026-10-14-product-custom-ai-design.md)及[交接](新会话交接.md)。
2026-10-14 最新:按用户纠正,仅准入/加减分的“条件项”和推荐/不推荐的“关联条件”第一列改为普通手输,其他列保留结构控件。隐藏内部英文编号,地区/用途及枚举多选改为勾选;改名保留值、对象与分值,模板按中文名称关联及回显。自定义硬/软条件与独立文字模板接入现有一次 AI 评估,标准规则保持,服务端算分、排序和保存前复算;旧快照不重算。产品仍为原16字段,配置保存在other,本轮无需DDL。代码及隔离Chrome/假模型任务验证通过,尚未部署本轮修改或调用真实模型。 配置、冻结兼容与验证边界见[设计](superpowers/specs/2026-10-14-product-custom-ai-design.md)和[最新交接](新会话交接.md)。
> 版本:v2.04 · 基线日期:2026-09-22 · 更新日期:2026-10-14 · 状态:后端及管理端20261014-product-ai-r1已发布,产品原16字段、31产品与历史报告保持;小程序配套已完成,29页编译及111项检查通过,用户自行发行,新版版本与时间尚未反馈。当前技术范围见1.17、进度2.36/1.46和新会话交接。真机、真实模型质量及正式验收尚未完成,用户暂缓验收;照片/短信关闭、协议待填及长期照片/7天备份安排保持。29页面/63任务/融资28项不变,下方保留历史状态。
### 1.17 2026-10-14 自定义AI条件、模板与小程序配套发布
产品物理表已恢复原16字段名,保留事务引擎和较长产品名等兼容改进,配置保存在原other;不新增匹配表列或独立有效期字段。线上后端/管理端版本20261014-product-ai-r1,API为https://airz.hqling.cloud/api/v1。原16字段发布及本轮11文件配套发布均已完成,31产品和历史报告保持。
管理端第一列手输,标准名称继续关联现有规则,自定义硬/软条件交给AI三态判断,硬条件不符不推荐、资料不足不排名,软条件满足时按配置加减分;基础分默认80可配置,最终分值统一限制0—100。不推荐无触发结果,推荐/不推荐配置用aiTemplates,关联条件与正文决定适用性,多个 `{X}` 按出现顺序填入各槽;AI输出templateRenderings与自定义判断合入现有一次模型请求。服务端校验冻结版本/摘要、事实引用、精确槽数和金额/时长单位,保存前复算;标准规则、算分/排序及历史结果语义保持。AI工作最多32条、模板最多32槽、关联描述总预算48KB,超量明确尚未判断;规则及格式细节见[当前设计](superpowers/specs/2026-10-14-product-custom-ai-design.md)。
小程序申请条件模板/解析保持,不新增AI自填来源说明;可选产品信息未填时隐藏,详情及对比不显示有效期。列表/对比使用服务端真实匹配度及机构Logo,旧结果不造分,资料不足与条件不符分开显示。微信29页编译及111项展示/路由检查通过,小程序由用户发行,版本/时间仍待反馈;H5有既有layout类型声明警告,真机未验证。
发布核验与恢复说明见[后端发布记录](D:/AAdesk/202609/airz/database/product-ai-release-20261014.md)及[新会话交接](新会话交接.md)。发布时715项本地纯检查、133项文件摘要、现有6产品编译、30表维护期一致性、29服务器/31公网HTTPS及回退预检通过,备份已保留;没有付费模型质量验证或正式验收。照片长期保存/备份滚动7天、协议待填及照片/短信关闭安排保持。本轮只同步文档,不启动服务、重放发布或修改运行代码。
### 1.16 2026-10-13 产品原字段与文本配置
用户已选择恢复16个原字段名,匹配参数、可选日期和内部版本收纳到原other文本配置;expectation/quota恢复为额度/期限说明。管理端仅产品名称、适用对象为基本必填(新建默认企业),机构/类型/来源/额度/期限/利率/日期等可空,填写后仍校验;资料不足显示待核实,不进入推荐排名。保留InnoDB事务、varchar(100)产品名与状态默认草稿,旧冻结报告不重算。
本轮2120项检查通过:纯转换/发布/编辑/规则/评分、16与28字段实际模型及目录、评估提交/重评/助手/匹配接口、历史报告/PDF、迁移拒绝与回撤、UTS及实际页面;管理端15项、小程序受控H5 8项,1280×900和390×844无应用控制台错误。微信29页编译成功。失败和重复运行不累计,前轮582/980项不另加。
本轮只在专用本机合成MySQL/Redis/HTTP/H5环境验证,不读取正式.env、不访问正式库、不调用真实模型、未部署/提交/推送。正式产品表仍未迁移;发布应配套后端与管理端,不能只覆盖原三个文件。小程序仍由用户发行,真机、正式数据预检和用户验收尚未完成。 证据根:D:/AAdesk/202609/airz-backups/product-original-storage-20261013,verification.json绑定源码摘要,migration-qa.json记录11项演练,admin-qa.json/mobile-qa.json与截图记录页面验证。合成迁移保留before原表与精确回撤SQL;工具不能自动暂停写入,生产转换须另按目标、备份和维护窗口授权。
### 1.15 2026-10-13 产品表原字段核对与有效期选填
ProductPublication允许生效/失效日期留空,ProductEditor已有空字符串转NULL逻辑;ProductCatalog查询、ProductMatching冻结过滤和MatchRecords当前可用性同步接受可空边界。填写日期仍校验格式/起止/过期,未来生效、下架规则保持;UTS分别显示无有效期/单边界/双边界。582项纯逻辑及实际本机合成MySQL检查、7PHP语法/差异检查、微信29页编译通过,未部署。原16字段与当前28字段还原方案、必要的读写适配及完整数据转换见[核对记录](产品表还原与选填核对-20261013.md);表还原范围仍待用户选择,不把日期选填当作其他上架字段全部可空或物理表已还原。
### 1.14 2026-10-13 产品机构库原型配置与匹配
产品编辑改为原型八标签,ProductEditor接收对象数组、基础版本、精确万元金额与规范准入条件,ProductPresentation验证并在other列保存product-presentation-v1扩展JSON。硬条件fail/unknown仍阻止推荐;软条件满足时在可配置基础分80上加减分并限制0—100,缺事实/核验来源不计分。新目录product-matching-v2与product-score-descending-v1固定配置、排序和理由,旧v1快照仍按ID执行且无分数。MatchRecords/ProductCatalog明确投影Logo、资料清单及顾问电话,UTS数据服务保持账户/revision/快照验证。资料缺失指标不从AI或照片推断,不增加原融资采集项。980项专项检查和微信29页编译通过,无新增表,本轮未部署;详情及截图见[实施记录](产品机构库原型实施-20261013.md)。
### 1.13 2026-10-13 私有存储与备份落地
用户确认在线照片长期保存、备份副本滚动7天。私有目录/www/wwwroot/xctai.cn/private-documents为public同级、www:www/0700,实际FPM合成读写通过;既有.env只追加照片directory及enabled=false。root-only备份目录/www/backup/airz-private-documents、Web外工具和独立cron每天04:20(Asia/Shanghai)已安装,原数据库备份保持。备份采用REPEATABLE READ只读快照和不可变密文,缺文件或异常失败;每次成功后只清理超过7天的有效旧包,未知/损坏包不自动清理,故障可能延迟,在线照片和元数据不清理。
独立密钥副本用本机DPAPI CurrentUser和当前用户/SYSTEM ACL保护,通过独立进程指纹及合成GCM恢复校验;不轮换原共享密钥,不生成明文密钥文件,照片包不含.env或密钥。换机/重装需受控迁移,当前没有异地照片副本。Windows文件测试6通过/1跳过、Linux7项通过,首份手动空快照校验及10:55:12最终核对通过;定时首次触发和真实照片恢复尚未验证,不计正式验收。
完整配置、证据和精确回撤见[落地记录](照片存储配置落地-20261013.md),工具入口见[备份说明](D:/AAdesk/202609/airz/database/private_documents_backup.md)。草案同步7天备份并重新核对摘要,照片/短信仍关闭、三新表0行、正式正文未发布;主体/有效申请渠道暂无,删除后元数据30天及其他证据期限仍待确认。宝塔退出、专用浏览器关闭、临时脚本清理,失败回执保留,以final-verification-retry.json及isolation-hardening.json接续,不重放已完成配置或再导出密钥。用户暂缓验收,小程序自行发行,进度2.32/1.42;下一项在定稿条件具备后发布协议并按明确范围开放照片,不发布占位正文。
### 1.12 2026-10-13 照片长期保存规则
按用户明确要求,在线照片在账户正常使用且仍需本人保管时长期保存,不设固定到期时间;本人删除、用途结束、撤销授权后的依法处理及注销申请仍按规则执行。取消原在线30天建议;备份7天/物理删除完成后元数据30天独立待确认,不套用于仍在正常使用的在线照片。
已同步[照片规则](协议草案/照片保存与删除规则.md)、隐私/资料协议正文和[存储方案](私有照片存储与备份方案-20261013.md),重新核对四正文格式并生成草案摘要。代码没有照片固定到期逻辑,此次无需改运行代码,不新增清理任务或配置。只做本地修订,未生产访问或发布;照片/短信仍关闭、正式正文未发布,主体/申请渠道及实际备份恢复安排仍待。用户暂缓验收,小程序用户发行,进度2.31/1.41;下方30天为已被本节取代的历史建议。
### 1.11 2026-10-13 私有存储与备份方案
[只读结果和具体配置](私有照片存储与备份方案-20261013.md)已整理。推荐public同级 `/www/wwwroot/xctai.cn/private-documents`,www:www/0700、加密文件0600,符合现有open_basedir;候选尚未创建,无需放宽immutable的.user.ini或增加公开映射。FPM真实运行身份与父目录/ACL/磁盘已核对,约166GiB剩余是时点容量,不是长期预留。
已有MySQL每天04:00本机备份、保留3份,最新2026-10-13 04:00:01 gzip可读且含协议/同意/资料三表;未做恢复。该任务不含照片文件/解密密钥,发布回退包也不含.env。生产共享密钥格式与内存往返有效,但独立备份及恢复证据仍缺;拟将照片密文/同一恢复点元数据和必要证据作为受控集合、密钥单独保管,恢复前排除已删除/到期/撤销后应处理的照片。同盘备份不等于主机故障保护,期限和执行人未落地。
10:10:47最终三服务active、五份生产配置摘要保持,在线/备份候选目录均不存在;本轮只有只读查询及本地方案更新,宝塔退出关闭,凭据不落仓库。未生产修改/模型/短信/验收/发行,四份正文保持;照片/短信关闭、正式正文未发布,期限仅建议,进度2.30/1.40。用户暂缓验收,主体及联系方式暂无;后续先准备关闭状态的目录权限和独立密钥恢复安排,再落实正式规则及启用范围。
### 1.10 2026-10-13 协议草案和照片规则
四份正文按现有协议code/scope整理为纯文本,主体及隐私/删除联系方式因用户暂无信息而待填,正式版本和生效日期未指定。见[草案与待定信息](协议草案/README.md)及[照片规则](协议草案/照片保存与删除规则.md)。照片只供本人保存/读取/删除,不绑定评估,不送模型/外部查询;资料撤销禁新上传但保留本人管理,在线删除不等于清除元数据/同意记录/备份。外部数据授权只是本地意向,不能充当第三方凭证或自填/AI的敏感处理门禁。
期限候选为在线照片30天/照片备份7天/完成删除后的元数据30天,尚未获业务确认且未落为生产规则。沿用简单本地存储与本人接口,拟以明确授权运维执行最小清理,不新增自动生命周期层。现有代码无到期、元数据或备份清理;关闭照片开关也会禁本人查看/删除,因此实际启用和退场须对应真实管理安排。账户/报告/对话/同意/日志期限另外定稿,不从照片期限推导。
AI实际经腾讯云TokenHub调用DeepSeek,报告外发结构化白名单,对话使用必要上下文及格式脱敏;照片不作为输入。不声称所有数据全加密、自动到期删除、全部AI数据已匿名或供应商绝不训练。实际主体、存储地域、处理关系和供应商留存待核对。正文定稿后按后台草稿/逐份审核发布、不可变版本和UTC生效时间管理,启用时同步修订功能状态描述。
本轮仅本地文档及既有纯文本校验,无生产访问/运行代码/配置/正式发布/模型/短信/小程序发行;用户先不验收,未开展功能验收或更改签收状态。进度2.29/1.39,29页/63任务/融资28项及产品AI回撤保持。
### 1.09 2026-10-12 既有服务器部署与小程序发行配置
用户授权直接部署唯一现有服务器,不再等待独立测试站点。后端20261012-agreement-documents-r1已部署29文件及三张空增量表;原26表/角色/账号绑定保持,只追加协议五节点与运营v2模板,未导入正式正文或合成业务数据。小程序services/config.uts默认production,PRODUCTION_API_URL=https://airz.hqling.cloud/api/v1,TEST_API_URL为空,demo=false;用户从当前29页源码重新编译发行,发行前确认微信request/uploadFile/downloadFile合法域名包含既有HTTPS域名。本轮未代发微信/H5或重新编译目标端。
Nginx全局拦截会覆盖协议404 JSON,本站server显式fastcgi_intercept_errors off保留业务错误响应,其他配置逐字保持。27维护内/31公网HTTPS、116摘要/旧数据保护和回退预检通过;37私密备份文件已下载核验,三服务active、worker ready/idle、维护标记已清理,宝塔退出关闭。维护模型调用0,既有模型业务配置保持;无提交/推送/PR。
协议后台代码已上线,但四份正式全文尚未发布,公开接口明确404 AGREEMENT_NOT_AVAILABLE;短信仍关闭并返回503 SMS_NOT_CONFIGURED;照片配置enabled=false、正式directory未配置。照片开启仍须确认正式用途/保留期限/撤销处理及Web外目录、ACL和密钥恢复,不能将代码部署当功能启用或正式验收。旧密码登录、自填评估28项、产品库及产品AI回撤保持。详见[本轮发布说明](后端协议与照片发布-20261012.md),进度2.28/1.38。下方测试配置和未部署状态均为历史记录。
### 1.08 2026-10-12 独立测试配置与部署配套
默认APP_ENVIRONMENT=test,TEST_API_URL/PRODUCTION_API_URL空值,未知环境返回空地址;只在本机联调副本显式development连接127.0.0.1:18580/api/v1。独立远程测试需可信HTTPS及微信request/uploadFile/downloadFile合法域名。后端.env.test.sample不是自动环境切换入口,只能用于另建站点;本轮独立ROOT/ENV_PREFIX、空MySQL库/Redis实例/命名空间、Web外目录和随机密钥生效,原仓库.env不读不改。
短信/真实模型关闭,不启动worker;仅合成账号通过实际密码登录API,四份合成协议经当前后台模型审核发布,不复用正式正文/产品数据。三个新增表与协议后台五节点/运营v2独立模板配套,存量目标仅执行缺少的增量,不用完整schema覆盖旧库。关闭照片会阻断上传/下载/删除并返回空引用,已有密文不会自动删除;回退前先完成本人管理,保留表/历史/密钥。细节及本轮13配置/API、32真实前后端H5检查与归属清理见[测试部署清单](协议与照片测试部署清单-20261012.md)。
### 1.07 2026-10-12 私有资料接口与页面边界
资料照片上传、本人鉴权预览和删除内部代码已实现,真实上传默认关闭、正式目录留空;照片单独保存、未核验、暂不参与评估。上传后直接在原卡片显示缩略图,查看/删除从卡片菜单操作,没有下方独立列表;离页后可恢复本人已保存照片,同类多张及删除失败的ID可继续管理。168项私有资料/44次本机HTTP、268项公开头像/76HTTP、351项协议/86HTTP、26组UTS、30项协议及31项分包通过,29页Android/微信编译和58项受控H5通过;未上线。正式用途/期限/目录与真机验收待办。详见[开发记录](私有资料上传开发-20261012.md)。
接口只提供POST /documents、GET /documents/:id/content及POST /documents/:id/delete;服务端Secret加密保存至Web外已配置目录、本人归属/同意校验,删除先不可读再同步移除。private_documents.enabled=false、directory为空,无生产迁移或启用。评估JSON仍为空附件/授权,仅自填信息;复用GET /consents?scope=documents读取独立本人资料引用,跨离页恢复;不新增业务接口,卡片按同类照片选择后管理各ID。
### 1.06 2026-10-12 资料上传简化方案
按用户要求收缩为[简化方案](superpowers/specs/2026-10-12-private-documents-design.md):复用现有登录鉴权、图片校验与资料卡片,照片保存在Web目录外,独立小型表记录附件归属,沿用既有加密。首版只提供POST /documents、本人GET /documents/:id/content及POST /documents/:id/delete三个接口,同步处理上传/读取/删除。
原云存储扩展层、上传租约/命令账本、原键状态查询、多阶段状态机及独立清理worker已取消。失败明确提示并支持手动重试,不承诺未知回执时永久幂等;删除确认文件已移除后才显示成功。未登录/跨用户不能访问,私有照片不进入公开附件列表。
本轮仅简化文档,未新增API/DDL/私有目录,未编译或进行业务测试/生产操作。资料继续选填,融资28项不扩展,附件绑定另做最小集成,不做OCR或外部查询;正式用途/期限和实际存储条件在启用前确定。进度2.25/1.35、29页面和63任务保持。
### 1.05 2026-10-12 协议全文与同意记录契约
协议全文、后台审核/不可变版本和本人同意/可选撤销记录已实现,新增两页共29页,63任务编号保持。融资28项填写内容、私有上传/外部查询及同事产品库未改,产品AI回撤保持。正式协议正文未提供、未发布;两张新表只在本机隔离库安装,正式权限未迁移、代码未部署,短信仍默认关闭。
当前版只接受审核发布的结构化正文;同版不可变,未来尚未生效不进入当前,最新下架不回退旧版,历史全文保留。同意/撤销键共用本人唯一空间,旧键可以补确认,仅documents/data提供撤销。认证后同意记录未知时手动原键重试;明确404未发布允许已有账户密码登录且不造记录,网络/503不能绕过。待确认仅同会话页面内保存,不提供刷新/进程重启持久恢复。
公开GET /agreements/:code可带version;本人GET/POST /consents、POST /consents/:id/revoke。requestId为16–64位ASCII字母数字/下划线/横线,ID为十进制字符串,UTC时间为秒Z格式;列表page≤1000/pageSize≤50。grant保存scope/code/version/hash/revision/grantedAt;每个撤销请求追加event_kind=revoke、指向原grant,首次revokedAt不变。严格字段校验,未知存储失败503,事务回滚。后台正式发布需reviewed=1,批量只允许draft/offline;旧入口不能绕过。两SQL和schema期望定义同步,不自动迁移生产。
最终六套隔离1730项/380次本机HTTP通过;协议351、后台500、会话284、资料268、短信账户271、内容服务56。纯协议40/权限106另列;14PHP语法与两仓diff通过,失败和重复运行不累计。公开读与同源发布的误404已先复现再修正。独立复审发现的问题均已红绿修正并复审关闭,30项纯源码及31项微信分包检查通过;最终源码29页Android class于12:21:51、微信小程序于12:22:35编译成功(2026-10-12,Asia/Shanghai)。初轮H5完整78项与最终复审专项48项分开记录,最终源码绑定source-manifest.final-return.json及final-return-receipt.json,见开发记录及本轮verification。
见[开发记录](协议全文与同意记录开发-20261012.md)与本轮verification。正式全文、资料用途/保留期限/撤销后处理、第三方授权凭据、真机和正式运营签收仍待;FILE-003仅内部协议与本地记录功能完成,FILE-001/002/004依各自条件推进。生产上线需另做具体范围预检并获得发布授权,不自动恢复产品AI或继续上传/外部查询。
### 1.04 2026-10-12 短信账户服务契约
短信验证码引擎SmsVerification通过sms_verification_provider Hook获取Provider(available/send),缺供应商或enabled=false返回503/SMS_NOT_CONFIGURED。默认关闭在Hook和短信Redis连接之前生效,测试替身无生产配置入口。验证码六位安全随机,300秒有效、60秒同号码冷却、每小时号码10次/IP30次、错误5次;Token配置派生HMAC秘密并复用Redis配置,独立sms命名空间,号码/IP/上下文键及验证码只存摘要。
AccountSms接入公开auth/sms-code、auth/sms-login,以及鉴权account/security/sms-code、account/password短信分支和account/phone。安全发送用途password/phone-current/phone-new,前两项用本人号码、新号须有效且未占用;上下文绑定账户ID和当前手机号。改密仅已登录本人;换绑请求newPhone/currentCode/newCode,通过同一次Lua原子核销后事务写入,撤销本人全部Token,成功reLoginRequired=true。首次注册随机未知密码/邮箱null/默认昵称/零积分,沿用已有手机号唯一索引,无DDL。Auth::direct在锁内重核用于登录的手机号,防换绑后旧号码并发重建会话。
发送明确成功才激活,未知/失败保持不可用;号码限流计数保留、迟到失败不能删除新挑战。双码一项错不核销另一正确项,重复/并发最多成功一次;验证码已消费后业务失败不恢复旧码,重新发送。供应商适配须映射四用途审核模板、限制超时及屏蔽日志,不自动重试未知发送,凭据不写仓库。
最终四套隔离测试894项/200次本机HTTP、12PHP语法及独立只读复审通过,所属测试环境已清理;无真实短信/模型/生产访问/部署。前端契约已存在,本轮未改运行源码、融资28项或同事产品库,产品AI回撤保持。真实渠道/平台/多设备及正式签收仍待,AUTH-001阻塞不解除;实现与证据见[短信账户开发记录](短信账户功能开发-20261012.md)及进度2.23/1.33。
### 1.03 2026-10-10 产品AI条件分析功能回撤边界
@@ -907,7 +1023,7 @@ REPORT-004页面子项完成本地联调:report/report改为评估概况、匹
| FILE-001 | 私有资料存储方案:区分公开头像与私有证件;确定文件白名单、大小限制、存储密钥、附件元数据与保留策略。 | P0 | BASE-001、EXT-STORAGE、DEC-PRIVACY | 私有文件不在公开web目录;形成文件用途/权限/生命周期配置清单。 | 1–1.5 |
| FILE-002 | 私有上传与下载:持久附件ID、归属校验、大小/MIME/内容校验、鉴权读取、失败重试和删除策略。 | P0 | FILE-001、AUTH-002 | 无登录/跨用户不能读取;客户端临时路径不能提交为附件;失效链接不可访问。 | 1–2 |
| FILE-003 | 协议与授权版本:协议全文展示、同意/撤销事件、范围与协议版本、历史快照及外部授权凭据边界。 | P0 | FILE-001、AUTH-002、DEC-PRIVACY | 提交可追溯所见协议版本;撤销阻止新查询;前端布尔勾选不冒充第三方授权。 | 1–2 |
| FILE-004 | 资料授权页面联调:替换选图预览和占位协议;展示上传/授权状态;拒绝、取消、断网恢复。 | P0 | FILE-002、FILE-003 | 仅已上传且有权访问的附件入评估;拒绝可选授权仍可自填,不显示已查询。 | 1–1.5 |
| FILE-004 | 资料授权页面联调:替换选图预览和占位协议;展示上传/授权状态;拒绝、取消、断网恢复。 | P0 | FILE-002、FILE-003 | 照片独立管理,暂不参与评估;拒绝可选资料仍可自填,不显示已查询。 | 1–1.5 |
### 4.5 ASSESS|评估与融资档案
@@ -1387,7 +1503,7 @@ flowchart LR
| EXT-SMS | 待选型 | 服务商、签名模板、用途、测试号码、频控及发送回执 | AUTH-001 |
| EXT-WX | 待确认产品和平台资源 | 公众号/开放平台类型、适用H5场景、回调域名、授权和身份字段能力 | LOGIN-001、LOGIN-002 |
| EXT-ONE | 待选型 | H5号码认证产品、运营商/网络/浏览器矩阵、SDK与服务端校验、测试环境 | LOGIN-001、LOGIN-003 |
| EXT-STORAGE | 待选型 | 私有存储方案、访问控制、文件限制、备份与生命周期 | FILE-001 |
| EXT-STORAGE | 待选型 | 现有服务器Web外目录/ACL、文件限制、备份及正式用途/期限 | FILE-001 |
| EXT-DATA | 待选型 | 实际可开通数据产品、个人/企业接口、授权要求、样例、回调/查询和费用状态 | DATA-001 |
| EXT-AI | 两个指定模型已真实可用,Flash自填分析通过 | JSON输出及tokens/耗时已核实;供应商余额、价格对账、商业预算仍待确认 | REPORT-001 |
| EXT-CS | 待选型 | H5接入、访客身份、坐席、在线/离线、历史与附件能力、工作时间 | SUPPORT-001、SUPPORT-003 |
+143 -15
View File
@@ -1,8 +1,136 @@
# AI 融资平台开发进度
当前状态:产品AI条件分析与后台人工复核已回撤,相关试点及后续接续取消。
2026-10-14 当前进度:后端与管理端20261014-product-ai-r1已正式部署,产品表保持原16字段/31产品;自定义AI条件、第一列手输和推荐/不推荐 `{X}` 模板已上线。小程序配套展示已本地完成,微信29页编译及111项检查通过,用户自行发行,本次新版发行时间/版本尚未反馈。进度更新至2.36/1.46,技术文档v2.04/1.17;详见[新会话交接](新会话交接.md)。下方各轮“未部署/未迁移”为历史状态,以本段及1.46为准;整体和真机尚未验收。
基线日期:2026-09-22。文档版本:2.22。产品AI条件分析功能回撤见1.32;原相关实施记录已移至仓库外归档。小程序1.0.7设备反馈修正见1.15节;本批前端待重新发行。万元统一与profile-grade-r4生产配套发布见1.14节。信用贷生产r2与前轮社保/公积金开发见1.13节。12档综合等级/原型03匹配/PDF优化见1.12节。搜索竞态/反馈持久恢复前端组合检查见1.11节,候选源码/验收用例/阻塞收口见1.10节,微信账号分包模块缺失修复见1.09节,security-r3后端与后台发布见1.08节,后台内容写入CSRF防护见1.07节,内容发布与整篇课程已读组合回归见1.06节,反馈处理与站内消息组合回归见1.05节,核心权限矩阵与后台组合回归见1.04节,会话撤销/跨账户权限/消息与助手组合回归见1.03节,核心评估主流程组合回归及独立Linux路线取消见1.02节,真实Linux隔离条件/执行方案准备见1.01节,合成告警链统一预检/证据汇总见1.00节,dead对账/人工恢复审计见0.99节,告警outbox调度/租约退避/模拟接收幂等见0.98节,告警原子存储/互斥/崩溃恢复见0.97节,告警连续门槛/去重/恢复与维护见0.96节,Linux/systemd守护与告警离线准备见0.95节,部署恢复手册与只读证据入口见0.94节,合成全库恢复导入中断/耗时开发证据见0.93节,队列进程生命周期隔离开发证据见0.92节,合成加密资料/独立密钥恢复开发证据见0.91节,r2隔离实际部署/中断/定向恢复开发证据见0.90节,后台与内容r2生产维护发布见0.89节,生产只读实测与r2具体发布计划见0.88节,生产只读预检脚本准备与历史待登录状态见0.87节,后台/内容增量配套发布准备见0.86节,ADMIN-001后台菜单、正式角色模板与跨模块权限组合检查见0.85节,ADMIN-003服务状态、调用用量与现行策略组合联调见0.84节,ADMIN-002评估排障、失败重试与运行策略组合联调见0.83节,CONTENT-001后台内容编辑与发布页面专项见0.82节,CONTENT-002/003资讯、课程与搜索实际后端H5隔离联调见0.81节,CONTENT-004 FAQ实际后端与H5隔离联调见0.80节,帮助FAQ页面专项见0.79节,CONTENT-002/003/004内容、课程已读与搜索后端隔离HTTP开发检查见0.78节,课程整篇阅读与单一已读操作见0.77节,CONTENT-002/003/004内容、课程进度与搜索组合开发检查见0.76节,AUTH-003/004账户资料、头像及会话撤销后端隔离HTTP开发检查见0.75节,AUTH-003/004账户资料、改密及退出组合开发检查见0.74节,ASSESS-004评估新建/修改后端隔离HTTP开发检查见0.73节,ASSESS-004资料填写、确认提交与档案回显组合开发检查见0.72节,PRODUCT-004匹配读取与产品解释后端隔离开发检查见0.71节,PRODUCT-004/005匹配详情与对比历史版本组合开发检查见0.70节,CHAT-004后端消息幂等、并发额度与历史上下文隔离开发检查见0.69节,CHAT-004历史版本上下文与异常会话组合开发检查见0.68节,REPORT-006行动页与报告可用性组合开发检查见0.67节,REPORT-005微信PDF下载与导出边界开发检查见0.66节,REPORT-004历史报告与重评组合开发检查见0.65节,REPORT-003报告生成与持久化开发检查见0.64节,REPORT-002异步任务与补偿链开发检查见0.63节,反馈与消息后端生产发布见0.62节,反馈与消息配套发布准备见0.61节,最新反馈恢复与消息组合检查见0.60节,反馈回复与站内消息联动见0.59节,会员反馈原键恢复见0.58节,小程序既有发布状态及后台反馈处理见0.57节,首页隔离回归见0.56节,后端发布见0.55节;此前章节保留阶段记录。技术方案和接口说明见[开发文档](./开发文档.md),发布材料见[反馈消息发布清单](./反馈消息发布清单.md)。本台账现有63项任务、27页面,编号与开发文档保持一致。
2026-10-14 最新:不推荐移除“触发结果”,推荐/不推荐模板统一只用 `{X}`;AI 根据关联条件及正文选择适用理由,按出现顺序填充,复用现有一次评估调用。原16字段、第一列手输及标准条件服务端判断保持。真实 Chrome 表单和隔离假模型任务验证通过,本轮未部署、未调用真实模型;最终证据及限制见[最新交接](新会话交接.md)。下方为前轮记录。
2026-10-14 最新:按用户纠正,仅准入/加减分的“条件项”和推荐/不推荐的“关联条件”第一列改为普通手输,其他列保留结构控件。隐藏内部英文编号,地区/用途及枚举多选改为勾选;改名保留值、对象与分值,模板按中文名称关联及回显。自定义硬/软条件与独立文字模板接入现有一次 AI 评估,标准规则保持,服务端算分、排序和保存前复算;旧快照不重算。产品仍为原16字段,配置保存在other,本轮无需DDL。代码及隔离Chrome/假模型任务验证通过,尚未部署本轮修改或调用真实模型。 以本段及[最新交接](新会话交接.md)为当前口径,下方原文AI回撤不等同于本轮自定义条件AI判断。
2026-10-13 历史进度(以1.46当前状态为准):产品原16字段、文本配置与仅名称/对象必填完成本地实施,2120项检查及微信29页编译通过;正式库未迁移,未部署,见1.45。
前轮新增进度:产品生效/失效日期改为选填,目录/匹配与小程序同步,582项检查及微信29页重新编译通过,详见1.44;原16字段还原方案已核对,尚未实施物理迁移或部署。前轮产品原型实施见1.43。原融资28项、63任务编号和产品AI回撤保持。
当前状态:在线照片长期保存、备份滚动7天已确认;唯一服务器私有目录/权限、每日04:20备份及本机DPAPI密钥副本已落实,技术校验通过。照片/短信仍关闭、正式正文未发布,主体/联系方式暂无;删除后元数据30天和其余期限仍待确认。定时触发及真实照片恢复未验收;用户暂缓验收,小程序用户发行,产品AI回撤保持。
基线日期:2026-09-22。文档版本:2.36。后端配套发布与小程序交接见1.46;产品原字段与文本配置实施见1.45;产品有效期选填与原字段核对见1.44;产品机构库原型实施见1.43;照片存储与备份落地见1.42;照片长期保存规则修订见1.41;私有存储/备份只读核对及方案见1.40;协议草案及资料规则见1.39;现有服务器发布见1.38;独立测试配置/联调历史记录见1.37;私有资料内部实现见1.36;资料上传简化方案见1.35;协议全文与同意记录见1.34;短信账户内部实现见1.33;产品AI条件分析功能回撤见1.32;原相关实施记录已移至仓库外归档。小程序1.0.7设备反馈修正见1.15节;本批前端待重新发行。万元统一与profile-grade-r4生产配套发布见1.14节。信用贷生产r2与前轮社保/公积金开发见1.13节。12档综合等级/原型03匹配/PDF优化见1.12节。搜索竞态/反馈持久恢复前端组合检查见1.11节,候选源码/验收用例/阻塞收口见1.10节,微信账号分包模块缺失修复见1.09节,security-r3后端与后台发布见1.08节,后台内容写入CSRF防护见1.07节,内容发布与整篇课程已读组合回归见1.06节,反馈处理与站内消息组合回归见1.05节,核心权限矩阵与后台组合回归见1.04节,会话撤销/跨账户权限/消息与助手组合回归见1.03节,核心评估主流程组合回归及独立Linux路线取消见1.02节,真实Linux隔离条件/执行方案准备见1.01节,合成告警链统一预检/证据汇总见1.00节,dead对账/人工恢复审计见0.99节,告警outbox调度/租约退避/模拟接收幂等见0.98节,告警原子存储/互斥/崩溃恢复见0.97节,告警连续门槛/去重/恢复与维护见0.96节,Linux/systemd守护与告警离线准备见0.95节,部署恢复手册与只读证据入口见0.94节,合成全库恢复导入中断/耗时开发证据见0.93节,队列进程生命周期隔离开发证据见0.92节,合成加密资料/独立密钥恢复开发证据见0.91节,r2隔离实际部署/中断/定向恢复开发证据见0.90节,后台与内容r2生产维护发布见0.89节,生产只读实测与r2具体发布计划见0.88节,生产只读预检脚本准备与历史待登录状态见0.87节,后台/内容增量配套发布准备见0.86节,ADMIN-001后台菜单、正式角色模板与跨模块权限组合检查见0.85节,ADMIN-003服务状态、调用用量与现行策略组合联调见0.84节,ADMIN-002评估排障、失败重试与运行策略组合联调见0.83节,CONTENT-001后台内容编辑与发布页面专项见0.82节,CONTENT-002/003资讯、课程与搜索实际后端H5隔离联调见0.81节,CONTENT-004 FAQ实际后端与H5隔离联调见0.80节,帮助FAQ页面专项见0.79节,CONTENT-002/003/004内容、课程已读与搜索后端隔离HTTP开发检查见0.78节,课程整篇阅读与单一已读操作见0.77节,CONTENT-002/003/004内容、课程进度与搜索组合开发检查见0.76节,AUTH-003/004账户资料、头像及会话撤销后端隔离HTTP开发检查见0.75节,AUTH-003/004账户资料、改密及退出组合开发检查见0.74节,ASSESS-004评估新建/修改后端隔离HTTP开发检查见0.73节,ASSESS-004资料填写、确认提交与档案回显组合开发检查见0.72节,PRODUCT-004匹配读取与产品解释后端隔离开发检查见0.71节,PRODUCT-004/005匹配详情与对比历史版本组合开发检查见0.70节,CHAT-004后端消息幂等、并发额度与历史上下文隔离开发检查见0.69节,CHAT-004历史版本上下文与异常会话组合开发检查见0.68节,REPORT-006行动页与报告可用性组合开发检查见0.67节,REPORT-005微信PDF下载与导出边界开发检查见0.66节,REPORT-004历史报告与重评组合开发检查见0.65节,REPORT-003报告生成与持久化开发检查见0.64节,REPORT-002异步任务与补偿链开发检查见0.63节,反馈与消息后端生产发布见0.62节,反馈与消息配套发布准备见0.61节,最新反馈恢复与消息组合检查见0.60节,反馈回复与站内消息联动见0.59节,会员反馈原键恢复见0.58节,小程序既有发布状态及后台反馈处理见0.57节,首页隔离回归见0.56节,后端发布见0.55节;此前章节保留阶段记录。技术方案和接口说明见[开发文档](./开发文档.md),发布材料见[反馈消息发布清单](./反馈消息发布清单.md)。本台账现有63项任务、29页面,编号与开发文档保持一致。
### 1.46 2026-10-14 产品配套后端发布与小程序交接
产品表原16字段还原已经正式完成,31产品保留,匹配配置放原other,独立日期限制已解除。管理端仅条件项/关联条件第一列手输,其他列保留结构控件;自定义硬/软条件接入现有一次AI评估,标准规则、算分和排序由服务端决定。不推荐无触发结果,推荐/不推荐统一 `{X}`,多个位置按顺序填值;历史冻结报告不重算。
按用户“部署一下后端,我来发行小程序”授权,后端和管理端11文件已发布20261014-product-ai-r1,14:19:02(Asia/Shanghai)恢复入口与worker。715项本地纯检查、133项文件摘要、6个上架产品编译、维护期30表数据/结构一致性、29服务器及31公网HTTPS、精确回退预检通过;29份备份已下载核验,临时传输和专用宝塔会话已清理。本次没有表迁移、业务导入或真实模型调用;以[后端发布记录](D:/AAdesk/202609/airz/database/product-ai-release-20261014.md)和仓库外release-receipt.json为发布时证据。
小程序申请条件与解析保持,不新增AI来源说明;详情/对比去掉有效期、隐藏未填可选项,列表/对比补匹配度和机构Logo,排序及条件不符/资料不足文案同步。111项针对性展示/路由检查及微信29页编译通过,真实设备和真实模型质量尚未验证;用户自行发行,新版版本号和发行时间尚未反馈。各轮检查分别记录,不跨轮累计,不把开发验证记为正式验收。
进度2.36/1.46、技术文档v2.04/1.17已同步,29页面、63任务编号及融资28项保持。用户暂缓验收;下一会话先处理用户新需求或具体反馈,收到发行信息再登记对应版本/源码。最新要求、运行位置、备份和未完成事项见[新会话交接](新会话交接.md),下方均保留当时阶段状态。
### 1.45 2026-10-13 产品原字段与文本配置本地实施
用户已选择恢复16个原字段名,匹配参数、可选日期和内部版本收纳到原other文本配置;expectation/quota恢复为额度/期限说明。管理端仅产品名称、适用对象为基本必填(新建默认企业),机构/类型/来源/额度/期限/利率/日期等可空,填写后仍校验;资料不足显示待核实,不进入推荐排名。保留InnoDB事务、varchar(100)产品名与状态默认草稿,旧冻结报告不重算。
本轮2120项检查通过:纯转换/发布/编辑/规则/评分、16与28字段实际模型及目录、评估提交/重评/助手/匹配接口、历史报告/PDF、迁移拒绝与回撤、UTS及实际页面;管理端15项、小程序受控H5 8项,1280×900和390×844无应用控制台错误。微信29页编译成功。失败和重复运行不累计,前轮582/980项不另加。
本轮只在专用本机合成MySQL/Redis/HTTP/H5环境验证,不读取正式.env、不访问正式库、不调用真实模型、未部署/提交/推送。正式产品表仍未迁移;发布应配套后端与管理端,不能只覆盖原三个文件。小程序仍由用户发行,真机、正式数据预检和用户验收尚未完成。 进度2.35/1.45;证据与操作说明见[实施记录](产品表还原与选填核对-20261013.md),原表备份和所属服务清理回执保存在证据根。
### 1.44 2026-10-13 产品表原字段核对与有效期选填
用户要求尽量还原原16字段并减少日期等必填;当前28字段与发布/匹配关联,完整还原需要迁移机器参数和内部版本到原文本配置,范围选择仍待回复。日期已改为选填,保存NULL、发布校验、公共目录、冻结匹配、当前可用性和UTS文案同步;填写的日期仍校验并限制未来/过期,下架保持。155发布、234编辑、151旧匹配、18UTS、24实际本机合成MySQL共582项与微信29页重新编译通过。未改物理表或生产、未部署/提交,专用MySQL已停止并核对端口释放。详情见[核对记录](产品表还原与选填核对-20261013.md),其他上架字段尚未全部放宽。
### 1.43 2026-10-13 产品机构库原型本地实施
按用户确认建议实施八标签规则页、列表搜索筛选/视图切换/规则/上下架、保存回显与版本保护、万元精确换算;加入稳定硬条件、基础分80可配置软评分、条件关联模板、资料清单与机构Logo/电话。新匹配按分数排序,旧快照与null分数保持;未采集的原型指标为待核实。小程序三标签、头摘要、理由分组、顾问拨号和既有申请准备路径接通。共980项隔离/纯检查及微信29页编译通过,详见[实施记录](产品机构库原型实施-20261013.md)。本轮没有部署、提交或真实模型调用;本地测试服务停止,合成数据和证据留仓库外。真实来源与渠道待运营核实,用户验收尚未进行。
### 1.42 2026-10-13 照片存储、7天备份及密钥恢复准备
用户已确认在线照片长期保存、备份副本滚动7天,授权继续现有唯一服务器准备。public同级私有目录www:www/0700已创建,既有.env仅追加directory和enabled=false,实际FPM合成读写/0600校验通过并清理临时文件。root-only备份目录、Web外标准库工具及独立cron已安装,每日04:20执行,原04:00数据库任务/保留3份保持。工具通过REPEATABLE READ只读快照关联元数据/同意/协议和活动密文;成功后只清理超过7天的有效旧包,不清理在线照片、孤立密文或业务行,故障可能延迟清理。
共享AES密钥经临时RSA封装后转存本机DPAPI CurrentUser副本,ACL仅当前用户/SYSTEM;独立进程指纹及合成GCM恢复校验通过,没有明文密钥文件或密钥轮换。Windows合成测试6通过/1symlink跳过、Linux7项通过,首份空资料归档校验通过;10:55:12四服务active、三张新表0行、照片/短信关闭,配置范围复查通过。定时首次触发和真实照片恢复尚未验证,技术校验不计用户或正式验收。
见[落地记录](照片存储配置落地-20261013.md)及[备份工具](D:/AAdesk/202609/airz/database/private_documents_backup.md),本机私密回执D:/AAdesk/202609/airz-backups/private-storage-setup/20261013-01,以final-verification-retry.json和isolation-hardening.json为最终证据,失败回执保留;宝塔退出/浏览器关闭/临时凭据脚本清理完成。草案同步备份7天并重生成格式和摘要,正式协议未发布。主体/有效申请渠道/处理责任暂无,删除后元数据30天及其他证据期限仍待确认;没有生产恢复、数据清理或开放照片。无异地照片副本,DPAPI换机/重装需受控迁移,不声称灾难恢复全面完成。
14前端运行摘要、29页面/63任务/融资28项及产品AI回撤保持,进度2.32/1.42、开发文档v2.00/1.13;用户暂缓验收、小程序自行发行,未提交/推送/PR。下一项在主体及申请渠道等具备后定稿协议并按明确范围开放照片;保留待填,不重复追问或发布占位正文。下方是各轮历史状态。
### 1.41 2026-10-13 在线照片改为长期保存
用户明确“希望长期保存”,已将在线照片规则改为在账户正常使用且仍需本人保管期间长期保存,不设上传起固定到期时间,原30天建议取消。本人删除、用途结束、撤回作为保存依据的同意及注销申请仍按相应规则处理,不承诺永久保存或撤销后任意留存。备份副本7天和物理删除完成后元数据30天仍只是建议,不能把备份副本滚动清理理解为删除在线照片。
[照片规则](协议草案/照片保存与删除规则.md)、隐私政策/资料使用协议、草案入口和[存储方案](私有照片存储与备份方案-20261013.md)已同步,四正文通过现有AgreementRegistry纯文本/长度/code-scope校验,格式回执与草案摘要重新生成;仅修改其中两份正文,用户协议和数据授权正文保持。现有PrivateDocuments没有按照片年龄到期删除的代码,不新增运行代码、任务或配置;下方各轮30天为历史建议,当前方向以本节为准。
本次仅本地文档及格式/摘要核对,不连接生产、读取生产.env、发布协议、开启照片/短信、验收或发行;运行源码保持,主体和申请联系方式暂无,目录/备份/密钥恢复仍未落实。29页/63任务/融资28项、同事产品库及产品AI回撤保持,无提交/推送/PR。
### 1.40 2026-10-13 私有照片存储与备份只读核对
现有唯一服务器已完成只读核对,见[具体配置与备份方案](私有照片存储与备份方案-20261013.md)。FPM实际www:www、UID/GID1001;public/.user.ini已有open_basedir限制及immutable属性。推荐 `/www/wwwroot/xctai.cn/private-documents`,位于public外且符合现有PHP允许范围,owner www:www/0700、文件0600;尚未创建或写入配置。检查时同盘剩余约166GiB,无公开root/alias映射,不扩展Nginx或PHP权限。
宝塔实际分库及root计划证实:MySQL每日04:00备份、localhost保留3份。10-11/12/13三个airz gzip只读解压通过,最新04:00:01包含三张新表;没有执行恢复。该任务不备份照片文件,已知发布归档没有.env,未取得独立生产密钥备份/恢复证据;有效32字节密钥和Secret内存往返通过不等于已托管。照片备份拟用root-only目录及一致恢复集合,密钥单独受控保管,保留期限/删除与恢复保护待落实;同盘不代表主机故障保护。
10:03:51新三表各0行、活动评估/调用及三队列0;10:10:47最终三服务active,.env/Nginx/.user.ini/PHP/FPM五配置摘要保持,候选在线/备份目录仍不存在(Asia/Shanghai)。未生产写入、DDL、目录/权限/计划任务修改、模型/短信请求或发行;宝塔退出、专用浏览器关闭,临时凭据脚本删除,私密回执保留。仅更新本地方案/文档,不算功能验收,也不重跑上轮部署或修改四份正文。
主体/联系方式用户暂无,30天/备份7天/完成物理删除后元数据30天仍是建议;正式协议未发布、照片/短信继续关闭。下一项可准备关闭状态下的目录权限与独立密钥恢复安排,再落实定稿规则和最小运维责任;实际启用另按具体范围推进,用户暂缓验收。29页/63任务/融资28项、同事产品库和产品AI回撤保持,无提交/推送/PR。
### 1.39 2026-10-13 协议草案与照片保存规则
用户要求先不验收、继续下一步,已整理四份完整纯文本草案及照片保存/删除规则,见[草案入口](协议草案/README.md)。对外名称、运营主体和隐私/删除联系方式,用户回复“暂时没有”,已保留明确待填位置,不编造主体或正式生效日期。照片用途限本人保存/查看/删除,拒绝或撤销不影响不依赖照片的自填服务;撤销不等于删除,在线文件/元数据/同意证据/备份分别说明。数据授权仍只记录本地意向,不等于第三方凭据或实际查询。
建议在线照片上传起30天、照片备份7天、删除后资料元数据30天,仅为待确认建议。当前没有到期/备份/元数据自动清理,未设为上线规则。AI按实际代码说明为腾讯云TokenHub调用DeepSeek,报告白名单及对话上下文单独披露;不宣称照片送AI或全部数据库同样加密。服务方主体/存储地域/留存和实际处理依据仍待核实。
四份正文通过现有AgreementRegistry纯文本/长度/code-scope契约检查,临时校验版本和2099时间仅在内存使用,不是草案生效时间;未加载框架/生产.env/数据库/Redis。文本编码、摘要、链接和文档一致性检查单独记录,不计功能验收。仅新增本地草案及更新README/进度/开发/交接,无运行代码、DDL、服务器配置、生产写入、模型或短信请求、发行、提交/推送/PR;后端已部署但正式正文未发布,照片/短信仍关闭,63任务/29页/融资28项/产品AI回撤保持。
下一步可继续核对现有服务器的私有目录/权限、备份范围与恢复安排;实际启用仍待主体/申请渠道、明确期限和可执行清理规则。用户明确暂缓验收,不能自动接续真机或正式签收。草案修改后须重新生成[草案摘要](协议草案/草案清单.json),不得直接导入占位正文或复跑已完成的发布脚本。
### 1.38 2026-10-12 既有服务器后端发布
用户明确授权直接部署唯一现有服务器,小程序自行发行;不再将独立测试服务器作为下一步。已发布29文件(26运行、3增量DDL,7替换/22新增),版本20261012-agreement-documents-r1;17:00:39完成备份、17:00:58更新、17:05:07恢复入口/worker,17:10:26最终核验(Asia/Shanghai)。27维护内HTTPS、31公网可信TLS、116源码摘要及29文件精确回退预检通过,旧26表数据/结构保持,三张新表为空;只追加协议五节点及运营v2模板,不绑定账号。既有.env/模型/非版本site配置保持,维护模型请求和真实短信为0。
生产全局fastcgi_intercept_errors on将协议404 JSON替换成HTML,首次验证失败;已定位并仅在本站server关闭拦截,重新验证四协议均明确AGREEMENT_NOT_AVAILABLE。敏感路径仍404,匿名资料/同意接口401,短信未配置503。Nginx只增加该站点覆盖,原全局配置不改,维护标记已移除;失败证据与修正摘要保留。三服务active、worker ready/idle心跳0秒、活动评估/调用和队列0。归档模板缓存属主已改root,目录700/文件600核验;37个私密备份文件下载逐项SHA核验,宝塔退出关闭,临时传输/凭据清理。
正式terms/privacy/document-use/data-authorization正文未提供/发布;照片用途、期限、私有目录/ACL及恢复条件仍待,照片上传保持关闭,照片暂不参与评估。短信渠道/签名/模板未接入,既有密码登录保留。当前前端APP_ENVIRONMENT=production,正式API已填写,test为空、demo=false;14相关前端摘要冻结,用户须从当前29页源码重新编译发行,本轮未代发微信/H5或重新编译目标端。正式启用、真机、多设备及63任务签收未完成;融资28项、同事产品库和产品AI回撤保持。
详见[发布说明](后端协议与照片发布-20261012.md)与[发布回执](D:/AAdesk/202609/airz-backups/production-release/airz-agreement-documents-1791794619319/release-receipt.json)。原1.37及下方未部署/测试环境描述为历史记录,不能重跑已完成发布或已清理测试工具;无提交/推送/PR。
### 1.37 2026-10-12 独立测试配置与实际前后端联调
第1步本机准备完成:前端默认test地址已清空,与已发布服务分开;显式development仅指向本机,未知环境不回退。后端新增独立.env.test.sample,原.env不读不改,短信/模型关闭。协议/照片配套源码、三个新增表、协议后台权限、顺序/回退和微信测试域名条件见[测试部署清单](协议与照片测试部署清单-20261012.md)。
13项配置/API预检、32项真实本机前后端H5检查及26组照片/30项协议专项通过;原卡片显示/预览、离页恢复、跨账号拒绝、撤销后禁止新上传及本人删除已联通。20页面API请求,0运行/控制台错误及未知外呼;63配置审计模型/短信/生产.env均false。29张合成表清空、Redis/私有目录零项后删除精确合成库,所属API/H5及子进程/MySQL/Redis停止并释放端口。
远程测试主机/HTTPS域名、微信体验版合法域名配置与真机验证尚待;本轮没有远程部署、评估/真实模型任务、系统安装或提交/推送/PR,不解除FILE-001等正式条件。融资28项、29页面、63任务及产品AI回撤保持。
### 1.36 2026-10-12 私有资料上传内部实现
资料照片上传、本人鉴权预览和删除内部代码已实现,真实上传默认关闭、正式目录留空;照片单独保存、未核验、暂不参与评估。上传后直接在原卡片显示缩略图,查看/删除从卡片菜单操作,没有下方独立列表;离页后可恢复本人已保存照片,同类多张及删除失败的ID可继续管理。168项私有资料/44次本机HTTP、268项公开头像/76HTTP、351项协议/86HTTP、26组UTS、30项协议及31项分包通过,29页Android/微信编译和58项受控H5通过;未上线。正式用途/期限/目录与真机验收待办。详见[开发记录](私有资料上传开发-20261012.md)。
本轮新建明确归属的合成隔离库、Redis及H5副本,不读取生产.env,不连接生产、不迁移/部署、不发真实短信/模型请求、不提交/推送/PR。既有短信/协议改动及同事产品库保持。资料选填,融资28项、29页面、63任务保持;服务端仍拒绝非空评估附件,外部授权仍未接通。清理与源码回执见本轮归档,禁止复用清理后的runner。
### 1.35 2026-10-12 资料上传简化方案
用户要求“还是不设计这么复杂”,[简化方案](superpowers/specs/2026-10-12-private-documents-design.md)已替代原提案。首版只做上传照片、本人查看/删除,复用现有界面与鉴权,补私有目录、小型资料表和归属校验;不采用原云扩展层、上传租约/命令账本或自动清理引擎。
本轮仅改方案,不新增运行代码/DDL/目录,不接生产或启服务。资料继续选填,融资28项和当前自填评估契约保持;附件绑定另做最小集成,不做OCR或外部查询。两仓既有改动/证据、29页面、63任务保持,不将文档核对当功能验收。
FILE-001正式用途/保存期限与实际存储条件仍待,FILE-002/004未实施;FILE-003内部功能与正式验收状态保持。下一步按三个后端接口和现有资料卡片推进,正式启用前确定必要规则,不追加复杂生命周期配置。
### 1.34 2026-10-12 协议全文与同意记录内部实现
协议全文、后台审核/不可变版本和本人同意/可选撤销记录已实现,新增两页共29页,63任务编号保持。融资28项填写内容、私有上传/外部查询及同事产品库未改,产品AI回撤保持。正式协议正文未提供、未发布;两张新表只在本机隔离库安装,正式权限未迁移、代码未部署,短信仍默认关闭。
当前版只接受审核发布的结构化正文;同版不可变,未来尚未生效不进入当前,最新下架不回退旧版,历史全文保留。同意/撤销键共用本人唯一空间,旧键可以补确认,仅documents/data提供撤销。认证后同意记录未知时手动原键重试;明确404未发布允许已有账户密码登录且不造记录,网络/503不能绕过。待确认仅同会话页面内保存,不提供刷新/进程重启持久恢复。
最终六套隔离1730项/380次本机HTTP通过;协议351、后台500、会话284、资料268、短信账户271、内容服务56。纯协议40/权限106另列;14PHP语法与两仓diff通过,失败和重复运行不累计。公开读与同源发布的误404已先复现再修正。独立复审发现的问题均已红绿修正并复审关闭,30项纯源码及31项微信分包检查通过;最终源码29页Android class于12:21:51、微信小程序于12:22:35编译成功(2026-10-12,Asia/Shanghai)。初轮H5完整78项与最终复审专项48项分开记录,最终源码绑定source-manifest.final-return.json及final-return-receipt.json,见开发记录及本轮verification。
[开发记录](协议全文与同意记录开发-20261012.md)包含两表、接口、离线权限和验证入口。正式全文、资料用途/保留期限/撤销后处理、第三方授权凭据、真机和正式运营签收仍待;FILE-003仅内部协议与本地记录功能完成,FILE-001/002/004依各自条件推进。生产上线需另做具体范围预检并获得发布授权,不自动恢复产品AI或继续上传/外部查询。
### 1.33 2026-10-12 短信账户功能内部实现
按用户授权先实现短信登录/首次注册、登录后验证码改密及原/新号码双验证换绑。供应商、审核签名及模板暂缺,sms_verification默认关闭,未配置不发送、不产生可用码/账户;无真实短信、生产访问、部署或提交/推送。Provider仅定义正常扩展契约,测试替身只在tests及显式隔离bootstrap使用,无线上模拟入口。
验证码安全随机/摘要存储、用途和本人当前号码绑定、300秒有效/60秒冷却、号码/IP限流/错误上限、Lua原子单双码核销;改密/换绑行锁内重核身份并撤销本人全部Token。首次注册沿用现有Auth/唯一手机号索引,无积分奖励及迁移;修复换绑后旧号码并发密码登录可重建Token的问题,验证码消费后失败不恢复旧码。原密码登录、资料头像兼容;前端运行代码、融资28项及同事产品机构库不改动,产品AI回撤保持。
最终短信引擎71、短信账户271/54HTTP、原会话284/70HTTP、资料头像268/76HTTP,共894项/200次本机HTTP通过;并发注册/换绑和故障回滚已覆盖,旧号码专项27项不重复累计。12PHP语法、两仓diff和独立只读复审通过。26空合成表结构/8触发器保持、Redis DB15零键;本轮精确库删除、两所属服务退出/端口释放,395回执真实模型客户端加载0次。短信JSON夹具/生成Python缓存清理,私密归档保留。
[开发记录](短信账户功能开发-20261012.md)及D:/AAdesk/202609/airz-backups/account-sms/1791773313671保存接口、供应商接入及最终证据。AUTH-001真实渠道仍阻塞,AUTH-002/004内部代码已接通、整体未验收;真实发送/目标端和多设备待接入渠道后验证。本轮不重编译小程序、不改旧候选/发布回执,63任务/27页面保持。
### 1.32 2026-10-10 产品AI条件分析与后台人工复核回撤
@@ -1390,7 +1518,7 @@ P0 表示核心流程、安全、首发外部能力或发布门槛;P1 表示
| --- | --- | --- | --- |
| 首页 | 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/assessment/assessment | ASSESS-004、FILE-004、DATA-004 | 当前流程内存填写/私有照片上传、本人预览及删除(默认关闭) |
| 评估确认 | packages/finance/confirm/confirm | ASSESS-004 | 元/万元转换、正式提交及网络重试幂等 |
| 分析中 | packages/finance/analyzing/analyzing | REPORT-004 | 按assessmentId轮询状态、失败重试、成功后打开真实报告 |
| 评估报告 | packages/finance/report/report | REPORT-004 | 三栏已接本人冻结报告、匹配候选与改善证据;真实业务及平台验收未完成 |
@@ -1467,10 +1595,10 @@ DEC-DEMAND已确认并发布,需求录入/提交/历史子范围已实现;
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- |
| AUTH-001 | 全栈开发(待指定) | 发送成功才倒计时;验证码过期、复用、跨用途和频繁发送均拒绝。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| AUTH-002 | 全栈开发(待指定) | 真实账户登录成功;登录失败无假 Token;登出撤销当前 Token;旧散列兼容。 | 已实施子集和验证见第0节;完整任务未验收 | 2026-09-22 |
| AUTH-001 | 全栈开发(待指定) | 发送成功才倒计时;验证码过期、复用、跨用途和频繁发送均拒绝。 | 1.33验证码引擎71项与账户271项隔离检查通过;供应商/审核签名模板暂缺,默认关闭、真实渠道及正式验收待补 | 2026-10-12 |
| AUTH-002 | 全栈开发(待指定) | 真实账户登录成功;登录失败无假 Token;登出撤销当前 Token;旧散列兼容。 | 1.33短信登录/首次注册271项及原会话284项隔离检查通过;默认关闭,真实渠道/目标端及完整任务未验收 | 2026-10-12 |
| AUTH-003 | 全栈开发(待指定) | 不同设备资料一致;昵称不覆盖评估实名;头像不依赖临时路径。 | 0.75后端资料268项/76次HTTP及0.74受控H5组合103项/微信Android编译通过;真机/跨设备/正式验收待补 | 2026-10-06 |
| AUTH-004 | 全栈开发(待指定) | 跨账户或单个验证码不能换绑;修改后旧 Token 全失效;新密码8–32位且含字母和数字。 | 0.75会话281项/69次HTTP含全Token撤销/故障回滚/并发及0.74前端检查通过;供应商双挑战/真机/正式验收待补 | 2026-10-06 |
| AUTH-004 | 全栈开发(待指定) | 跨账户或单个验证码不能换绑;修改后旧 Token 全失效;新密码8–32位且含字母和数字。 | 1.33短信改密/双码换绑271项及原会话284项隔离检查通过,含并发/全Token撤销/故障回滚;真实渠道/真机/正式验收待补 | 2026-10-12 |
直接阻塞:AUTH-001 → EXT-SMS。
@@ -1500,17 +1628,17 @@ DEC-DEMAND已确认并发布,需求录入/提交/历史子范围已实现;
| 任务编号 | 任务 | 范围 | 优先级 | 直接依赖 | 预计人日 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| FILE-001 | 私有资料存储方案 | 区分公开头像与私有证件;确定文件白名单、大小限制、存储密钥、附件元数据与保留策略。 | P0 | BASE-001、EXT-STORAGE、DEC-PRIVACY | 1–1.5 | 阻塞 |
| FILE-002 | 私有上传与下载 | 持久附件ID、归属校验、大小/MIME/内容校验、鉴权读取、失败重试和删除策略。 | P0 | FILE-001、AUTH-002 | 1–2 | 未开始 |
| FILE-003 | 协议与授权版本 | 协议全文展示、同意/撤销事件、范围与协议版本、历史快照及外部授权凭据边界。 | P0 | FILE-001、AUTH-002、DEC-PRIVACY | 1–2 | 阻塞 |
| FILE-004 | 资料授权页面联调 | 替换选图预览和占位协议;展示上传/授权状态;拒绝、取消、断网恢复。 | P0 | FILE-002、FILE-003 | 1–1.5 | 未开始 |
| FILE-001 | 私有资料存储方案 | 区分公开头像与私有证件;确定文件白名单、大小限制、存储密钥、附件元数据与保留策略。 | P0 | BASE-001、EXT-STORAGE、DEC-PRIVACY | 1–1.5 | 阻塞;方案已简化,正式条件待确认 |
| FILE-002 | 私有上传与下载 | 持久附件ID、归属校验、大小/MIME/内容校验、鉴权读取、失败重试和删除策略。 | P0 | FILE-001、AUTH-002 | 1–2 | 开发中;内部完成,默认关闭、正式未验收 |
| FILE-003 | 协议与授权版本 | 协议全文展示、同意/撤销事件、范围与协议版本、历史快照及外部授权凭据边界。 | P0 | FILE-001、AUTH-002、DEC-PRIVACY | 1–2 | 开发中;内部功能完成,正式资料待确认 |
| FILE-004 | 资料授权页面联调 | 复用资料卡片上传、本人查看/删除;照片单独保存、暂不参与评估。 | P0 | FILE-002、FILE-003 | 1–1.5 | 开发中;内部完成,真机及正式条件待办 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- |
| FILE-001 | 全栈开发(待指定) | 私有文件不在公开web目录;形成文件用途/权限/生命周期配置清单。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| FILE-002 | 全栈开发(待指定) | 无登录/跨用户不能读取;客户端临时路径不能提交为附件;失效链接不可访问。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| FILE-003 | 全栈开发(待指定) | 提交可追溯所见协议版本;撤销阻止新查询;前端布尔勾选不冒充第三方授权。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| FILE-004 | 全栈开发(待指定) | 仅已上传且有权访问的附件入评估;拒绝可选授权仍可自填,不显示已查询。 | 未验收;无真实业务验收证据 | 2026-09-22 |
| FILE-001 | 全栈开发(待指定) | 私有文件不在公开web目录;形成文件用途/权限/生命周期配置清单。 | 简化方案及内部实现见1.35/1.36;正式用途/期限与实际目录未确认,未验收;1.38后端已部署、正式配置仍待 | 2026-10-12 |
| FILE-002 | 全栈开发(待指定) | 无登录/跨用户不能读取;客户端临时路径不能提交为附件;失效链接不可访问。 | 168项隔离/44HTTP见1.36;正式未验收;1.38接口/空表已部署、真实上传仍关闭 | 2026-10-12 |
| FILE-003 | 全栈开发(待指定) | 提交可追溯所见协议版本;撤销阻止新查询;前端布尔勾选不冒充第三方授权。 | 内部协议/本地记录检查见1.34;正式正文、外部凭据与目标端未验收;1.38后台/两空表/权限已部署,正式正文未发布、账号未绑定 | 2026-10-12 |
| FILE-004 | 全栈开发(待指定) | 照片独立管理,暂不参与评估;拒绝可选资料仍可自填,不显示已查询。 | 26组UTS/58项受控页面见1.36;正式未验收;1.38正式API配置已就绪,用户重新编译发行/真机待办 | 2026-10-12 |
直接阻塞:FILE-001 → EXT-STORAGE、DEC-PRIVACY;FILE-003 → DEC-PRIVACY。
@@ -1753,7 +1881,7 @@ DEC-DEMAND已确认并发布,需求录入/提交/历史子范围已实现;
| EXT-SMS | 服务商待定 | 服务商、可测试账户、签名/模板、回执与限流能力、环境配置交付可用;测试号码发送成功 | AUTH-001 | 用户/业务负责选型与开通,开发负责验证;M0 发起,M1 验收前解除 |
| EXT-WX | 服务类型和账号准备待定 | 确认微信内 H5/普通 H5 登录方式、主体资质、应用/公众号权限、域名回调与测试能力 | LOGIN-001、LOGIN-002 | 业务负责账号权限,开发形成能力矩阵;M0 发起,M1 前解除 |
| EXT-ONE | 服务商及 H5 能力待定 | 明确浏览器/运营商/网络限制、合法的服务端验证接口、测试环境、SDK 和降级策略 | LOGIN-001、LOGIN-003 | 业务与开发联合选型;M1 接入前解除 |
| EXT-STORAGE | 私有存储服务待定 | 私有桶或等价私有存储、权限策略、服务端访问配置、签名链接/鉴权读取能力、容量和生命周期方案 | FILE-001 | 开发提出方案,业务确认预算与保留边界;M1 开发前解除 |
| EXT-STORAGE | 私有存储服务待定 | 现有服务器Web外私有目录、ACL/归属鉴权、容量与备份;正式用途/期限及撤销后处理待确认 | FILE-001 | 开发提出方案,业务确认预算与保留边界;M1 开发前解除 |
| EXT-DATA | 数据渠道待定 | 可开通数据产品、个人/企业覆盖、正式授权流程、样例与字段字典、验签/回调/对账、计费及测试权限 | DATA-001 | 业务确认可获得的产品及资质,开发验证接口;M0 发起,M2 前解除 |
| EXT-AI | 腾讯云TokenHub;Pro/Flash真实权限、JSON和用量字段已验证;余额/预算待运营确认 | 确认可用模型标识、服务端测试配置、实际额度、返回格式、超时和费用核对入口;完成最小真实调用 | REPORT-001 | 开发验证能力,业务确认预算;M2 接入前解除 |
| EXT-CS | 客服服务商和坐席待定 | 支持普通/微信内 H5 的客服方案、用户身份签名、测试坐席、工作时间与离线接待责任人 | SUPPORT-001、SUPPORT-003 | 业务负责供应商与坐席,开发负责接入;M3 前解除 |
+232 -1
View File
@@ -1,6 +1,237 @@
# AI融资项目新会话交接
## 当前状态:2026-10-10 产品AI条件分析与后台人工复核已回撤
## 新会话先读:2026-10-14 进度已整理
本轮按用户要求记录进度,未继续开发、重新部署、调用模型或执行验收。开发进度2.36/1.46、技术文档v2.04/1.17已同步。先读取本节和下方最新发布记录,再结合用户的新需求接续;下方旧记录中的“未部署/未迁移”不作为当前待办。
- 线上后端及管理端:20261014-product-ai-r1,2026-10-14 14:19:02(Asia/Shanghai)恢复入口/worker;API为https://airz.hqling.cloud/api/v1,管理端入口https://airz.hqling.cloud/index/login。原16字段还原已于上午发布,本次不再迁移,31产品与旧报告保留。
- 已确认产品规则:仅条件项/关联条件第一列手输,其他列保留结构控件;不推荐无触发结果,推荐/不推荐模板统一 `{X}`,AI决定正文适用性并顺序填值。自定义硬条件三态影响推荐,资料不足不能判为不符;基础分80可配置,标准规则、算分、排序与保存前复算由服务端控制。不要恢复已撤回的整页全手输方案或原产品原文AI整理功能。
- 小程序:申请条件及其解析保持,不新增“AI 根据自填资料判断”等来源说明;有效期和未填可选项隐藏,列表/对比补匹配度、Logo和排序文案,区分条件不符、资料不足与待确认。当前源码已配套,本地微信29页编译及111项展示/路由检查通过;用户自行发行,本次新版版本号与发行时间尚未反馈。
- 发布证据:后端715项本地纯检查、133项服务器文件摘要、6个上架产品编译、30表维护期一致性、29服务器/31公网HTTPS及回退预检通过,29份备份已下载核验;服务在发布核验时正常。证据根D:/AAdesk/202609/airz-backups/production-release/airz-product-ai-20261014,以release-receipt.json为发布时回执。本节是记录既有结果,不是新的服务器实测;不要重跑发布/迁移/初始化脚本或改写旧回执。
- 尚未完成:真机、真实模型判断质量及正式验收。用户此前暂缓验收,不自动继续验收或发起付费模型调用。照片在线长期保存、备份滚动7天;照片/短信仍关闭,正式协议未发布,主体和联系方式暂无,保留待填,不重复追问;备份定时触发和真实照片恢复未验收。
- 工作区:前端D:/WorkSpace/cyun/ai融资,后端D:/AAdesk/202609/airz;两仓已有大量未提交改动,全部保留,不擅自提交/推送/覆盖。默认后端.env指向正式配置,测试只能使用显式隔离入口;无独立测试服务器,小程序由用户部署。
接续时先处理用户的新需求或反馈。若用户报告小程序已发行,再登记版本、时间及对应源码摘要,并按具体问题处理;不重复已完成的后端发布和产品表还原。进度与技术范围分别见[开发进度](开发进度.md)和[开发文档](开发文档.md)。
## 当前工作:2026-10-14 自定义AI条件与 `{X}` 后端已部署
用户明确要求“部署一下后端,我来发行小程序”。现有https://airz.hqling.cloud已发布20261014-product-ai-r1,14:18:04完成11个运行文件替换,14:19:02恢复入口/worker(Asia/Shanghai)。条件第一列手输、自定义硬/软条件AI三态判断、推荐/不推荐正文适用性及多个 `{X}` 顺序填值已配套上线;不推荐移除触发结果,复用现有一次评估请求。小程序由用户发行,本轮不再修改其运行代码。
发布范围为ProductPresentation、ProductPublication、ProductAiConditions、ProductAiTemplates、ProductMatching、AssessmentNeedsReport、AnalysisTasks、Assessment、DeepSeekClient与管理端product.js/product/form.html。133项运行/依赖摘要核验、现有6个上架产品候选编译、30表维护期业务行及完整结构一致性通过,产品仍原16字段/31产品,完整旧表保持。本轮无需迁移、没有业务数据导入或重算历史报告,仅同步fa_config资源版本值和site.php对应版本,.env/权限及其他配置保持。
715项本地纯检查和10个源码语法检查通过,候选11文件与前轮已验证最终摘要一致;29项服务器和31项公网可信HTTPS及精确回退预检通过。三服务active、worker ready,最后心跳1秒,活动评估/模型调用及三队列0。维护期未触发真实模型/短信,真机及真实模型判断质量仍未验收。最初本地检查的Windows默认编码/隔离类加载入口失败已修正为UTF-8与已有仓库外隔离bootstrap后通过,不计入成功数量;宝塔调查弹窗只关闭、没有提交调查。
服务器归档/root/airz-product-ai-20261014,本机D:/AAdesk/202609/airz-backups/production-release/airz-product-ai-20261014。29份before源码、site/Nginx、数据库灾难备份与回执已下载并独立核摘要,目录权限限制为当前Windows用户/SYSTEM;不下载.env。临时传输和凭据脚本已清理,宝塔退出并关闭专用浏览器。以manifest.json、verify.json、final-verification.json、public-smoke.json、backup-download.json、server-cleanup.json、panel-cleanup.json和release-receipt.json为本轮证据;详细说明见后端database/product-ai-release-20261014.md。
当前小程序源码/前轮微信29页编译及111项展示检查保持,申请条件保持且不新增AI来源说明,用户自行发行。下方“自定义AI条件和 `{X}` 未部署”是前轮历史状态。本轮不提交/推送/PR,不重跑产品表迁移;以后回退先保护新数据并核对新AI配置/报告与旧代码的兼容性,不能整库覆盖或照旧重放发布脚本。
## 当前工作:2026-10-14 小程序产品展示配套完成(本地)
用户确认申请条件保持,不新增“AI 根据自填资料判断”等来源说明,并同意简化详情、同步匹配列表与对比展示。本轮仅调整小程序 matches 服务和 product/matches/compare 三页。申请条件模板、规则解析、既有资料准备内容保持;AI 已填模板正文保留,只移除自动判断理由中的自填来源后缀,关联依据显示中文资料名称或实际值,不展示内部英文编号。
详情和对比去掉有效期,未填写的额度、利率、期限、还款、渠道及资料来源等可选项隐藏;对比中所有产品均未填写时隐藏整行,部分有值时保留并以“—”显示缺失列。列表及对比补机构 Logo 与实际匹配度,0%正常展示,条件不符/资料不足/旧结果无分数时不造分。推荐理由条数不再当作满足条件数量;排序文案遵循冻结版本,对比仍按所选顺序。资料缺失显示“资料不足”,配置缺失或尚未判断显示“待确认”,不改服务端准入、评分或排序算法。
最终111项针对性检查通过:23项隔离UTS展示、38项实际Chrome页面与交互、19项现有产品展示、31项分包路由及新微信产物检查,失败与重复运行不累计。手机390×844和桌面1280×900无页面横向溢出、错误覆盖层或运行/控制台错误;浏览器QA使用合成数据并拦截所有外部请求,未发业务POST。HBuilderX 5.26微信29页编译成功,H5编译成功但存在既有services/layout.uts的onResize/窗口回调类型声明警告,本轮不扩展修复。真机尚未验证。
证据根:D:/AAdesk/202609/airz-backups/product-miniapp-sync-20261014,before/before-docs保留本轮修改前状态;service-qa.json、ui-qa.json及手机/桌面截图保留验证结果,verification.json绑定最终源码摘要与编译结果,清理以cleanup.json为准。Browser插件未提供,使用已有Playwright/Chrome;首次CLI预览启动中断后重新启动成功,HBuilderX默认浏览器自动打开首页时未在拦截QA范围内,无法据此保证整轮没有正式只读GET。QA路由初始化失败改为全新导航后通过,未改业务路由。
本轮未修改或初始化后端、连接数据库、调用真实模型、部署、发行、提交或推送。此前自定义AI条件及统一 `{X}` 后端/管理端实现仍未发布,后续应配套核对完整发布范围;当前小程序源码和本地微信编译产物已更新,用户自行发行。保留既有未提交改动,融资28项不变;不要用默认后端.env启动测试,或将本轮本地完成视为后端已上线。
## 当前工作:2026-10-14 推荐与不推荐统一 `{X}`(本地)
用户要求不推荐移除“触发结果”,推荐/不推荐模板只用 `{X}`。管理端两个表格均保留关联条件、模板内容、操作三列,关联条件继续手输;多个 `{X}` 按出现顺序填入不同内容。AI 根据正文选择是否适用,不能因资料不足写成明确不符合。标准准入、加减分及排名仍由服务端决定,模板不新增门槛。
新配置 `aiTemplates` 精确五键,保存于原16字段的 `other`,无需DDL。旧六种变量编辑时转为 `{X}`,原 fail/unknown 模板均转换为 caution,允许同条件两份正文由 AI 分别选择;历史报告及未编辑旧冻结目录保持。模型八键 `templateRenderings` 与自定义条件判断合入现有一次请求,绑定版本/摘要、事实引用、精确槽数、金额/时长单位及保存前复算均校验。单次合计32条 AI 工作、32槽、48KB实际关联描述,超量明确尚未判断;不适用对象及 profileOnly 已移除规则的绑定不额外评估。
本轮真实 Chrome 表单51项、隔离假模型实际任务49项通过;涵盖新保存/回显、旧模板转换、手机布局、多槽顺序、正文选择、三态、非法输出无半报告、冻结重试、旧报告保持、租约/用量和归属。另运行新模板纯测试及编辑/发布/规则/存储/AI条件回归、PHP/JS语法检查;最终数量和源码摘要以本轮 `verification.json` 为准,失败及重复不累计。
证据根:D:/AAdesk/202609/airz-backups/product-template-x-20261014,before及before-docs保留前轮状态,template-ui-qa.json/template-task-qa.json/qa-summary.json与截图保留证据,所属服务清理以cleanup.json为准。Browser插件未提供,使用已有Playwright/Chrome;仅仓库外隔离ROOT、本机合成MySQL/Redis/HTTP与假模型,不读取正式.env、不连接正式库或调用真实模型。真实模型的正文选择与填值质量尚未付费验证,本轮未部署/提交/推送或修改小程序运行代码。
本轮运行变更为7个后端库(新ProductAiTemplates及ProductPresentation、ProductPublication、ProductMatching、AssessmentNeedsReport、AnalysisTasks、Assessment)与两个管理端文件;前轮自定义AI条件配套仍未部署,后续发布必须连同其ProductAiConditions和DeepSeekClient等一起核对,不能仅覆盖页面。不得用此前产品表恢复授权自动部署新功能或重跑正式迁移;产品仍原16字段,小程序由用户发行。
## 当前工作:2026-10-14 条件第一列手输与自定义 AI 匹配(本地)
2026-10-14 最新:按用户纠正,仅准入/加减分的“条件项”和推荐/不推荐的“关联条件”第一列改为普通手输,其他列保留结构控件。隐藏内部英文编号,地区/用途及枚举多选改为勾选;改名保留值、对象与分值,模板按中文名称关联及回显。自定义硬/软条件与独立文字模板接入现有一次 AI 评估,标准规则保持,服务端算分、排序和保存前复算;旧快照不重算。产品仍为原16字段,配置保存在other,本轮无需DDL。代码及隔离Chrome/假模型任务验证通过,尚未部署本轮修改或调用真实模型。
新增可选manualSoftConditions(原硬条件七键加points)与textTemplates(id/condition/outcome/text),已有模板仍存ruleId;名称重名和删除原引用拦保存,旧独立文字条件碰同名仍保持原语义。同产品相同文字关联共用判断,避免推荐/不推荐矛盾。AI明确不符使硬准入不推荐,未知不排名;软项仅满足时加减分,0—100在所有分值相加后统一限制;文字模板仅决定理由展示。
判断严格绑定冻结产品id/版本/条件hash及白名单事实,完整结果随报告保存。复用原调用、预算、租约和用量;一次最多32个不同判断/48KB条件描述,超范围明确尚未判断,不能按满足处理。8192输出预算及真实供应商判断质量尚未付费验证;照片未参与评估,不新增28项融资字段,不恢复已撤回的产品原文AI整理。
证据根:D:/AAdesk/202609/airz-backups/product-first-column-20261014;root-checks.json、admin-qa.json、admin-edge-qa.json、ai-task-qa.json及verification.json。Chrome1280×900/390×844实际保存、回显、中文关联、改名、版本冲突和勾选通过;实际AnalysisTasks假模型三态、加减分、重试冻结、错绑定拒绝、迟到租约与用量通过;旧匹配/报告事务回归通过,失败/重复执行不计成功。原AI测试入口默认ROOT问题已修为仓库外无.env,最终测试使用本机显式隔离库/Redis且真实模型构造阻断,未连接正式库。测试进程最终状态以cleanup.json为准。
本轮只改后端/管理端10个运行文件及3个测试,未部署、迁移正式库、提交或推送。发布须配套ProductPresentation、ProductPublication、ProductAiConditions、ProductMatching、AssessmentNeedsReport、AnalysisTasks、Assessment、DeepSeekClient与product.js/form.html,不能只覆盖管理端文件。上午已发布的产品16字段及其他功能按下方发布记录保持。误扩大的整页文字方案已撤回,before及废弃方案归档位于D:/AAdesk/202609/airz-backups/product-text-ai-20261014;不要恢复ProductTextRules/ProductAiMatching废弃文件。
## 当前工作:2026-10-14 正式产品表还原与配套发布
用户明确授权“正式服务器也一起处理并部署”。https://airz.hqling.cloud已发布20261014-product-original-r1,fa_product从实际29列恢复原16个字段,31产品保留;仍使用的匹配参数在原other文本配置,expectation/quota恢复额度/期限说明。额外bank_logo经核对31条均为空后移除;6产品旧日期限制解除并递增内部版本,产品开放通过上下架管理。历史冻结报告不重算,其余28业务表数据/结构、账号权限与.env保持。
17文件定向发布、131运行/依赖摘要、产品逐字段/其他业务表一致性、服务器和公网可信HTTPS、精确回退预检通过,入口与worker已恢复;三服务active、worker ready/idle、活动评估/调用及队列0。本地613项迁移与产品回归及14项发布工具边界检查通过;此前页面/微信编译是前轮证据。小程序由用户重新编译发行,本轮没有发行小程序、真实模型/短信调用、业务导入、提交或推送,真机和正式验收仍待。
证据及备份:D:/AAdesk/202609/airz-backups/production-release/airz-product-original-20261014;服务器/root/airz-product-original-20261014。完整旧产品表、before源码及私密灾难备份保留;恢复须重新暂停写入并保护后续数据,按本轮migration-applied.json精确双表RENAME及恢复源码,不整库覆盖。备份下载和清理以backup-download.json/server-cleanup.json/panel-cleanup.json为准。详细说明:后端database/product-original-release-20261014.md。下方“未部署/未迁移”和仅编辑清空日期为历史状态。
## 当前工作:2026-10-13 管理端移除产品有效期(本地)
用户要求去掉生效日期和失效时间。后台新增/编辑页已移除两输入;控制器统一在保存和发布预检将valid_from/valid_until设为NULL,客户端补传日期也不能恢复限制。旧产品在下一次编辑保存后解除原日期约束,未编辑的旧数据不批量改写;旧规则保护、版本冲突、历史冻结报告及内部日期兼容保持。产品状态通过上架/下架管理,不改融资28项或小程序运行源码。
本轮622项针对性检查通过:纯内存565项、新16/旧28列实际事务保存各12项、管理端实际Chrome交互33项;含新增/回显/上下架、过期及未来旧产品预检/保存、补传日期覆盖、旧窗口冲突。1280×900与390×844,无页面错误/警告或外部请求;Browser插件未提供,使用已有Playwright/Chrome。PHP语法与差异检查通过,失败或重复运行不累计。证据:D:/AAdesk/202609/airz-backups/product-remove-dates-20261013,before保留修改前源码,verification.json绑定最终摘要,资源与合成行清理见cleanup.json。
仅本地修改两个管理端运行文件及一份纯内存测试,未部署、提交、推送、正式库访问或真实模型调用;尚未发行的小程序和此前配套后端发布范围保持。下方日期选填描述为历史状态,以本段为准。
## 当前工作:2026-10-13 准入条件统一录入完成(本地)
用户要求移除独立“地区与用途”,统一点击“添加条件”;条件项支持下拉建议或手输入。管理端已实现,已有地区/用途转换为条件行回显,按对象分别维护用途;重复地区/同一对象重复用途、空条件阻止提交。已知名称继续映射现有规则,任意自定义名称与文字要求保存在原other展示配置的可选manualConditions列表,仅对适用对象提示人工核实、unknown且无推荐排名;既有硬性不符仍不推荐,不重算旧报告。
本轮1013项针对性检查通过,其中管理端实际Chrome交互42项(1280×900、390×844),自定义纯逻辑29项、实际任务/保存/报告读取12项;其余为编辑/发布/规则/评分/存储、新16/旧28列兼容、215项历史报告/PDF回归。页面身份、非空、无错误覆盖层、控制台无错误/警告及外部请求为0。使用现有Playwright/Chrome,因为Browser插件未提供。失败或重复运行不累计,前轮2120项为历史记录。
仅改后端/管理端六个运行文件与两份测试,没有新增表列、小程序运行代码、正式库写入、部署、提交、推送或真实模型调用。发布仍须配套此前原16字段实现,不能仅覆盖旧三个管理端文件。证据根:D:/AAdesk/202609/airz-backups/product-condition-input-20261013,verification.json绑定最终八个源码摘要;before保留本轮修改前文件,截图与admin-qa.json保留页面证据。所属进程、合成行恢复与端口释放以cleanup.json为准。
测试环境首次后台MySQL进程意外退出,改为持续会话启动后验证通过;历史PDF测试的空目录和依赖前提在全新本机合成库airz_product_test_2026101320、已有锁定PDF引擎/字体副本中补齐后通过,不改业务代码。不要重跑start.py/history-env.py/旧setup.py或使用默认.env测试。下一步可在已有本地管理端刷新核对;服务器与小程序发行安排保持。
## 当前工作:2026-10-13 原16字段与文本配置本地实施完成
用户已选择恢复16个原字段名,匹配参数、可选日期和内部版本收纳到原other文本配置;expectation/quota恢复为额度/期限说明。管理端仅产品名称、适用对象为基本必填(新建默认企业),机构/类型/来源/额度/期限/利率/日期等可空,填写后仍校验;资料不足显示待核实,不进入推荐排名。保留InnoDB事务、varchar(100)产品名与状态默认草稿,旧冻结报告不重算。
本轮2120项检查通过:纯转换/发布/编辑/规则/评分、16与28字段实际模型及目录、评估提交/重评/助手/匹配接口、历史报告/PDF、迁移拒绝与回撤、UTS及实际页面;管理端15项、小程序受控H5 8项,1280×900和390×844无应用控制台错误。微信29页编译成功。失败和重复运行不累计,前轮582/980项不另加。
本轮只在专用本机合成MySQL/Redis/HTTP/H5环境验证,不读取正式.env、不访问正式库、不调用真实模型、未部署/提交/推送。正式产品表仍未迁移;发布应配套后端与管理端,不能只覆盖原三个文件。小程序仍由用户发行,真机、正式数据预检和用户验收尚未完成。
证据根:D:/AAdesk/202609/airz-backups/product-original-storage-20261013,verification.json绑定源码摘要,migration-qa.json记录11项演练,admin-qa.json/mobile-qa.json与截图记录页面验证。合成迁移保留before原表与精确回撤SQL;工具不能自动暂停写入,生产转换须另按目标、备份和维护窗口授权。
所属服务停止和端口释放以cleanup.json为准,合成库与原表备份保留;不要重跑创建脚本,不使用默认.env进行测试。下一步可看管理端页面;若要上线,再按配套代码和产品表转换的具体范围准备发布。
## 前轮历史:2026-10-13 产品表原字段核对与有效期选填
用户提供原始16字段产品表,要求尽可能还原并减少必填。当前28字段依赖保存/发布/目录SQL/匹配;完整还原建议恢复expectation/quota并把机器参数和内部版本移到原other配置,保留InnoDB事务及varchar(100)防截断,需完整数据转换。已异步询问“恢复物理字段/仅简化管理端”,尚未收到选择;物理还原和其他字段放宽未实施,不执行旧database/migrate.py、不删列或覆盖建表。
两种方案共通的日期选填已完成:NULL表示无对应日期边界,填写日期仍校验并限制未来/过期,目录/冻结匹配/当前可用性及UTS文案同步。155发布、234编辑、151旧匹配、18UTS、24实际隔离MySQL共582项通过,PHP语法/差异检查和微信29页重新编译成功。未部署/正式库访问/模型调用/提交,隔离MySQL已停止、端口释放,既有数据回滚并核对。备份与证据:D:/AAdesk/202609/airz-backups/product-original-20261013。详情见[原字段与选填核对](产品表还原与选填核对-20261013.md);此前980项为前轮历史验证。
## 前轮状态:2026-10-13 产品机构库按客户原型完成本地实施
用户确认前轮十项建议并要求以原型为准,确认基础分默认80可配置、满足软条件时加减分,未知不计分。管理端八标签、列表筛选/视图/规则/上下架、保存回显/版本/万元转换已修复;规则、模板、评分、资料与真实Logo/顾问电话接入后端及小程序。新目录v2按评分排序,旧v1保持原结果;原型未采集指标为待核实,不增加融资28项,不恢复已撤回的产品AI分析。共980项检查和微信29页编译通过,详见[实施记录](产品机构库原型实施-20261013.md)。
本轮未部署、提交或调用真实模型,两仓既有改动保留。后台前三文件已先备份,配置扩展无需DDL;发布必须同步ProductPresentation、匹配/DTO和管理端相关文件,不能仅覆盖原三文件。真实出处/Logo/顾问渠道由运营核实;本地测试MySQL/Redis/HTTP/H5服务已停止,证据和合成库留在D:/AAdesk/202609/airz-backups/product-prototype-20261013。不要重跑旧setup/reset脚本或直接用默认.env做测试(默认库为远端正式库)。下一步可核对截图/页面,再按确认范围发布后端;小程序仍由用户发行。正式协议/照片/短信及长期照片/7天备份安排见下方前轮状态。
## 前轮状态:2026-10-13 存储、7天滚动备份与密钥恢复准备完成
用户授权继续唯一生产服务器的存储准备,明确在线照片长期保存、备份副本滚动7天。私有目录`/www/wwwroot/xctai.cn/private-documents`已建,www:www/0700、实际FPM合成读写/0600检查通过并清理临时文件;既有.env仅追加directory及enabled=false。照片/短信仍关闭,三张新表0行,正式协议未发布;没有业务数据清理或照片固定到期任务。见[落地记录](照片存储配置落地-20261013.md)。
备份工具在Web外root-only目录,独立`/etc/cron.d/airz-private-photos`每天04:20(Asia/Shanghai),备份目录`/www/backup/airz-private-documents`为root:root/0700,文件0600。工具用REPEATABLE READ只读快照及不可变密文形成对应归档,缺文件或异常失败;每次成功后只清理超过7天且完整校验有效的本工具旧包,不删除在线照片、孤立密文、原SQL或业务行。故障可延迟清理,需查看受控日志处理;原04:00数据库任务和保留3份保持,没有异地照片副本。
共享AES密钥已有独立本机DPAPI CurrentUser副本:D:/AAdesk/202609/airz-backups/key-escrow/production-data-key.dpapi.json,ACL仅当前用户/SYSTEM。独立进程指纹和合成GCM恢复校验通过,无明文密钥文件、没有轮换密钥或持久RSA私钥;换机/重装需受控迁移原Windows用户配置,不能说灾难恢复全面完成。
10:55:12最终核对四服务active,FPM检查、目录权限、关闭状态、配置变更范围及首份空快照校验通过;Windows文件测试6通过/1跳过、Linux7项通过。定时任务首次触发和实际用户照片恢复尚未验证,不计功能/正式验收。宝塔退出、专用浏览器关闭、临时登录/命令脚本已清理。私密归档D:/AAdesk/202609/airz-backups/private-storage-setup/20261013-01:以final-verification-retry.json为成功回执,isolation-hardening.json是最终工具摘要;失败回执保留,不能计成功,不重放prepare/install或再次导出密钥。服务端environment.before.private只留root-only目录,不下载.env。
协议草案同步7天备份规则并重生成格式/摘要,未正式发布。主体、联系方式和申请处理责任暂无,删除后元数据30天/其余证据期限仍待确认,不实施其清理。下一项在这些信息具备时定稿协议并按明确范围开放照片;保留待填,不重复追问、不发布占位正文,也不接续暂缓的真机或正式验收。撤回配置时先只停新增cron并保留备份/密钥,.env必须匹配本轮after摘要且仍关闭、无新照片才能用服务器before恢复;禁止整库覆盖或清空在线目录。
进度2.32/1.42、开发文档v2.00/1.13,14前端运行摘要、29页/63任务/融资28项及产品AI回撤保持;小程序用户发行,未提交/推送/PR。下方保留各轮历史状态,接续时以本节及落地回执为准。
## 前轮状态:2026-10-13 在线照片长期保存已确认,规则已修订
用户解释后明确“希望长期保存”。当前在线照片在账户正常使用且仍需本人保管时长期保存,不设置上传起固定到期时间,原30天建议取消。本人删除、用途结束、撤销作为保存依据的同意及注销申请仍按规则处理;不把长期保存写成永久保存保证或撤销后任意留存。照片备份7天和物理删除完成后元数据30天仍仅建议,不推导为在线照片期限。下方各轮30天为历史建议,后续不得照旧新增照片到期删除任务。
已更新[照片规则](协议草案/照片保存与删除规则.md)、隐私政策/资料使用协议、草案入口和[存储方案](私有照片存储与备份方案-20261013.md),格式核对及[草案摘要](协议草案/草案清单.json)已重新生成;用户协议与数据授权正文保持。既有PrivateDocuments没有固定到期删除,此次不改前后端运行代码或增加任务/配置。
本次仅本地修订,不访问生产或读取.env、不正式发布/启用/验收/发行;照片和短信仍关闭,主体及删除联系方式用户暂无,目录权限/照片备份/独立密钥恢复还未落实。前轮只读服务器证据及归档保持,不能改写其摘要或重跑发布;原14前端运行摘要保持。进度2.31/1.41、开发文档v1.99/1.12,29页/63任务/融资28项、产品AI回撤及小程序用户发行安排保持,无提交/推送/PR。
下一项依当前长期保存规则准备现有服务器的关闭状态目录权限及备份恢复安排,删除申请处理和其他副本期限单独落实;生产写入按明确范围和恢复办法推进,不新增在线照片到期删除引擎、不重复追问暂无主体信息、不接续暂缓的真机或正式验收。
## 前轮状态:2026-10-13 私有存储/备份只读核对完成,尚未配置
用户继续下一步且先不验收,已对唯一现有服务器做只读核对并形成[私有照片存储与备份方案](私有照片存储与备份方案-20261013.md)。推荐 `/www/wwwroot/xctai.cn/private-documents`(public同级),www:www/0700、文件0600;它符合现有public/.user.ini的open_basedir,不能只看CLI的空限制。目录不存在、祖先无symlink、无公开root/alias覆盖;本轮没有创建目录、修改immutable配置或放宽路径权限。
生产版本保持20261012-agreement-documents-r1,照片enabled=false/directory空、短信关闭。10:03:51三张新表均0行、活动评估/模型调用及三队列0;10:10:47三服务active,.env/本站Nginx/.user.ini/PHP/FPM五配置摘要保持(Asia/Shanghai)。这些是本轮时点检查,不是功能验收或整时段模型计费统计。未DDL/业务写入、权限/角色变更、模型/短信请求、发行或提交/推送/PR。
宝塔当前使用data/db/分库,旧default.db为空不能据此判无备份。真实MySQL备份每天04:00、目标ALL、localhost保留3份;10-11/12/13三份airz gzip只读解压通过,最新04:00:01含三张新表,未恢复。照片密文没有日常备份,已知发布包不含.env,尚无独立生产密钥恢复证据;现有密钥32字节/GCM内存往返正常不等于已托管。同盘剩余约166GiB,不代表异地或主机故障保护;地域、云快照和其他副本未核实。
私密检查归档:D:/AAdesk/202609/airz-backups/private-storage-preflight/20261013-01;storage-inventory/backup-discovery/backup-php-detail/preferred-storage/final-guards五回执可接续。panel-cleanup证实宝塔退出、专用浏览器关闭、login.private.js及command.private.js删除;权限核对结果以preferred-storage的worker字段为准。后续复用既有DPAPI凭据,不索取/输出密码、不下载.env,不覆盖本轮或上轮发布回执,不重跑已完成部署。
下一项可准备关闭状态下的推荐目录权限、root-only照片备份目录和独立密钥恢复安排,具体路径/权限/备份排除/删除与恢复保护见方案;尚未执行,不能把只读核对写成生产配置完成。实际生产写入需明确对应范围和恢复办法,不凭此前29文件部署授权自动增加计划任务、传出密钥或开放照片。主体/隐私删除联系方式用户暂无,继续保留待填;照片30天/备份7天/物理删除完成后元数据30天只是建议,不能设置或发布为已生效。
原四份协议正文/草案摘要、前后端运行源码未改,正式正文未发布,照片/短信仍关闭;用户先不验收,小程序由用户发行。进度2.30/1.40、开发文档v1.98/1.11;29页/63任务/融资28项、同事产品库和产品AI回撤保持。下方为各轮历史状态,按当前状态接续,不自动恢复真机/正式签收。
## 前轮状态:2026-10-13 协议草案已整理,暂缓验收
用户先不验收,要求继续下一步;本轮完成[四份协议草案及待定信息](协议草案/README.md)和[照片保存/删除规则](协议草案/照片保存与删除规则.md)。用户明确暂时没有平台对外名称、运营主体和隐私/删除联系方式,正文保留明确待填位置,不追问或编造。四份UTF-8无BOM纯文本可定稿后用于现有协议后台,不提供自动导入/发布。
照片只供本人保存、查看、删除,未进入评估/模型/OCR/外部查询。撤销资料授权阻止旧同意记录的新上传,不自动删除既有文件;本人仍能管理。在线删除、元数据、同意版本证据和备份副本分别说明。数据授权仍只是本地意向,不充当第三方凭证、实际查询或自填/AI的处理门禁。AI实际经腾讯云TokenHub调用DeepSeek,服务方信息/留存/地域及处理依据待核对。
期限建议为照片上传起30天、照片备份7天、完成删除后元数据30天,全部尚待确认,不是已启用规则。现有代码没有自动到期/元数据/备份清理,不新增复杂引擎;要启用时落实最小授权运维清理与备份恢复保护。账号/报告/对话/同意/日志期限单独定稿,不能套用照片期限。正式目录/ACL、备份实际范围、密钥恢复和执行人仍未核对。
下一步可核对现有服务器私有存储/权限及备份方案,仍只使用现有服务器;实际开放须补主体/联系方式、确认期限并落实规则。当前没有发布占位正文或开启照片,短信仍关闭、正式外部查询未接入;后端发布仍为20261012-agreement-documents-r1,小程序由用户自行发行。本轮不连接生产、验收、发模型/短信、提交/推送/PR。
四份正文通过现有后端纯文本/长度/code-scope校验,时间使用仅内存2099格式夹具而非真实生效日期;本地摘要/编码/链接核对不算功能验收。草案源码绑定[清单](协议草案/草案清单.json),修改后需更新。进度2.29/1.39、文档v1.97/1.10,29页面/63任务/融资28项及产品AI回撤保持。下方2026-10-12服务状态是历史发布时点,不是本轮线上重测;不能重跑已完成发布或已清理测试工具。
## 前轮状态:2026-10-12 唯一现有服务器已部署
用户明确授权“就一个服务器,没有专门的测试服务器,直接部署吧,小程序我自己部署”。后端已发布20261012-agreement-documents-r1;不再要求另找独立测试服务器。前端APP_ENVIRONMENT=production,正式API=https://airz.hqling.cloud/api/v1,test地址留空、demo=false;小程序由用户从当前完整29页源码重新编译发行,本轮没有上传微信或发布H5,也未重新编译目标端。
发布29文件、三张空表(fa_agreement_revision/fa_user_consent/fa_private_document)、协议五节点和独立运营v2模板;7已有源码/22新增,旧26表、已有角色/账号绑定、产品库、.env/模型与非版本site配置保持。17:00:39完成备份,17:00:58更新,17:05:07恢复入口/worker,17:10:26最终核验(Asia/Shanghai)。27服务器HTTPS/31公网可信TLS、116源码摘要、29文件精确回退干跑通过,三服务active、worker ready/idle心跳0秒,活动工作及队列0;以上为发布检查时点。维护模型请求/真实短信0。
首次验证发现全局Nginx错误拦截将协议404 JSON改为HTML,已仅在本域名server增加fastcgi_intercept_errors off并重新验证,全局配置保持;维护标记已清理,不能再宣称Nginx完全原样。精确回退工具已兼容本次修正,原Nginx/site/7before源码与26表灾难dump保留;新增权限只有未被更改且无新绑定时可按本批IDs定向删除,三张增量表保留。上线后不直接重跑旧脚本,不整库覆盖后续数据。
37备份文件已下载逐项SHA核验,服务器归档root/700/600,本机仅当前Windows用户/SYSTEM访问;宝塔退出、专用浏览器关闭,临时传输和凭据清理。证据:D:/AAdesk/202609/airz-backups/production-release/airz-agreement-documents-1791794619319;[发布说明](后端协议与照片发布-20261012.md)及[发布回执](D:/AAdesk/202609/airz-backups/production-release/airz-agreement-documents-1791794619319/release-receipt.json)。14相关前端源码摘要已冻结,不能当微信上传包摘要;已清理本机测试runner不能复跑。两仓既有未提交改动保留,没有提交/推送/PR。
下一步是用户小程序发行与设备复核,并完成实际启用条件:四份正式协议全文未提供/审核发布(明确404 AGREEMENT_NOT_AVAILABLE);短信渠道/签名/模板未接入(503 SMS_NOT_CONFIGURED);照片正式用途、期限、撤销处理、Web外私有目录/ACL及密钥恢复未确认,真实上传继续关闭。既有密码登录允许协议明确未发布,自填评估/28项字段保持,照片暂不参与评估。小程序先确认微信request/uploadFile/downloadFile合法域名,记录实际发行版本/时间/源码或包摘要,真机及正式验收不预填通过。
进度2.28/1.38、开发文档v1.96/1.09,29页面/63任务、产品AI回撤和同事产品库保持。下方独立测试前置、未部署及默认test描述是历史快照,由本段覆盖。
## 前轮状态:2026-10-12 第1步本机独立测试准备完成
用户要求“推进第1步”:分开测试/生产配置,接通测试协议与照片,仅合成资料、短信关闭。已清空services/config.uts中原指向已发布服务的TEST_API_URL;默认test未配置时请求明确失败,production也留空,未知环境不回退。development仅在本轮前端副本显式启用,指向本机18580。后端原.env未读取或修改,新增.env.test.sample,正式私有上传配置仍默认关闭。
最小配套文件、三份增量DDL、协议后台权限、顺序/回退及微信合法域名条件见[测试部署清单](协议与照片测试部署清单-20261012.md)。14前端/30后端运行及DDL/配置源码清单、13配置/API预检、32实际前后端H5检查(20页面本机API请求、0运行/控制台错误/未知外呼)、26组照片与30项协议检查通过。验证同意、上传、原卡片缩略图/菜单预览、离页恢复、跨账户拒绝、撤销后禁止新上传及本人删除;没有评估/真实模型任务。
本轮证据D:/AAdesk/202609/airz-backups/test-configuration/1791793388934;63份bootstrap审计全部模型/短信/生产.env false。29表清空、Redis零键、照片零文件后删除精确合成库,所属API18580/H55182及子进程/MySQL/Redis停止并释放端口。run.py是已清理本轮工具,不能直接重复start;必须创建新的归属环境。H5复用现有HBuilderX/Playwright,未安装依赖;前轮29页微信/Android编译证据保持,本轮未重新编译目标端。默认源码现在不连接远程服务;不能把仓库后端.env当测试配置直接启动。
进度2.27/1.37、开发文档v1.95/1.08。下一步先取得独立测试服务器/站点及HTTPS域名,再按具体目标预检/授权部署测试站点、配置微信request/uploadFile/downloadFile合法域名并编译体验版真机验证;当前未指定远程测试目标,不自行写外部服务器。FILE-001正式用途/期限/正文/目录与权限/密钥恢复仍阻塞,不等同正式上线或解除验收条件。融资28项、29页面、63任务、同事产品库及产品AI回撤保持;未提交/推送/PR。
## 前轮状态:2026-10-12 私有资料上传内部实现(默认关闭、未上线)
资料照片上传、本人鉴权预览和删除内部代码已实现,真实上传默认关闭、正式目录留空;照片单独保存、未核验、暂不参与评估。上传后直接在原卡片显示缩略图,查看/删除从卡片菜单操作,没有下方独立列表;离页后可恢复本人已保存照片,同类多张及删除失败的ID可继续管理。168项私有资料/44次本机HTTP、268项公开头像/76HTTP、351项协议/86HTTP、26组UTS、30项协议及31项分包通过,29页Android/微信编译和58项受控H5通过;未上线。正式用途/期限/目录与真机验收待办。详见[开发记录](私有资料上传开发-20261012.md)。
接续代码已实现三个接口及原卡片操作,私有Secret加密照片不进public/uploads或公开附件表;服务端归属/同意检查,撤销后禁止新上传、本人仍可管理。默认private_documents.enabled=false且directory为空,正式用途/保留期限/实际目录及ACL尚未确认,不得启用。上传未知回执仅手动重试,可能另存;复用GET /consents?scope=documents返回独立的本人资料引用,不依赖同意分页或撤销状态;重新进入时恢复缩略图及各ID操作,不新增业务接口或评估绑定。
本轮证据D:/AAdesk/202609/airz-backups/private-documents/1791787367342及C:/Users/JUN/AppData/Local/Temp/airz-private-documents-ui-1791787367342;精确合成库/文件/Redis/所属服务清理以cleanup回执为准,不能直接复跑已清理runner。默认两仓生产地址未改,不启动默认框架或未拦截H5;原5174服务保持。仅新建明确归属隔离环境,0生产/真实短信/模型/发布/提交/推送/PR。
进度2.26/1.36、开发文档v1.94/1.07。FILE-001正式条件仍阻塞,FILE-002/004内部完成但未正式验收;融资28项、29页面、63任务、既有短信/协议改动及同事产品库保持,产品AI回撤继续有效。后续正式启用需另行具体预检/授权和真机验证,不自动上线或增加OCR/外部查询。
## 前轮状态:2026-10-12 资料上传方案简化
用户明确要求“还是不设计这么复杂”。[简化方案](superpowers/specs/2026-10-12-private-documents-design.md)已覆盖原复杂提案,只保留上传照片、本人查看和删除,复用现有选图、登录鉴权和图片校验。照片保存到服务器Web目录外,随机文件名,小型资料表记录归属,沿用既有加密。不能把身份证等直接放入public/uploads。
取消云存储扩展层、上传命令账本/租约、专门原键查询接口、多阶段状态机及独立清理worker,不再按旧提案扩张。资料仍选填,融资28项不增加;附件进入评估另做最小集成,不做OCR或外部查询。
下一步按三个后端接口与原资料卡片推进,正式用途/保存期限和实际私有目录在启用前确定。本轮仅简化文档,没有上传运行代码/DDL/存储目录或生产操作。两仓短信/协议改动、29页面、63任务及原测试回执保持;进度2.25/1.35、开发文档v1.93/1.06为同日简化修订,FILE-001条件仍待,FILE-002/004未实施。
默认前后端仍指向生产,实施须全新合成隔离环境,不复用已清理的协议runner。未提交/推送;同事产品库与产品AI回撤保持。
## 前轮状态:2026-10-12 协议全文与同意记录内部实现
协议全文、后台审核/不可变版本和本人同意/可选撤销记录已实现,新增两页共29页,63任务编号保持。融资28项填写内容、私有上传/外部查询及同事产品库未改,产品AI回撤保持。正式协议正文未提供、未发布;两张新表只在本机隔离库安装,正式权限未迁移、代码未部署,短信仍默认关闭。
当前版只接受审核发布的结构化正文;同版不可变,未来尚未生效不进入当前,最新下架不回退旧版,历史全文保留。同意/撤销键共用本人唯一空间,旧键可以补确认,仅documents/data提供撤销。认证后同意记录未知时手动原键重试;明确404未发布允许已有账户密码登录且不造记录,网络/503不能绕过。待确认仅同会话页面内保存,不提供刷新/进程重启持久恢复。
最终六套隔离1730项/380次本机HTTP通过;协议351、后台500、会话284、资料268、短信账户271、内容服务56。纯协议40/权限106另列;14PHP语法与两仓diff通过,失败和重复运行不累计。公开读与同源发布的误404已先复现再修正。独立复审发现的问题均已红绿修正并复审关闭,30项纯源码及31项微信分包检查通过;最终源码29页Android class于12:21:51、微信小程序于12:22:35编译成功(2026-10-12,Asia/Shanghai)。初轮H5完整78项与最终复审专项48项分开记录,最终源码绑定source-manifest.final-return.json及final-return-receipt.json,见开发记录及本轮verification。
后端证据D:/AAdesk/202609/airz-backups/agreement-consents/1791775959310,前端证据C:/Users/JUN/AppData/Local/Temp/airz-agreements-ui-20261012及后续专项。新隔离28表/8触发器和Redis零键已在清理前核验;精确库/所属服务清理以本轮cleanup回执为准,禁止复用已清理runner测试。默认前后端仍连接生产,不通过默认H5入口或框架回归。归档包含私密配置,不整包共享。
进度2.24/1.34、开发文档v1.92/1.05,见[开发记录](协议全文与同意记录开发-20261012.md)。正式全文、资料用途/保留期限/撤销后处理、第三方授权凭据、真机和正式运营签收仍待;FILE-003仅内部协议与本地记录功能完成,FILE-001/002/004依各自条件推进。生产上线需另做具体范围预检并获得发布授权,不自动恢复产品AI或继续上传/外部查询。
## 前轮状态:2026-10-12 短信账户内部代码已实现,默认关闭
用户已授权先写短信登录/首次注册、验证码改密和原/新号码双验证换绑代码,服务商、已审核签名及模板暂缺。本轮后端实现已完成,sms_verification.enabled=false;关闭时在Provider Hook及短信Redis连接前拒绝,返回503/SMS_NOT_CONFIGURED,不模拟成功、不注册账户、不发送真实短信。原密码登录与账户资料保持可用。产品机构库由同事维护,本轮未改产品模块、融资评估28项或前端运行源码,已回撤的产品AI条件分析不恢复。
新增SmsVerification/Provider契约及AccountSms,复用现有Token、Auth和手机号唯一索引,无DDL/迁移。验证码用途/账户/当前号码绑定、摘要存储、冷却/限流、原子单双码核销;改密/换绑撤销本人全部Token。验证码已消费后的存储失败不恢复旧码,须重发。短信改密仅登录本人,不增加匿名找回密码;Auth锁内重核手机号,已修复换绑后旧号码并发密码登录重建会话的问题。
四套最终隔离测试:短信引擎71、短信账户271/54HTTP、原会话284/70HTTP、资料头像268/76HTTP,共894项/200次本机HTTP通过。双worker检查一次注册、号码唯一归属及赢家Token撤销/输家号码与Token保持;旧号码专项27项不重复计入。12PHP语法、两仓diff及独立只读复审通过,未重新编译前端或执行真实渠道/目标端验收。
归档D:/AAdesk/202609/airz-backups/account-sms/1791773313671保留脱敏输出、verification与cleanup回执。26合成表核空/结构和8触发器保持、Redis DB15零键,本轮精确数据库已删除、所属MySQL/Redis已退出/端口释放;395模型屏蔽回执确认真实客户端加载0次,短信JSON夹具及本轮生成Python缓存已清理。不读取默认生产.env、无真实短信/模型/生产访问/部署/提交/推送。测试环境已清理,不能直接重跑旧runner的test。
进度2.23/1.33、开发文档v1.91/1.04,见[短信账户开发记录](短信账户功能开发-20261012.md)。AUTH-001真实渠道仍阻塞,AUTH-002/004内部功能接通但正式未验收。下一步待资料齐备后补Provider适配、验证真实渠道及小程序端,再单独安排发布;期间可以按用户新要求做其他功能,无需改融资录入内容。下方保留前轮历史状态,产品AI回撤继续有效。
## 前轮状态:2026-10-10 产品AI条件分析与后台人工复核已回撤
用户已要求撤回“25条正式产品 AI 条件分析与后台人工复核”。本地分析/归一化/证据校验、独立任务与预算账本、后台原文对照及草稿/确认接口、权限、消费命令和专用配置/迁移/测试全部撤回;旧产品后台和DeepSeek客户端恢复到本功能前内容。本功能与其余20条接续已取消,后续会话不得按旧试点授权自动恢复或继续调用。
+49
View File
@@ -0,0 +1,49 @@
# 照片存储配置落地记录
2026-10-13。用户授权继续服务器存储准备,已确认在线照片长期保存、备份副本滚动保留7天。本轮配置完成,照片上传仍关闭;正式协议、主体和删除联系方式尚待补齐。用户继续暂缓功能验收,小程序自行发行。
## 已落地配置
| 对象 | 实际结果 |
| --- | --- |
| 在线照片 | `/www/wwwroot/xctai.cn/private-documents`,public同级;www:www(1001:1001)/0700,目前为空 |
| 运行配置 | 仅在既有`.env`追加private_documents区段:enabled=false、directory为上述路径;其他原始字节保持,没有更换或轮换密钥 |
| 权限核对 | 以www并使用现有open_basedir的CLI检查通过;另通过本机FastCGI实际FPM进程(1001:1001)进行合成文件读写/0600权限检查,测试脚本及文件已清理。没有调用上传API或合成生产用户 |
| 备份目录 | `/www/backup/airz-private-documents`,root:root/0700;文件0600,没有Nginx公开映射,不修改旧备份父目录权限 |
| 备份任务 | `/etc/cron.d/airz-private-photos`,root:root/0644;每日04:20,服务器Asia/Shanghai;MAILTO为空,不发邮件。crond active,原root crontab和04:00数据库任务保持 |
| 工具 | `/root/airz-private-storage-setup-20261013-01/database/private_documents_backup.py`,root-only目录/文件0600;对应本机后端`database/private_documents_backup.py`。无需框架启动或新依赖 |
| 密钥副本 | [production-data-key.dpapi.json](D:/AAdesk/202609/airz-backups/key-escrow/production-data-key.dpapi.json),本机DPAPI CurrentUser保护,ACL仅当前Windows用户/SYSTEM;不在Git仓库或照片备份包内 |
`.env`的服务器before副本仅保存在本轮root-only归档,未下载`.env`。密钥在服务器以临时RSA公钥封装后传回本机,在内存解包后立即转成DPAPI文件;没有输出、写入明文密钥文件或持久保存RSA私钥。本机另一个进程只读该DPAPI副本,即可核对原指纹并成功认证解密合成GCM校验串;没有解密真实业务资料。DPAPI依赖原Windows用户配置,换机/重装前应先完成受控迁移,不能把当前文件直接当作任意机器可用的密钥包。
## 备份范围与执行边界
工具用独立PDO、显式REPEATABLE READ只读事务取得资料元数据、资料scope的同意/撤销证据、document-use协议版本、三表结构和所依赖账户的id/createtime。密钥只用于生成指纹,不进入JSON或归档。后续读取清单中的活动照片密文,已删除行只保留状态、不会备份其文件;孤立密文不自动打包,也不自动删除。
归档包含metadata.json、文件摘要manifest.json和相关密文`.bin`。照片和元数据相互对应:文件本来缺失、链接、大小异常、并发修改或摘要校验失败会拒绝成功,不把残缺结果当作正常备份;上传/删除与备份并发时,必要情况下本次失败并等待重试,不暂停业务或改在线状态。新上传未进入该次快照的,留待下一次备份。归档的照片可能在快照之后被删除,恢复时必须结合最新删除/撤销记录处理。
每次成功备份后检查本工具生成的归档,删除创建时间已超过7天的旧副本。只匹配并完整验证本工具的photos-时间-随机ID.zip,未知文件保持;不触及在线照片、原数据库SQL、密钥副本或资料表。机器停机、任务失败或旧归档损坏时可能延迟清理,需查看受控backup.log并处理,不能宣称“无论故障都恰好7天内删除”。照片删除后元数据30天仍是未确认建议,本轮没有元数据清理或在线照片到期任务。
工具限制单张密文7MiB、元数据16MiB、单包密文总量2GiB/最多10000条资料记录;磁盘可用量小于1GiB时失败,避免无界生成。它们是当前运维工具的拒绝边界,不是新增的用户上传配额;后续接近边界时调整备份安排。日志只写状态、计数、归档名称和摘要,不输出行内容或凭据。
04:00整库备份仍保留3份,同盘照片备份用于有限误删/文件故障恢复;没有配置异地照片副本。照片包不是整库恢复包,不含密码/模型凭据或全部账户资料,恢复需先保护当前数据并核对所属账户及对应数据库恢复点。不能用另一时点整库直接覆盖已有用户数据,再把照片包无条件导入。
## 验证与证据
10:46:09目录/关闭配置完成,10:49:54备份任务安装并手动生成首份空资料快照(Asia/Shanghai)。当前三张新表均0行、在线目录为空;首份备份照片数0,归档SHA核对通过。这验证了正式环境的读取和归档路径,不是实际用户照片恢复验收,也未等待下一次04:20定时触发。
Windows合成文件测试6通过/1符号链接跳过,服务器独立临时目录7项全通过,覆盖缺文件失败、墓碑保留、孤立密文保留、七天边界、损坏包拒绝、路径和symlink拒绝。补充显式事务隔离后再次服务器7项及只读快照通过。没有合成数据库、DDL、业务写入、模型或短信请求,也没有改变账号/角色/产品库。
最后核对确认四服务airz-analysis/php-fpm-74/redis/crond均active,照片及短信关闭、三张新表仍为空;Nginx和root crontab保持,`.env`只追加照片区段。初次检查通道改写遗漏回执解析、最终核对模板占位符冲突及cron环境行被误计为任务的错误已定位修正;失败回执保持,最终以final-verification-retry.json为准,不能将失败计为通过。
本机私密归档:[20261013-01](D:/AAdesk/202609/airz-backups/private-storage-setup/20261013-01),包含before-inventory、prepare、install、isolation-hardening、key-export、key-recovery和final-verification-retry回执。服务端归档为`/root/airz-private-storage-setup-20261013-01`,root-only;敏感environment.before.private不共享或下载。panel-cleanup证实宝塔退出、专用浏览器关闭,login.private.js/command.private.js已删除。
本地文档同步后,四正文通过AgreementRegistry纯格式校验,草案清单/正文/配套文件摘要一致;最终工具和测试摘要与服务器回执一致,14前端运行摘要保持。20个相关文本UTF-8无BOM和空白检查、25个本地链接、两个仓库diff检查及40个私密目录/文件ACL检查通过,无当前用户/SYSTEM以外的允许规则。结果保存为上述归档内local-verification.json;本地核对未读取生产配置、访问数据库或网络,不把其时间当作新的生产探测时间。
## 恢复和接续
归档可用工具的`--verify <具体归档绝对路径>`进行只读检查;恢复先在Web外隔离位置校验摘要、密钥指纹、所属账户和协议/同意关系,再与备份之后的删除、撤销、用途结束和注销处理记录核对。已删除或应处理删除的照片不得重新可读;最新保护记录缺失时保持隔离。当前未实施任何生产恢复或自动恢复入口。
撤回本轮配置时,先仅停用新增cron文件并保留备份/密钥。恢复`.env`前必须匹配本轮after摘要且仍关闭、无新照片,再使用服务器before;出现配置漂移或新业务数据则先保留现场。目录仅在确认本轮创建、仍为空时才能非递归移除,不清空在线目录、整库或密钥。
下一项为补齐主体及有效隐私/删除申请渠道,定稿并正式发布协议后再按明确范围开放照片。用户已说暂无信息,保留待填,不编造或重复追问。用户验收继续暂缓;29页面/63任务/融资28项和产品AI回撤保持,未提交/推送/PR。
+69
View File
@@ -0,0 +1,69 @@
# 短信账户功能开发记录
2026-10-12,按用户“先写相关代码”的要求完成短信登录/首次注册、登录后验证码改密及原/新手机号双验证换绑的后端实现。供应商、已审核签名及模板暂缺,配置默认关闭;本轮未发送真实短信、连接生产或部署,未提交/推送。
现有小程序页面和请求契约可复用,本轮没有修改前端运行代码、融资评估28项输入或同事负责的产品机构库。已回撤的产品AI条件分析与人工复核不恢复。AUTH-001真实渠道仍阻塞,AUTH-002/004内部代码已接通,完整任务和正式验收仍未完成。
## 已实现行为
- 验证码采用六位安全随机数字、300秒有效期、同号码60秒冷却;每小时号码最多10次、IP最多30次,错误最多5次。Redis仅保存HMAC摘要,号码、IP和用途/账户上下文的键也作摘要处理。
- 登录验证码绑定登录用途和号码;安全验证码绑定用途、本人账户及发送时的当前手机号,不能跨账户、用途或换绑后的身份复用。
- 发送先预留,供应商明确确认成功后才激活;失败或结果未知时不产生可用验证码,冷却和限额仍保留。迟到失败不能删除后续新挑战。
- 单码/双码通过Lua原子核销;换绑任一码错误不消耗另一正确码,重复/并发最多成功一次。已核销后SQL、Hook或Token存储失败不恢复验证码,须重新发送。
- 首次短信登录生成随机未知初始密码,沿用现有密码哈希、Token及注册逻辑;邮箱为空、昵称为“融资用户”、积分为零。已有正常账户直接登录,禁用账户拒绝。
- 验证码改密须登录本人,不新增匿名找回密码;新密码8–32位且含字母和数字、不含空格。改密和换绑均撤销本人全部会话,要求重新登录。
- 换绑检查同号码及号码占用,复用现有手机号唯一索引,无DDL或迁移;账户行锁内再次核对会话和当前号码,历史评估快照不改写。密码登录也在行锁内重核号码,防止换绑后的旧号码并发重建会话。
## 接口
路径均以 `/api/v1` 为前缀,请求为JSON;安全操作使用已有Bearer鉴权。
| 方法/路径 | 请求字段 | 行为 |
| --- | --- | --- |
| POST `/auth/sms-code` | `phone`、`purpose: login` | 发送登录验证码,成功返回 `retryAfter`/`expiresIn` |
| POST `/auth/sms-login` | `phone`、`code` | 登录/首次注册,返回原会话及profile契约 |
| POST `/account/security/sms-code` | `purpose: password / phone-current / phone-new`、`newPhone` | 前两种只发给本人当前号码;新号码用途须提供有效且未占用的新号码 |
| PUT `/account/password` | `verification: sms`、`newPassword`、`code`,兼容页面发送的空 `oldPassword` | 本人短信验证改密;原密码验证分支保留 |
| PUT `/account/phone` | `newPhone`、`currentCode`、`newCode` | 原/新号码同时验证后换绑 |
改密/换绑成功返回 `reLoginRequired: true`。短信未启用或缺少可用Provider时,认证后的请求及公开短信入口返回503/`SMS_NOT_CONFIGURED`;未登录的安全操作仍返回401。关闭配置在供应商Hook及短信Redis连接之前生效,不发送、不生成验证码、不注册账户;原密码登录、资料及头像功能可继续使用。
## 后续供应商接入
后端仓库为 `D:/AAdesk/202609/airz`,新增服务 `application/common/library/SmsVerification.php` 和 `AccountSms.php`;`application/extra/sms_verification.php` 中的 `enabled` 固定为 `false`。
供应商实现以下契约,通过正常扩展Hook `sms_verification_provider` 返回Provider实例,配置项作为Hook参数传入:
```php
namespace app\common\library\smsverification;
interface Provider
{
public function available(): bool;
public function send(string $phone, string $code, string $purpose): bool;
}
```
`available()` 只在凭据、已审核签名、用途模板均可用时返回true;`send()` 按四种用途映射模板并限制超时,只有供应商明确接受该次发送才返回true。异常、超时或未知结果不能当成功,也不能自动重试未知发送。日志须屏蔽号码、验证码和凭据;密钥通过环境配置注入,不存仓库。短信摘要秘密由已有服务端 `token.key` 派生,复用Token的Redis配置和独立 `tokenprefix + sms:` 命名空间。
缺少Provider时默认使用 `UnavailableProvider`。当前没有真实供应商实现、生产模拟发送开关或固定验证码;测试替身只在tests目录中、显式隔离bootstrap下使用。资料齐备后补Provider适配及真实渠道/目标端验证,再单独安排后端发布;本轮代码完成不等于短信已经上线。
## 验证证据
全新本机MySQL5.7.26/Redis DB15、26张空合成表、独立ROOT及命名空间,未读取默认生产 `.env`。四套最终通过结果如下,不重复累计中途运行及专项检查:
| 入口 | 最终回执 | 检查数 | 本机HTTP请求 |
| --- | --- | ---: | ---: |
| `tests/sms_verification.php` | `sms-green-1` | 71 | 0 |
| `tests/account_sms.php` | `account-green-4` | 271 | 54 |
| `tests/api_session.php` | `session-regression-1` | 284 | 70 |
| `tests/api_profile.php` | `profile-regression-1` | 268 | 76 |
| 合计 | 四套入口 | 894 | 200 |
覆盖关闭零写入、限流/过期/错误上限、摘要存储、发送失败、用途/账户隔离、重复及并发核销、首次注册、双码换绑、全会话撤销、禁用账户、Hook/SQL/Redis故障及原密码登录/资料/头像回归。旧号码并发登录缺陷已通过红测试复现并修正;独立专项27项不另计入上表。新增双worker竞争验证一次注册、号码唯一归属、赢家Token撤销及输家号码/Token保持;未强制两方同时通过占用预检,不宣称确定覆盖唯一索引冲突分支。独立只读复审未发现P1/P2问题。
本轮12个PHP文件语法检查和两仓diff检查通过。未改前端运行代码,未重新编译小程序;真实短信到达、渠道错误码/超时、平台及多设备流程仍待供应商接入后验证。
私密归档:`D:/AAdesk/202609/airz-backups/account-sms/1791773313671`。脱敏测试输出及单套JSON、`verification.json`、`cleanup.json` 和进程退出回执保留。每套测试后表行和Redis核空、结构及8原触发器保持;最终仅删除本轮精确合成数据库,关闭所属两服务并释放端口。395份屏蔽回执显示真实模型客户端加载0次;短信测试JSON夹具和本轮生成Python缓存已精确清理,归档及证据保留。该归档包含私密配置及历史失败诊断,不整体共享,不直接复跑已清理环境。
下一步在短信资料未齐时,可继续其他已授权功能;接入短信时复用本契约补供应商适配,不需要改动融资录入内容或产品机构库。
@@ -0,0 +1,82 @@
# 私有照片存储与备份方案
2026-10-13。用户已确认在线照片长期保存、备份副本滚动保留7天,并授权继续配置现有唯一服务器。私有目录、每日04:20照片备份及本机受控密钥副本已落实,最终10:55:12核对通过,详见[落地记录](照片存储配置落地-20261013.md)。照片/短信仍关闭,正式协议未发布;主体和申请联系方式暂无,物理删除完成后元数据30天仍只是未确认建议。用户暂缓验收,小程序仍由用户发行。
下方只读核对保留10:03—10:10时点的历史证据,不能用其中“尚不存在”推断当前状态;配置实施结果以落地记录为准,原回执未覆盖。
## 配置前只读核对结论
生产后端仍为 `20261012-agreement-documents-r1`。10:03:51(Asia/Shanghai)查询到协议版本、本人同意、私有资料三表均为0行;照片 `enabled=false`、`directory=''`,短信关闭。活动评估、活动模型调用及 ready/delayed/reserved 队列均为0,这是检查时点的快照,不是整个时段的模型用量统计。
10:10:47最终检查中 `airz-analysis`、`php-fpm-74`、`redis` 均为 active;`.env`、本站Nginx、`.user.ini`、PHP全局及FPM配置摘要与本轮初次读取一致。未创建服务器目录、修改权限/配置/定时任务、执行DDL或业务写入,也未发起模型或短信请求。
| 项目 | 只读证据及含义 |
| --- | --- |
| 公开目录 | Nginx root 为 `/www/wwwroot/xctai.cn/public`;核对本站7个配置/包含文件及现有vhost的字面量 root/alias,没有发现覆盖推荐照片目录的映射。此结论依赖当前配置,后续改变root或新增alias要重新检查 |
| PHP身份 | PHP 7.4.33;FPM worker 实际为 `www:www`,UID/GID均1001,umask为0022;照片目录应归该运行用户,不能按发布归档的root属主设置 |
| PHP路径限制 | `public/.user.ini` 设置 `open_basedir=/www/wwwroot/xctai.cn/:/tmp/`,文件有immutable属性;`.user.ini`启用、缓存300秒。CLI的空open_basedir不能代表Web进程。推荐目录在现有允许范围内,无需修改此文件或扩大允许范围 |
| 推荐目录 | `/www/wwwroot/xctai.cn/private-documents` 尚不存在;既有父目录及祖先无symlink,父目录为 `www:www` / 0755;它是public的同级目录,符合当前PrivateDocuments对Web目录不重叠的约束 |
| 容量 | 检查时剩余约166 GiB、约9389万可用inode;站点、候选父目录及本机备份位于同一文件系统。没有预留专用配额,不能把当前空闲量视为长期容量承诺 |
| 加密 | 现有共享 `security.data_key` 是有效32字节密钥,AES-256-GCM可用;使用现有Secret对内存固定字符串加密/解密通过。未读取照片、解密真实业务内容或下载密钥;指纹只保存于私密回执 |
| 环境文件权限 | `.env` 为 `root:www` / 0640,ACL无额外用户授权。本轮只在服务端内存解析必要配置,不保存或传回明文凭据 |
这轮没有创建目录或以FPM上传/下载实际文件;权限和open_basedir符合配置条件,真实Web读写仍应在具体配置落地时做最小技术检查,不能写成已验收。0700/0600保护其他系统身份,但不区分共享 `www` 身份的进程;目前沿用现有FPM和本人API鉴权,不增设专用进程体系。
## 配置前既有备份范围
宝塔实际数据在 `data/db/` 分库中;旧 `data/default.db` 空表不能用来判断“没有备份”。已读取真实计划任务及备份记录,并核对相应root crontab和脚本类型,没有输出脚本正文、数据库密码或其他凭据。
| 项目 | 实际状态 |
| --- | --- |
| 日常数据库备份 | 启用的MySQL任务,每天04:00,目标ALL,当前包含airz;保存本机,保留3份,不是已确认的照片备份7天规则 |
| 现存airz备份 | 2026-10-11、12、13共3份,路径为 `/www/backup/database/mysql/crontab_backup/airz/`;最新为2026-10-13 04:00:01,大小76713字节 |
| 可读性 | 三个gzip在内存完整解压成功,未超过读取上限;最新包含 `fa_agreement_revision`、`fa_user_consent`、`fa_private_document`,前两份早于该批部署、不包含新表。只核对表结构标记,不输出行内容,未执行恢复 |
| 照片文件 | 该数据库任务不打包本地照片目录;已核对的宝塔任务没有站点或照片目录备份,`/www/backup/site`为空,推荐照片备份目录尚不存在 |
| 发布回退归档 | 已知 `/root/airz-agreement-documents-release-1791794619319` 包含一次性的 `database.before.private.sql.gz` 和定向源码备份;不含 `.env`,不是每天照片备份或独立密钥托管 |
| 密钥恢复 | 已核对的发布归档及 `/www/backup` 没有匹配名称的独立key备份文件,也没有已核对的密钥备份任务。仅能结论为“尚无独立密钥恢复证据”,不能据此断言其他位置绝无副本 |
| 备份权限 | 既有 `/www/backup`、`database`及`site`目录为root / 0600、无扩展ACL;root维护通道可访问,普通身份无父目录遍历权限。最新SQL文件是0644,较早两份0600;不把它描述为已公开可读,也不批量改动旧目录 |
| 主机故障保护 | 已核对的日常任务为localhost,数据和备份同盘。云快照、异地副本、存储地域及供应商未获证据,不能从IP或现有模型供应商推断 |
“gzip可读且有表”只证明备份文件的基本完整性,不能代替实际恢复、数据库与照片的一致性验证或灾难恢复验收。现有3份整库备份的期限独立于照片备份期限;启用照片后,它也会保存资料元数据和同意证据,需要在正式说明中分别列明。
## 已执行的最小配置
本节已在现有服务器实施,照片enabled保持false。只使用本地私有文件和现有技术栈,不添加对象存储、OCR、评估附件绑定或在线清理worker;完整操作及验证见[落地记录](照片存储配置落地-20261013.md)。
| 对象 | 目标值与操作边界 |
| --- | --- |
| 在线目录 | 已创建 `/www/wwwroot/xctai.cn/private-documents`;owner `www:www`、mode `0700`,目前为空,实际FPM合成读写通过,临时文件已清理;没有新增公开链接或Nginx映射 |
| 在线文件 | 原代码继续使用随机32hex `.bin` 和Secret加密,文件最终0600;不保留上传原名或原图,不放入公开附件表 |
| 未开放配置 | 既有.env仅追加private_documents:enabled=false、directory为上述路径;其他原字节保持,不轮换原密钥。before副本只留服务器root-only归档,不下载.env |
| 照片备份目录 | 已创建 `/www/backup/airz-private-documents`,root:root / 0700;root任务采用umask077,档案及日志0600;独立cron每天04:20,不修改旧备份父目录或04:00数据库任务 |
| 备份最小内容 | 加密 `.bin`、同一恢复点的资料元数据及对应协议/同意证据、文件摘要与备份时点/密钥指纹;记录所依赖的账户及数据库恢复点。证据和清单也属于受控资料,不能公开共享 |
| 排除内容 | 不把`.env`、数据库/面板/Redis/模型凭据、明文照片、public/uploads、运行缓存和无关发布归档打进照片包;不把解密密钥和密文一起归档 |
| 独立密钥保管 | 已保存到 `D:/AAdesk/202609/airz-backups/key-escrow/production-data-key.dpapi.json`,DPAPI CurrentUser、ACL仅当前Windows用户/SYSTEM;独立进程指纹及合成GCM恢复校验通过,无明文密钥文件。DPAPI依赖原Windows用户配置,换机/重装需受控迁移,不能宣称灾难恢复全面完成 |
密钥是现有业务共用密钥,不能因为旧照片备份到期就删掉或随意重新生成,否则会影响其他加密数据。本轮以临时RSA公钥封装传回本机,在内存转换为DPAPI副本并核对恢复能力;没有持久RSA私钥,不把密钥放进代码、日志或本文。
5MiB处理后图片经过当前Base64密文封装,最大约6.67MiB,容量估算需按密文及备份副本计算。用户已确认7天滚动备份;每次成功备份后按归档创建时间清理超过7天且完整校验通过的本工具旧包,不是仅保留7份。未知文件保持,损坏包拒绝自动清理;停机或失败可能延迟清理,需查看受控日志及时处理。
实际备份每天04:20(Asia/Shanghai),与既有04:00数据库备份错开。工具用独立PDO、显式REPEATABLE READ只读事务取得资料元数据、对应同意/撤销证据、document-use版本及依赖账户标识,再复制清单中的不可变活动密文。缺文件、链接、并发变更或校验异常则本次失败;不暂停业务或写库,不将另一时点SQL直接配成一致恢复集。删除墓碑保留但其文件不打包,孤立密文不备份也不删除。首份手动执行的是空资料快照,尚未等待下一次计划触发或验收真实照片恢复。
## 删除与恢复保护
沿用[照片规则草案](协议草案/照片保存与删除规则.md),在线长期保存及备份7天已确认,不安排按上传年龄删除照片。最小安排由授权运维核对本人删除、用途结束、撤销和注销申请及删除失败清单,逐项按现有业务顺序执行:先不可读标记,物理文件确认不存在后才记完成;失败保留同ID处理。元数据及其他证据期限仍待确认,没有照片数据时不执行空泛批量清理。
资料表的 `deleted_at` 代表不可读标记时间,不保证物理删除完成,不能直接用它计算“完成删除后30天”。若采用该建议期限,要在受控运维结果记录中保存实际确认时间;再按已确认规则清理最小元数据。同意/撤销证据和整库备份分别定期限,不随着照片元数据一起删除。
备份恢复前保留备份之后的删除、撤销及用途结束/注销处理结果,最小记录只需资料ID、随机storage_key、处理类型/时间及完成状态,按其必要期限受控保存。恢复在Web外的隔离目录进行,先校验摘要/密钥、资料归属及数据库恢复点,再排除已经删除或依相关申请应处理删除的照片;仍在正常使用的长期保存照片不因上传时间较早而被排除。若无法取得最新保护记录,先隔离并保持不可读,不恢复为用户可查看状态。不能用旧备份直接覆盖运行中的整库、同意记录或删除状态,也不能因为有密文文件就补造用户资料行。
孤立密文要与完整资料表、在途上传及安全时间边界一起核对,未知提交结果的文件不得因暂时无行而直接删除。退场前先落实本人删除/申请处理与必要密钥保管,当前关闭开关会同时禁本人读取和删除,不能把关开关当成清理完成。
同盘照片备份只能用于有限误操作恢复。若需要覆盖服务器/磁盘损坏,应另外确定受控的异地副本目的地、访问权限及删除期限;可以使用用户现有设备,不要求增加测试服务器,本轮没有创建或承诺任何异地传输。
## 接续与证据
配置准备已完成,后续待主体、有效申请渠道、其余期限及删除处理责任具备后定稿协议,再按明确范围开放照片;不增加在线照片固定到期任务。主体和联系方式用户已说暂无,保留待填,不重复追问或发布占位正文。用户暂缓功能和正式验收,小程序仍自行发行。
本机私密检查归档:[20261013-01](D:/AAdesk/202609/airz-backups/private-storage-preflight/20261013-01)。只读结果分别保存为 `storage-inventory.json`、`backup-discovery.json`、`backup-php-detail.json`、`preferred-storage.json`、`final-guards.json`;`panel-cleanup.json`记录面板已退出、专用浏览器已关闭、临时凭据和命令脚本已删除。初轮FPM master补充组解析跨行的问题在preferred-storage探针中修正,实际worker UID/GID结果一致,不使用初轮master字段推导权限。
后续落地私密归档:[20261013-01](D:/AAdesk/202609/airz-backups/private-storage-setup/20261013-01)。最终以final-verification-retry.json为准,isolation-hardening.json记录最终工具摘要,panel-cleanup.json证实退出面板和清理临时脚本。Windows合成文件测试6通过/1跳过,服务器7项通过,独立密钥恢复及实际FPM读写检查通过;这些不计正式验收。
本轮新增Web外备份工具与测试,同步隐私/资料协议中的已确认备份说明;用户协议、数据授权和14前端运行摘要保持。格式核对及草案摘要重新生成,原回执不覆盖。进度2.32/1.42、开发文档v2.00/1.13,29页面、63任务和融资28项保持;照片/短信仍关闭,未提交、推送或创建PR。
+43
View File
@@ -0,0 +1,43 @@
# 私有资料上传内部实现与验证
日期:2026-10-12;未上线,真实上传默认关闭。按已批准的简化范围完成上传照片、本人预览、本人删除,没有恢复复杂提案。融资28项、29页面、63任务保持;产品机构库、已回撤产品AI、短信默认关闭均保持。两仓既有未提交短信/协议改动保留,仅在协议读取控制器追加本人资料引用,无提交、推送、PR、生产连接、迁移、发布或真实短信/模型请求。
## 实现与边界
后端 `PrivateDocuments` 和 `v1/Documents` 提供三个接口:multipart `POST /api/v1/documents`(file、category、consentId)、Bearer `GET /api/v1/documents/:id/content`、JSON `{}` `POST /api/v1/documents/:id/delete`。返回附件ID字符串,不返回文件路径或公开地址。类别沿用身份证正反面、银行卡、收入证明、营业执照、银行流水、经营证明。
照片仅接收真实JPG/PNG,原图和处理后文件均不超过5MiB,长宽不超过4096、总像素不超过400万。沿用头像的图片解码及PNG重编码方法,去除用户文件名、元数据和尾随内容,再用已有Secret/AES-GCM密钥加密保存。`fa_private_document` 记录归属、随机storage_key、类别、格式/大小、同意ID及创建/删除时间,不进入公开attachment表。单表SQL同步到schema,仅安装于本轮合成隔离库。
存储配置 `private_documents.enabled=false`、`directory=''`。需操作者明确提供已存在且位于Web目录外的本地目录;目录不能与Web根重叠,也不能是Web根的祖先。未配置时拒绝,运行代码不创建正式目录。私有文件0600,正式目录ACL、备份、密钥托管及恢复方式仍需运营确认;本轮不拟定生产保留期限。
上传在用户锁内复核Token及本人未撤销的documents同意记录,与同意撤销使用相同锁顺序;撤销后禁止新上传,本人仍可读/删已有资料。明确的保存失败清理本次文件;提交回执未知保留加密文件并报DOCUMENT_SAVE_UNKNOWN,用户手动重试可能产生另一份照片,不自动重发。删除先提交不可读标记,再移除文件;只有确认文件不存在才报成功,失败保留同ID供重试。无云层、命令账本、租约、复杂状态机或清理worker。
小程序复用原资料卡片。复用现有协议服务取得服务端同意记录;未确认不得选图上传。保存失败显示“未上传·重试”,删除失败显示“删除未完成·重试”;上传成功后直接在原上传卡片显示所选照片缩略图,查看/删除从卡片菜单操作;同类多张先选择照片再管理,不在下方新增独立列表。重新进入资料步骤时按类别携带Bearer下载恢复缩略图,预览重新携带Bearer下载,不在URL中携带Token、不存相册。页面及会话守卫拒绝退出、换账户和离页后的迟到回包,清空临时路径引用,原生端尽力删除下载临时文件。查看协议时保留当前自填表单与服务端ID;文件路径不传给确认流程。
资料仍选填,照片单独保存、未核验、暂不参与评估,不做OCR、自动核验或外部查询。确认页明示边界;已取得合法服务端ID的照片不再触发旧“上传未开放”拒绝,但未上传/删除未完成的选择须先处理。评估JSON仍发送空documents/consentIds,只提交自填信息;服务端继续拒绝任何非空评估附件或授权。外部查询选择继续拒绝,不把本机路径当附件ID。
三个业务接口保持。复用现有GET /consents?scope=documents返回独立documents字段:按当前登录user_id读取全部资料ID/类别/consentId/deletePending,不暴露路径,不受同意分页、版本或撤销状态限制。已物理删除的ID不再显示,删除未完成且文件仍存在的ID可恢复重试。未启用时返回空引用,不查询未安装的资料表。普通离页、提交成功、刷新后重新进入均从服务端恢复,不把私密图片路径持久保存。同类全部ID可通过原卡片逐份管理,未上传项优先处理,未知回执后重新读取不会遮住本地重试/移除入口。未知保存回执若已提交入表可恢复ID;未入表的孤立加密文件仍需正式启用前明确运营处理方式,本轮不增加清理worker。
## 验证证据
本轮新建专属MySQL/Redis/ROOT/存储环境:`D:/AAdesk/202609/airz-backups/private-documents/1791787367342`。默认生产.env未读取,独立ENV_PREFIX与全新合成空库,Token/队列使用独立Redis DB15和命名空间;模型客户端被类替身拦截,短信关闭。只读取旧隔离工具的实现参考,没有复跑已清理协议runner。
| 验证 | 最终结果 |
| --- | --- |
| 私有照片真实HTTP/SQL/文件专项 | 168项、44次本机HTTP通过 |
| 公开头像回归 | 268项、76次本机HTTP通过 |
| 原有协议后端回归 | 351项、86次本机HTTP通过 |
| UTS服务/页面/评估边界 | 26组场景通过 |
| 既有协议纯源码 / 微信分包 | 30项 / 31项通过 |
| Android / 微信 | 当前29页编译通过;日志android.log / mp.log |
| 受控H5 | 58项、39合成API、12POST,0运行错误、0未知外呼 |
上传专项覆盖PNG/JPG、重编码/加密、非法图片/GIF、原大小/长宽/像素超限、缺少文件、未登录/跨用户、撤销同意、篡改密文、SQL插入失败与文件清理、物理删除失败后不可读与同ID重试、公开目录拒绝、评估附件拒绝和撤销Token。前端覆盖回执校验、Bearer下载、原会话401不得清除新账户、失败重试、离页/退出/换账户迟到响应、删除回执确认、协议往返保留表单、照片不进入评估JSON。
页面副本:`C:/Users/JUN/AppData/Local/Temp/airz-private-documents-ui-1791787367342`,仅副本配置改为synthetic.invalid;新建5181预览,导航前拦截全部网络,业务响应仅在进程内合成,原5174服务保持。Browser插件未提供,使用已有Playwright及非持久Chromium,无新增依赖。390×844和1280×900截图核对无横向裁切;三条console 503来自故意模拟一次上传关闭和两次删除失败,0未解释的业务错误。H5共享layout已有类型警告保持,不等同真机下载/临时文件清理验收。
Android最终编译15:57:08、微信15:58:32(2026-10-12,Asia/Shanghai)。独立审查先后发现离页丢失ID和恢复后遮住同类待上传项,均先复现红测试后修复;新增卡片缩略图/多份管理/迟到清理场景通过;自动缩略图不得删除手动预览的临时文件,同类多张菜单分组以满足微信六项限制。开发途中缺少接口红测试、缺少文件的错误处理、Android局部前向声明及匿名函数默认参数问题已定位修正;测试首次清理触发框架无条件删除保护、独立H5环境标记及页面选择器问题也已修正。失败或重复运行不累计。`source-manifest.json`仅绑定本次相关运行文件和测试入口,不是整仓或发行包摘要。最终verification与cleanup回执由本轮归档记录:29张合成表零行、Redis零键、私有目录零文件后删除精确合成库并停止所属MySQL/Redis;最后58项H5通过后关闭非持久浏览器及5181的所属进程和子进程。重启预览冷编译阶段曾加载超时并触发取DOM错误,已补全finally关闭保护,待编译完成后最终复验通过;失败或重复运行不累计。归档含合成私密配置,不整包共享。
## 正式启用条件
正式资料用途、协议正文/版本、保存期限、撤销后处理、实际私有目录及ACL、容量/备份/密钥恢复、孤立文件运营处理方式,以及微信/Android真机选择/下载/临时文件清理仍待确认。FILE-001保持阻塞;FILE-002/004仅内部开发完成,正式验收未完成;FILE-003原内部协议实现状态保持。附件绑定评估另做最小集成,不将上传成功或本轮编译当作已核验、已进入分析或正式上线。
+134
View File
@@ -0,0 +1,134 @@
<template>
<view class="page" :style="pageStyle()"><AppHeader title="隐私与授权" />
<scroll-view scroll-y :style="contentStyle(96, false)"><view class="consents-content" :style="bottomStyle(40)">
<view class="intro-card"><text class="intro-title">我的同意记录</text><text class="intro-copy">记录所见协议版本与确认时间。可选授权可撤销;同意记录不代表已上传资料、查询征信或发起外部数据查询。</text><view class="policy-links"><text class="policy-link" @click="openAgreement('terms')">用户协议</text><text class="policy-link" @click="openAgreement('privacy')">隐私政策</text><text class="policy-link" @click="openAgreement('document-use')">资料使用</text><text class="policy-link" @click="openAgreement('data-authorization')">数据授权</text></view></view>
<view class="filter-row"><view v-for="item in filters" :key="item.scope" :class="scope == item.scope ? 'filter active' : 'filter'" @click="chooseScope(item.scope)"><text :class="scope == item.scope ? 'filter-copy active-copy' : 'filter-copy'">{{ item.title }}</text></view></view>
<view v-if="pending != null" class="pending-card"><text class="pending-copy">{{ consentTitle(pending!.agreementCode) }}({{ pending!.agreementVersion }})撤销结果待确认。再次确认会沿用原请求。</text><button class="secondary-button retry-button" :disabled="revoking" @click="retryRevoke">{{ revoking ? '正在确认…' : '再次确认撤销' }}</button></view>
<text v-if="actionError.length > 0" class="error-copy">{{ actionError }}</text>
<view class="list-heading"><text class="count-copy">{{ total }} 条记录</text><text class="refresh-link" @click="reload">刷新</text></view>
<view v-if="loading && records.length == 0" class="empty-content"><text class="empty-copy">正在加载同意记录…</text></view>
<view v-else-if="error.length > 0 && records.length == 0" class="empty-content"><text class="empty-title">记录加载失败</text><text class="empty-copy">{{ error }}</text><button class="primary-button empty-button" @click="reload">重新加载</button></view>
<view v-else-if="records.length == 0" class="empty-content"><text class="empty-title">暂无同意记录</text><text class="empty-copy">正式协议发布并确认后,记录会显示在这里。</text></view>
<view v-for="item in records" :key="item.consentId" class="record-card">
<view class="record-heading"><text class="record-title">{{ consentTitle(item.agreementCode) }}</text><text :class="item.revokedAt.length > 0 ? 'record-status revoked' : 'record-status'">{{ item.revokedAt.length > 0 ? '已撤销' : (optionalConsentScope(item.scope) ? '已同意' : '已确认') }}</text></view>
<text class="record-meta">协议版本:{{ item.agreementVersion }}</text><text class="record-meta">同意时间:{{ consentTime(item.grantedAt) }}</text><text v-if="item.revokedAt.length > 0" class="record-meta">撤销时间:{{ consentTime(item.revokedAt) }}</text>
<text v-if="!optionalConsentScope(item.scope)" class="record-note">账户使用协议确认记录;可查看当时版本。</text>
<view class="record-actions"><text class="action-link" @click="openAgreement(item.agreementCode, item.agreementVersion)">查看当时版本</text><text v-if="item.canRevoke && optionalConsentScope(item.scope) && item.revokedAt.length == 0" class="revoke-link" @click="confirmRevoke(item)">撤销可选授权</text></view>
</view>
<text v-if="error.length > 0 && records.length > 0" class="error-copy">{{ error }}</text>
<button v-if="records.length > 0 && hasMore" class="secondary-button more-button" :disabled="loading" @click="loadMore">{{ loading ? '正在加载…' : '加载更多' }}</button>
<text v-if="records.length > 0 && !hasMore" class="end-copy">已显示全部记录</text>
</view></scroll-view>
</view>
</template>
<script setup lang="uts">
import AppHeader from '@/components/AppHeader.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { Consent, fetchConsents, agreementRoute, consentTitle, consentTime, optionalConsentScope, revokeConsent, consentRequestId, sameConsentSession } from '@/services/agreements.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
type ScopeFilter = { scope: string, title: string }
const filters: ScopeFilter[] = [{ scope: '', title: '全部' }, { scope: 'account-terms', title: '用户协议' }, { scope: 'account-privacy', title: '隐私政策' }, { scope: 'documents', title: '资料' }, { scope: 'data', title: '数据' }]
const scope = ref(''); const records = ref<Consent[]>([]); const total = ref(0); const hasMore = ref(false); const loading = ref(false); const error = ref('')
const pending = ref<Consent | null>(null); const revoking = ref(false); const actionError = ref('')
let pendingId = ''; let page = 1; let visible = false; let generation = 0; let actionGeneration = 0; let ownerToken = ''; let ownerId = ''; let accountTimer = 0
const clearView = () => { records.value = []; total.value = 0; hasMore.value = false; loading.value = false; error.value = ''; generation++; actionGeneration++; revoking.value = false }
const sameOwner = (): boolean => {
if (!visible) return false
const current = getSession()
if (sameConsentSession(ownerToken, ownerId, current?.getString('token') ?? '', current?.getString('userId') ?? '')) return true
clearView(); pending.value = null; pendingId = ''; actionError.value = ''; clearInterval(accountTimer)
uni.redirectTo({ url: current == null ? '/packages/account/login/login?resumeConsents=1' : '/packages/account/profile/profile' })
return false
}
async function loadPage(nextPage: number, reset: boolean) {
if (!sameOwner() || loading.value) return
const request = ++generation; const selectedScope = scope.value; loading.value = true; error.value = ''
if (reset) { records.value = []; total.value = 0; hasMore.value = false }
try {
const result = await fetchConsents(selectedScope, nextPage)
if (request != generation || !sameOwner()) return
const merged = reset ? result.items : records.value.concat(result.items)
const ids: string[] = []; const unique: Consent[] = []
merged.forEach((item: Consent) => { if (!ids.includes(item.consentId)) { ids.push(item.consentId); unique.push(item) } })
records.value = unique; total.value = result.total; hasMore.value = result.hasMore; page = nextPage
} catch (err) { if (request == generation && sameOwner()) error.value = err instanceof Error ? err.message : '记录加载失败,请重试' }
finally { if (request == generation) loading.value = false }
}
const reload = () => { if (loading.value) return; loadPage(1, true) }
const loadMore = () => { if (hasMore.value && !loading.value) loadPage(page + 1, false) }
const chooseScope = (value: string) => { if (!sameOwner() || value == scope.value) return; generation++; loading.value = false; scope.value = value; reload() }
function openAgreement(code: string, version: string = '') { const url = agreementRoute(code, version); if (sameOwner() && url.length > 0) uni.navigateTo({ url }) }
async function retryRevoke() {
if (!sameOwner() || revoking.value || pending.value == null) return
const item = pending.value!; const request = ++actionGeneration
revoking.value = true; actionError.value = ''
try {
const result = await revokeConsent(item, pendingId)
if (request != actionGeneration || !sameOwner()) return
// 撤销前读取的列表快照不得覆盖回执;筛选/刷新/分页仍在途时重读当前范围第一页。
const staleList = loading.value
generation++; loading.value = false
records.value = records.value.map((row: Consent): Consent => row.consentId == result.consentId ? result : row)
pending.value = null; pendingId = ''; uni.showToast({ title: '可选授权已撤销', icon: 'none' })
if (staleList) loadPage(1, true)
} catch (err) { if (request == actionGeneration && sameOwner()) actionError.value = (err instanceof Error ? err.message : '撤销未确认') + ';请再次确认撤销。' }
finally { if (request == actionGeneration) revoking.value = false }
}
const confirmRevoke = (item: Consent) => {
if (!sameOwner() || revoking.value || !item.canRevoke || !optionalConsentScope(item.scope) || item.revokedAt.length > 0) return
if (pending.value != null) { actionError.value = '请先确认上一项撤销结果。'; return }
const request = actionGeneration; const view = generation
uni.showModal({ title: '撤销可选授权', content: '撤销此条可选授权同意记录。历史确认记录仍可查看,当前未开放外部查询。', confirmText: '确认撤销', success: (result) => {
if (!result.confirm || request != actionGeneration || view != generation || !sameOwner() || pending.value != null) return
pending.value = item; pendingId = consentRequestId(); retryRevoke()
} })
}
onShow(() => {
visible = true; const session = getSession(); const token = session?.getString('token') ?? ''; const userId = session?.getString('userId') ?? ''
if (token != ownerToken || userId != ownerId) { pending.value = null; pendingId = ''; actionError.value = '' }
ownerToken = token; ownerId = userId; clearView(); clearInterval(accountTimer)
if (!sameOwner()) return
accountTimer = setInterval(() => { sameOwner() }, 1000); reload()
})
// 隐藏时只失效请求;离场过渡仍使用已挂载节点,返回后再按当前账户清空并重读。
const hidePage = () => { visible = false; clearInterval(accountTimer); generation++; actionGeneration++; loading.value = false; revoking.value = false }
onHide(() => hidePage())
onUnload(() => { hidePage(); pending.value = null; pendingId = '' })
</script>
<style>
.consents-content { padding: 30rpx; }
.intro-card { padding: 26rpx; background-color: #edf5ff; border-radius: 18rpx; }
.intro-title { color: #23344e; font-size: 32rpx; font-weight: 700; }
.intro-copy { color: #66758a; font-size: 24rpx; line-height: 40rpx; margin-top: 14rpx; }
.policy-links { flex-direction: row; flex-wrap: wrap; margin-top: 10rpx; }
.policy-link { color: #0868f7; font-size: 24rpx; padding: 14rpx 18rpx 8rpx 0; }
.filter-row { flex-direction: row; flex-wrap: wrap; margin-top: 24rpx; }
.filter { padding: 16rpx 18rpx; margin-right: 10rpx; margin-bottom: 10rpx; border-radius: 12rpx; background-color: #ffffff; }
.active { background-color: #e3efff; }
.filter-copy { font-size: 24rpx; color: #66758a; }
.active-copy { color: #0868f7; font-weight: 600; }
.list-heading { flex-direction: row; justify-content: space-between; padding: 16rpx 0; }
.count-copy { color: #748198; font-size: 24rpx; }
.refresh-link { color: #0868f7; font-size: 24rpx; }
.record-card { padding: 26rpx; background-color: #ffffff; border-radius: 18rpx; margin-top: 18rpx; }
.record-heading { flex-direction: row; align-items: center; justify-content: space-between; }
.record-title { flex: 1; min-width: 0; color: #29394f; font-size: 29rpx; font-weight: 600; }
.record-status { color: #0868f7; font-size: 23rpx; margin-left: 12rpx; }
.revoked { color: #8490a3; }
.record-meta { color: #66758a; font-size: 23rpx; line-height: 36rpx; margin-top: 12rpx; }
.record-note { color: #8793a6; font-size: 23rpx; line-height: 36rpx; margin-top: 12rpx; }
.record-actions { flex-direction: row; justify-content: space-between; margin-top: 20rpx; }
.action-link { color: #0868f7; font-size: 24rpx; padding: 10rpx 0; }
.revoke-link { color: #bd594d; font-size: 24rpx; padding: 10rpx 0; }
.pending-card { padding: 24rpx; background-color: #fff8ec; border-radius: 16rpx; margin-top: 18rpx; }
.pending-copy { color: #7b643e; font-size: 24rpx; line-height: 38rpx; }
.retry-button { margin-top: 18rpx; font-size: 26rpx; }
.error-copy { color: #bd594d; font-size: 24rpx; line-height: 38rpx; margin-top: 18rpx; }
.empty-content { padding: 72rpx 10rpx; align-items: center; }
.empty-title { color: #172640; font-size: 30rpx; font-weight: 600; }
.empty-copy { color: #66758a; font-size: 25rpx; line-height: 40rpx; margin-top: 20rpx; }
.empty-button { width: 100%; margin-top: 28rpx; }
.more-button { margin-top: 26rpx; }
.end-copy { color: #8793a6; font-size: 23rpx; text-align: center; padding: 30rpx 0; }
</style>
+105 -18
View File
@@ -7,15 +7,19 @@
<text class="welcome">欢迎登录 AI融资</text>
<text class="login-subtitle">智能评估,让融资更简单</text>
<text class="form-heading">{{ mode == 'phone' ? '手机号登录 / 注册' : '密码登录' }}</text>
<view class="input-box"><text class="input-prefix">+86</text><input v-model="phone" :disabled="loading" type="number" maxlength="11" placeholder="请输入手机号" class="field-input" /></view>
<view v-if="mode == 'password'" class="input-box"><input v-model="password" :disabled="loading" password placeholder="请输入登录密码" class="field-input" /></view>
<view v-if="mode == 'phone' && smsSent" class="input-box"><input v-model="smsCode" type="number" maxlength="6" placeholder="请输入 6 位验证码" class="field-input" /><text class="countdown" @click="resendSms">{{ countdown > 0 ? countdown.toString() + 's 后重试' : '重新获取' }}</text></view>
<view class="input-box"><text class="input-prefix">+86</text><input v-model="phone" :disabled="loading || authenticated" type="number" maxlength="11" placeholder="请输入手机号" class="field-input" /></view>
<view v-if="mode == 'password'" class="input-box"><input v-model="password" :disabled="loading || authenticated" password placeholder="请输入登录密码" class="field-input" /></view>
<view v-if="mode == 'phone' && smsSent" class="input-box"><input v-model="smsCode" :disabled="loading || authenticated" type="number" maxlength="6" placeholder="请输入 6 位验证码" class="field-input" /><text class="countdown" @click="resendSms">{{ countdown > 0 ? countdown.toString() + 's 后重试' : '重新获取' }}</text></view>
<text v-if="errorMessage.length > 0" class="error-message">{{ errorMessage }}</text>
<button class="login-button" :disabled="loading" @click="login">{{ loading ? '处理中…' : (mode == 'phone' ? (smsSent ? '登录' : '获取验证码') : '登录') }}</button>
<button class="login-button" :disabled="loading" @click="login">{{ loading ? '处理中…' : (authenticated ? '确认同意记录并继续' : (mode == 'phone' ? (smsSent ? '登录' : '获取验证码') : '登录')) }}</button>
<view class="mode-row"><text class="mode-link" @click="switchMode">{{ mode == 'phone' ? '密码登录' : '使用验证码登录' }}</text></view>
<view class="other-login"><view class="line"></view><text class="other-text">其他登录方式</text><view class="line"></view></view>
<view class="social-row"><view class="social-button" @click="socialLogin('微信')"><image class="social-icon" src="/packages/account/static/wechat.svg" mode="aspectFit" /><text class="social-label">微信</text></view><view class="social-button" @click="socialLogin('一键登录')"><image class="social-icon" src="/packages/account/static/quick-login.svg" mode="aspectFit" /><text class="social-label">一键登录</text></view></view>
<view class="agreement"><view class="check-hit" @click="agreed = !agreed"><view :class="agreed ? 'check checked' : 'check'"><text v-if="agreed" class="check-mark">✓</text></view></view><text class="agreement-copy">我已阅读并同意<text class="agreement-link" @click="showAgreement('用户协议')">《用户协议》</text>和<text class="agreement-link" @click="showAgreement('隐私政策')">《隐私政策》</text></text></view>
<view class="agreement"><view class="check-hit" @click="toggleAgreement"><view :class="agreed ? 'check checked' : 'check'"><text v-if="agreed" class="check-mark">✓</text></view></view><text class="agreement-copy">我已阅读并同意<text class="agreement-link" @click="showAgreement('terms')">《用户协议》</text>和<text class="agreement-link" @click="showAgreement('privacy')">《隐私政策》</text></text></view>
<text v-if="agreementState == 'ready'" class="agreement-note">确认版本:用户协议 {{ terms.version }} / 隐私政策 {{ privacy.version }}</text>
<text v-else class="agreement-note">{{ agreementNote }}</text>
<text v-if="agreementState == 'error' || agreementState == 'unavailable'" class="agreement-retry" @click="loadAgreements">重新加载协议</text>
<text v-if="authenticated" class="agreement-note">登录已成功。确认同意记录失败时,点击上方按钮以原请求重试,无需再次验证密码或短信。</text>
</view>
</scroll-view>
</view>
@@ -25,7 +29,8 @@
import { usePageLayout } from '@/services/layout.uts'
import BrandLogo from '@/components/BrandLogo.uvue'
import { API_BASE_URL, requestSmsCode, loginWithSms, loginWithPassword } from '@/packages/account/services/auth.uts'
import { showAgreementNotice } from '@/services/assessment.uts'
import { getSession } from '@/services/api.uts'
import { Agreement, emptyAgreement, fetchAgreement, agreementRoute, agreementUnavailable, agreementChanged, grantConsent, consentRequestId, sameConsentSession } from '@/services/agreements.uts'
import { consumeInitialLaunch } from '@/services/launch.uts'
import { assistantReturnPath } from '@/services/assistant-return.uts'
import { nextStepRoute } from '@/services/next-step-route.uts'
@@ -56,6 +61,11 @@
if (target.length > 0) afterLoginUrl = target
} else if (options?.['resumePoints'] == '1') {
afterLoginUrl = '/packages/account/points/points'
} else if (options?.['resumeConsents'] == '1') {
afterLoginUrl = '/packages/account/consents/consents'
} else if (options?.['resumeAgreement'] == '1') {
const target = agreementRoute(options?.['agreementCode'] ?? '')
if (target.length > 0) afterLoginUrl = target
}
})
const { statusStyle, contentStyle, pageStyle } = usePageLayout()
@@ -68,14 +78,49 @@
const loading = ref(false)
const errorMessage = ref('')
const agreed = ref(false)
const authenticated = ref(false)
const terms = ref<Agreement>(emptyAgreement()); const privacy = ref<Agreement>(emptyAgreement())
const agreementState = ref('loading'); const agreementNote = ref('正在加载用户协议与隐私政策…')
const isDemo = API_BASE_URL.length == 0
const showAgreement = (title: string) => { showAgreementNotice(title) }
let pageVisible = false; let generation = 0; let agreementGeneration = 0; let accountTimer = 0; let authToken = ''; let authUserId = ''
type LoginConsentCommand = { agreement: Agreement, requestId: string }
let pendingConsents: LoginConsentCommand[] = []; let confirmedCount = 0; let selectedTerms: Agreement | null = null; let selectedPrivacy: Agreement | null = null
const showAgreement = (code: string) => { const item = code == 'terms' ? terms.value : privacy.value; const url = agreementRoute(code, item.version); if (pageVisible && url.length > 0) uni.navigateTo({ url }) }
const sameAuthentication = (): boolean => { const current = getSession(); return sameConsentSession(authToken, authUserId, current?.getString('token') ?? '', current?.getString('userId') ?? '') }
const clearAuthentication = () => { authToken = ''; authUserId = ''; authenticated.value = false; pendingConsents = []; confirmedCount = 0; agreed.value = false; selectedTerms = null; selectedPrivacy = null; generation++ }
const toggleAgreement = () => {
if (loading.value || agreementState.value != 'ready' || pendingConsents.length > 0) return
agreed.value = !agreed.value
selectedTerms = agreed.value ? terms.value : null; selectedPrivacy = agreed.value ? privacy.value : null
}
async function loadAgreements() {
if (!pageVisible || loading.value || pendingConsents.length > 0) return
const request = ++agreementGeneration; agreementState.value = 'loading'; agreementNote.value = '正在加载用户协议与隐私政策…'
let nextTerms: Agreement | null = null; let nextPrivacy: Agreement | null = null; let missing = false; let failure = ''
try { nextTerms = await fetchAgreement('terms') } catch (err) { if (agreementUnavailable(err)) missing = true; else failure = err instanceof Error ? err.message : '协议加载失败' }
try { nextPrivacy = await fetchAgreement('privacy') } catch (err) { if (agreementUnavailable(err)) missing = true; else failure = err instanceof Error ? err.message : '协议加载失败' }
if (!pageVisible || request != agreementGeneration) return
if (failure.length > 0 || missing || nextTerms == null || nextPrivacy == null) {
agreed.value = false; selectedTerms = null; selectedPrivacy = null; terms.value = emptyAgreement(); privacy.value = emptyAgreement()
agreementState.value = failure.length > 0 ? 'error' : 'unavailable'
agreementNote.value = failure.length > 0 ? failure + ',请重新加载协议。' : '用户协议或隐私政策尚未正式发布,暂不记录同意;现有账户可使用密码登录。'
return
}
if (selectedTerms?.version != nextTerms!.version || selectedTerms?.contentHash != nextTerms!.contentHash || selectedPrivacy?.version != nextPrivacy!.version || selectedPrivacy?.contentHash != nextPrivacy!.contentHash) { agreed.value = false; selectedTerms = null; selectedPrivacy = null }
terms.value = nextTerms!; privacy.value = nextPrivacy!; agreementState.value = 'ready'; agreementNote.value = ''
}
const agreementReadyForLogin = (): boolean => {
if (agreementState.value == 'unavailable' && mode.value == 'password') return true
if (agreementState.value != 'ready') { errorMessage.value = agreementState.value == 'unavailable' ? '协议未正式发布,请使用现有账户密码登录' : '请先重新加载用户协议与隐私政策'; return false }
if (!agreed.value || selectedTerms == null || selectedPrivacy == null) { errorMessage.value = '请先阅读并勾选用户协议与隐私政策'; return false }
return true
}
let countdownTimer = 0
watch(phone, () => {
smsSent.value = false; smsCode.value = ''; countdown.value = 0; errorMessage.value = ''
clearInterval(countdownTimer)
})
const switchMode = () => { if (loading.value) return; mode.value = mode.value == 'phone' ? 'password' : 'phone'; errorMessage.value = '' }
const switchMode = () => { if (loading.value || authenticated.value) return; mode.value = mode.value == 'phone' ? 'password' : 'phone'; errorMessage.value = '' }
const socialLogin = (provider: string) => { uni.showModal({ title: provider + '暂未开放', content: '请先使用手机号登录。该登录方式将在正式服务接入后开放。', showCancel: false, confirmText: '我知道了' }) }
const validPhone = (): boolean => {
if (!/^1[3-9][0-9]{9}$/.test(phone.value)) { errorMessage.value = '请输入正确的 11 位手机号'; return false }
@@ -88,39 +133,79 @@
if (countdown.value <= 1) { countdown.value = 0; clearInterval(countdownTimer) } else countdown.value -= 1
}, 1000)
}
const finishLogin = (_token: string) => { uni.redirectTo({ url: afterLoginUrl }) }
async function confirmLoginConsents(request: number) {
if (!pageVisible || request != generation || !sameAuthentication()) throw new Error('登录状态已变化,请重新登录')
if (pendingConsents.length == 0 && agreementState.value == 'ready') {
if (!agreed.value || selectedTerms == null || selectedPrivacy == null) throw new Error('请重新阅读并确认当前协议')
pendingConsents = [{ agreement: selectedTerms!, requestId: consentRequestId() }, { agreement: selectedPrivacy!, requestId: consentRequestId() }]; confirmedCount = 0
}
while (confirmedCount < pendingConsents.length) {
const command = pendingConsents[confirmedCount]
await grantConsent(command.agreement, command.requestId)
if (!pageVisible || request != generation || !sameAuthentication()) return
confirmedCount++
}
if (pageVisible && request == generation && sameAuthentication()) { pendingConsents = []; uni.redirectTo({ url: afterLoginUrl }) }
}
const resendSms = async () => {
if (loading.value || countdown.value > 0 || !validPhone()) return
if (!agreed.value) { errorMessage.value = '请先阅读并勾选用户协议与隐私政策'; return }
if (!pageVisible || authenticated.value || loading.value || countdown.value > 0 || !validPhone()) return
if (!agreementReadyForLogin()) return
const request = generation
loading.value = true; errorMessage.value = ''
try {
if (!(await requestSmsCode(phone.value))) throw new Error('验证码发送失败,请稍后重试')
if (!pageVisible || request != generation) return
startCountdown()
uni.showToast({ title: isDemo ? '请输入 6 位验证码' : '验证码已重新发送', icon: 'none' })
} catch (error) { errorMessage.value = error instanceof Error ? error.message : '验证码发送失败,请稍后重试' } finally { loading.value = false }
} catch (error) { if (pageVisible && request == generation) errorMessage.value = error instanceof Error ? error.message : '验证码发送失败,请稍后重试' } finally { if (request == generation) loading.value = false }
}
const login = async () => {
if (!pageVisible || loading.value) return
errorMessage.value = ''
if (!agreed.value) { errorMessage.value = '请先阅读并勾选用户协议与隐私政策'; return }
if (!validPhone() || loading.value) return
if (authenticated.value && !sameAuthentication()) { clearAuthentication(); errorMessage.value = '登录状态已变化,请重新登录'; return }
if (pendingConsents.length == 0 && !agreementReadyForLogin()) return
if (!authenticated.value && !validPhone()) return
const request = ++generation
loading.value = true
try {
if (authenticated.value) { await confirmLoginConsents(request); return }
let token = ''
if (mode.value == 'phone') {
if (!smsSent.value) {
if (!(await requestSmsCode(phone.value))) throw new Error('验证码发送失败,请稍后重试')
if (!pageVisible || request != generation) return
smsSent.value = true; startCountdown()
uni.showToast({ title: isDemo ? '请输入 6 位验证码' : '验证码已发送', icon: 'none' })
return
} else {
if (!/^[0-9]{6}$/.test(smsCode.value)) throw new Error('请输入 6 位数字验证码')
finishLogin(await loginWithSms(phone.value, smsCode.value))
token = await loginWithSms(phone.value, smsCode.value)
}
} else {
if (password.value.length < 6) throw new Error('密码至少需要 6 位')
finishLogin(await loginWithPassword(phone.value, password.value))
token = await loginWithPassword(phone.value, password.value)
}
} catch (error) { errorMessage.value = error instanceof Error ? error.message : '登录失败,请稍后重试' } finally { loading.value = false }
if (!pageVisible || request != generation) return
const session = getSession()
if (session?.getString('token') != token) throw new Error('登录状态已变化,请重新登录')
authToken = token; authUserId = session?.getString('userId') ?? ''; authenticated.value = true
await confirmLoginConsents(request)
} catch (err) {
if (!pageVisible || request != generation) return
if (agreementChanged(err)) { pendingConsents = []; confirmedCount = 0; agreed.value = false; selectedTerms = null; selectedPrivacy = null; loading.value = false; await loadAgreements(); errorMessage.value = '协议版本已更新,请重新阅读并勾选后继续。' }
else errorMessage.value = (err instanceof Error ? err.message : '登录失败,请稍后重试') + (authenticated.value ? ';同意记录未确认,请点击按钮以原请求重试。' : '')
} finally { if (request == generation) loading.value = false }
}
onUnmounted(() => { clearInterval(countdownTimer) })
onShow(async () => {
pageVisible = true
if (authenticated.value && !sameAuthentication()) clearAuthentication()
clearInterval(accountTimer); accountTimer = setInterval(() => { if (pageVisible && authenticated.value && !sameAuthentication()) { clearAuthentication(); loading.value = false; errorMessage.value = '登录状态已变化,请重新登录'; loadAgreements() } }, 1000)
if (pendingConsents.length == 0) await loadAgreements()
})
const hidePage = () => { pageVisible = false; generation++; agreementGeneration++; loading.value = false; clearInterval(accountTimer) }
onHide(() => hidePage())
onUnload(() => { hidePage(); clearInterval(countdownTimer); clearAuthentication() })
onUnmounted(() => { clearInterval(countdownTimer); clearInterval(accountTimer) })
</script>
<style>
@@ -150,4 +235,6 @@
.check-mark { font-size: 23rpx; color: #fff; }
.agreement-copy { flex: 1; min-width: 0; color: #748198; font-size: 24rpx; line-height: 42rpx; }
.agreement-link { color: #0868f7; font-size: 24rpx; line-height: 42rpx; }
.agreement-note { color: #748198; font-size: 23rpx; line-height: 38rpx; margin-top: 16rpx; }
.agreement-retry { color: #0868f7; font-size: 24rpx; padding: 18rpx 0; }
</style>
+2 -2
View File
@@ -24,7 +24,7 @@
import { getSession } from '@/services/api.uts'
import { logoutAccount } from '@/packages/account/services/auth.uts'
import { findLatestReportEntry } from '@/packages/account/services/latest-report.uts'
import { showAgreementNotice } from '@/services/assessment.uts'
import { showAgreementNotice } from '@/services/agreements.uts'
import { DEFAULT_AVATAR, getAccountProfile, fetchAccountProfile, maskPhone } from '@/packages/account/services/account.uts'
const { contentStyle, pageStyle } = usePageLayout()
type ProfileMenu = { icon: string, name: string, note: string, key: string }
@@ -104,7 +104,7 @@
if (key == 'archive') go('/packages/finance/archive/archive')
else if (key == 'help') go('/packages/content/help/help')
else if (key == 'security') go(loggedIn.value ? '/packages/account/security/security' : '/packages/account/login/login')
else if (key == 'privacy') { const view = generation; uni.showActionSheet({ itemList: ['查看隐私说明', '管理资料与授权'], success: (result) => { if (view != generation || !sameOwner()) return; if (result.tapIndex == 0) showAgreementNotice('隐私说明'); else if (result.tapIndex == 1) { go('/packages/finance/archive/archive') } } }) }
else if (key == 'privacy') { const view = generation; uni.showActionSheet({ itemList: ['查看隐私政策', '管理同意记录与可选授权'], success: (result) => { if (view != generation || !sameOwner()) return; if (result.tapIndex == 0) showAgreementNotice('隐私说明'); else if (result.tapIndex == 1) { go(loggedIn.value ? '/packages/account/consents/consents' : '/packages/account/login/login?resumeConsents=1') } } }) }
else if (key == 'about') uni.showModal({ title: '关于 AI融资', content: '版本 V1.0.0\n帮助您整理融资信息、了解融资知识与融资方案。评估结果仅供参考,不构成授信承诺。', showCancel: false, confirmText: '我知道了' })
}
const openAccount = () => { if (sameOwner()) go(loggedIn.value ? '/packages/account/edit-profile/edit-profile' : '/packages/account/login/login') }
+174 -34
View File
@@ -50,9 +50,11 @@
</view>
<view v-else class="upload-area">
<text class="upload-title">资料选择 <text class="optional">(选填)</text></text>
<text class="upload-desc">支持照片,仅在本机预览,尚未上传。</text>
<view class="upload-grid"><view class="upload-card" v-for="(item, index) in uploadNames" :key="item" :class="index == 2 ? 'upload-last' : ''" @click="documentAction(item)"><image v-if="documentPath(item).length > 0" class="document-thumb" :src="documentPath(item)" mode="aspectFill" @error="documentExpired(item)" /><AppIcon v-else name="upload" :size="52" /><text class="upload-name">{{ item }}</text><text :class="documentPath(item).length > 0 ? 'upload-state selected-state' : 'upload-state'">{{ documentPath(item).length > 0 ? '已选择 · 管理' : '点击选择' }}</text></view></view>
<view class="consent"><view class="check-hit" @click="documentConsent = !documentConsent"><view :class="documentConsent ? 'checkbox checked' : 'checkbox'"><text v-if="documentConsent" class="check-mark">✓</text></view></view><text class="consent-copy">我已阅读并同意<text class="consent-link" @click="showAgreement('资料使用协议')">《资料使用协议》</text></text></view>
<text class="upload-desc">JPG / PNG,单张最多5MB。仅本人可查看和删除;照片未核验,暂不参与评估。</text>
<view class="upload-grid"><view class="upload-card" v-for="(item, index) in uploadNames" :key="item" :class="index == uploadNames.length - 1 ? 'upload-last' : ''" @click="documentAction(item)"><image v-if="documentPath(item).length > 0" class="document-thumb" :src="documentPath(item)" mode="aspectFill" @error="documentExpired(item, '')" /><AppIcon v-else name="upload" :size="52" /><text class="upload-name">{{ item }}</text><text class="upload-state">{{ documentState(item) }}</text></view></view>
<text v-if="documentMessage.length > 0" class="upload-desc">{{ documentMessage }}</text>
<text v-if="documentRecoveryFailed" class="consent-link" @click="restoreDocuments">照片读取失败 · 点击重试</text>
<view class="consent"><view class="check-hit" @click="toggleDocumentConsent"><view :class="documentConsent ? 'checkbox checked' : 'checkbox'"><text v-if="documentConsent" class="check-mark">✓</text></view></view><text class="consent-copy">我已阅读并同意<text class="consent-link" @click="showAgreement('资料使用协议')">《资料使用协议》</text></text></view>
<view class="data-section"><text class="upload-title">数据授权 <text class="optional">(可选)</text></text><text class="upload-desc">自主选择授权项目,可随时取消选择。</text><view class="grant-list"><view v-for="grant in authorizations" :key="grant.key" :class="grant.selected ? 'grant-card grant-selected' : 'grant-card'" @click="grant.selected = !grant.selected"><text :class="grant.selected ? 'grant-name grant-name-active' : 'grant-name'">{{ grant.name }}</text><view :class="grant.selected ? 'checkbox checked' : 'checkbox'"><text v-if="grant.selected" class="check-mark">✓</text></view></view></view><view class="consent"><view class="check-hit" @click="dataConsent = !dataConsent"><view :class="dataConsent ? 'checkbox checked' : 'checkbox'"><text v-if="dataConsent" class="check-mark">✓</text></view></view><text class="consent-copy">我已阅读并同意<text class="consent-link" @click="showAgreement('数据授权协议')">《数据授权协议》</text></text></view><text class="local-note">当前仅保存选择意向,不查询外部数据。</text></view>
</view>
<view class="scroll-spacer" :style="bottomStyle(24)"></view>
@@ -74,22 +76,29 @@
import AssessmentSelect from '@/packages/finance/components/AssessmentSelect.uvue'
import { AssessmentForm, SelectedDocument, DataAuthorization, emptyAssessmentForm, activeAssessmentType, getAssessmentInput, formFromInput, documentsFromInput, setAssessmentInput, showAgreementNotice, assetValidationMessage, contributionValidationMessage, clearAssessmentFlow } from '@/services/assessment.uts'
import { contributionOptions as contributionChoices, contributionBaseVisible as baseVisible } from '@/services/contribution.uts'
import { uploadDocument, downloadDocument, deleteDocument, releaseDocumentTemp, documentCategory, fetchOwnedDocuments } from '@/services/documents.uts'
import { Agreement, emptyAgreement, fetchAgreement, grantConsent, consentRequestId } from '@/services/agreements.uts'
const { bottomStyle, contentStyle, pageStyle } = usePageLayout()
const contributionOptions = (current: string): string[] => contributionChoices(current)
const contributionBaseVisible = (duration: string): boolean => baseVisible(duration)
const showAgreement = (title: string) => { showAgreementNotice(title) }
const editId = ref(''); const editError = ref(''); const editLoading = ref(false)
let baseRevisionId = ''; let editGeneration = 0
const step = ref(0)
const assessmentType = ref('personal')
const form = ref<AssessmentForm>(emptyAssessmentForm())
let ownerToken = ''; let goingToConfirm = false; let visible = false; let accountTimer = 0
let ownerToken = ''; let goingToConfirm = false; let goingToAgreement = false; let visible = false; let accountTimer = 0
const sameOwner = (): boolean => { const session = getSession(); return session != null && session['token'] == ownerToken }
const customCity = ref('')
const cityChanged = (city: string) => { form.value.city = city; if (city != '其他城市') customCity.value = '' }
const documentConsent = ref(false)
const dataConsent = ref(false)
const documents = ref<SelectedDocument[]>([])
const documentBusy = ref(''); const documentMessage = ref('')
const documentRecoveryFailed = ref(false); let thumbnailIds: string[] = []
let documentGeneration = 0; let documentConsentId = ''; let consentKey = ''; let pendingAgreement: Agreement = emptyAgreement()
let choosingImage = false; let previewingImage = false
const clearDocumentTemps = () => { documents.value.forEach((file) => { if (file.documentId.length > 0) releaseDocumentTemp(file.path); file.path = '' }) }
const invalidateDocuments = () => { documentGeneration++; documentBusy.value = ''; thumbnailIds = []; documentRecoveryFailed.value = false; clearDocumentTemps() }
const authorizations = ref<DataAuthorization[]>([{ key: 'credit', name: '征信查询', selected: false }, { key: 'business', name: '经营数据', selected: false }, { key: 'fund', name: '公积金数据', selected: false }])
const titles = ['基本信息', '经营 / 收入信息', '资产 / 负债信息', '资料上传 / 授权']
const uploadNames = computed((): string[] => assessmentType.value == 'enterprise' ? ['营业执照', '银行流水', '经营证明'] : ['身份证正面', '身份证反面', '银行卡', '收入证明'])
@@ -100,7 +109,7 @@
if (sameOwner()) return true
const session = getSession()
ownerToken = session?.getString('token') ?? ''
editGeneration++; initialized = false; stopAccountTimer(); clearAssessmentFlow()
editGeneration++; invalidateDocuments(); documentConsentId = ''; consentKey = ''; pendingAgreement = emptyAgreement(); documentMessage.value = ''; initialized = false; stopAccountTimer(); clearAssessmentFlow()
form.value = emptyAssessmentForm(); documents.value = []; customCity.value = ''; step.value = 0
documentConsent.value = false; dataConsent.value = false; authorizations.value.forEach((item) => { item.selected = false })
baseRevisionId = ''; editLoading.value = false; editError.value = '账户已变化,请返回重新填写。'
@@ -121,6 +130,7 @@
if (currentForm.city == '其他城市' && customCity.value.trim().length > 0) currentForm.city = customCity.value.trim()
setAssessmentInput(assessmentType.value, step.value, currentForm, documents.value, authorizations.value, documentConsent.value, dataConsent.value, editId.value, baseRevisionId)
}
const showAgreement = (title: string) => { if (!ensureOwner()) return; rememberInput(); goingToAgreement = true; showAgreementNotice(title) }
const restore = (type: string) => {
assessmentType.value = type; step.value = 0; form.value = emptyAssessmentForm(); customCity.value = ''; documents.value = []; documentConsent.value = false; dataConsent.value = false
authorizations.value.forEach((item) => { item.selected = false })
@@ -136,7 +146,39 @@
initialized = true
rememberInput()
}
const switchType = (type: string) => { if (!ensureOwner() || editId.value.length > 0 || assessmentType.value == type) return; rememberInput(); restore(type); step.value = 0; rememberInput() }
const hydrateDocumentCards = () => {
if (!visible || !sameOwner() || step.value != 3) return
const ticket = documentGeneration
uploadNames.value.forEach((name) => {
const item = documents.value.find((file) => file.name == name)
if (item == null || item.documentId.length == 0 || item.deletePending || item.path.length > 0 || thumbnailIds.includes(item.documentId)) return
const id = item.documentId; thumbnailIds.push(id)
downloadDocument(id).then((path) => {
const current = documents.value.find((file) => file.documentId == id && !file.deletePending)
if (ticket != documentGeneration || !visible || !sameOwner() || current == null) { releaseDocumentTemp(path); return }
if (current.path.length > 0) { releaseDocumentTemp(path); return }
current.path = path
}).catch(() => {
if (ticket == documentGeneration && visible && sameOwner()) documentMessage.value = '缩略图读取失败,点击对应卡片可重试查看'
}).finally(() => { if (ticket == documentGeneration) thumbnailIds = thumbnailIds.filter((value) => value != id) })
})
}
const restoreDocuments = async () => {
if (!ensureOwner() || !initialized || documentBusy.value.length > 0) return
const ticket = documentGeneration; documentBusy.value = 'restore'
try {
const saved = await fetchOwnedDocuments()
if (ticket != documentGeneration || !ensureOwner()) return
const pending = documents.value.filter((file) => file.documentId.length == 0)
documents.value.forEach((file) => { if (file.documentId.length > 0) releaseDocumentTemp(file.path) })
// 卡片先处理待上传项;同类已保存照片通过卡片逐份管理。
documents.value = pending.concat(saved.map((file): SelectedDocument => ({ name: file.name, path: '', documentId: file.documentId, consentId: file.consentId, deletePending: file.deletePending })))
documentRecoveryFailed.value = false; hydrateDocumentCards()
rememberInput()
} catch (error) { if (ticket == documentGeneration && ensureOwner()) { documentRecoveryFailed.value = true; documentMessage.value = error instanceof Error ? error.message : '已保存照片读取失败,请重新读取' } }
finally { if (ticket == documentGeneration) documentBusy.value = '' }
}
const switchType = (type: string) => { if (!ensureOwner() || editId.value.length > 0 || assessmentType.value == type) return; rememberInput(); invalidateDocuments(); restore(type); step.value = 0; rememberInput(); restoreDocuments() }
const loadEdit = async (type: string) => {
const ticket = ++editGeneration; editLoading.value = true
try {
@@ -144,6 +186,7 @@
if (ticket != editGeneration || !ensureOwner()) return
baseRevisionId = saved.getString('baseRevisionId') ?? ''
writeAssessmentFlow(ownerToken, saved); restore(saved.getString('type') ?? type)
restoreDocuments()
} catch (err) { if (ticket == editGeneration && ensureOwner()) editError.value = err instanceof Error ? err.message : '资料读取失败,请返回重试。' }
finally { if (ticket == editGeneration) editLoading.value = false }
}
@@ -161,16 +204,20 @@
})
onShow(() => {
visible = true; stopAccountTimer()
goingToConfirm = false
const returningFromImage = choosingImage || previewingImage
goingToConfirm = false; goingToAgreement = false; previewingImage = false
if (initialized && !sameOwner()) ensureOwner()
const input = getAssessmentInput(assessmentType.value)
if (initialized && input != null && typeof input['step'] == 'number') step.value = Math.max(0, Math.min(3, input['step'] as number))
startAccountTimer()
if (!returningFromImage) restoreDocuments()
})
// 离开填写流程即丢弃未提交内容;进入确认页时只在内存传递。
onHide(() => {
visible = false; stopAccountTimer()
if (!goingToConfirm) {
if (choosingImage || previewingImage) return
invalidateDocuments()
if (!goingToConfirm && !goingToAgreement) {
editGeneration++; if (editId.value.length > 0) editError.value = '已离开修改流程,请返回档案重新进入。'
// 离页清空资料但不切换步骤,避免卸载时又挂载另一组原生输入组件。
clearAssessmentFlow(); form.value = emptyAssessmentForm(); documents.value = []
@@ -178,37 +225,130 @@
authorizations.value.forEach((item) => { item.selected = false })
}
})
onUnload(() => { visible = false; stopAccountTimer(); editGeneration++; initialized = false; clearAssessmentFlow() })
onUnload(() => { visible = false; stopAccountTimer(); editGeneration++; invalidateDocuments(); initialized = false; clearAssessmentFlow() })
// 页内前后步骤只维护当前内存状态,返回入口后不保留填写内容。
onBackPress((): boolean => { rememberInput(); if (step.value > 0) { step.value--; rememberInput(); return true }; return false })
const previous = () => { rememberInput(); if (step.value > 0) step.value--; rememberInput() }
const back = () => { if (step.value > 0) { previous(); return }; rememberInput(); if (getCurrentPages().length > 1) uni.navigateBack(); else uni.redirectTo({ url: '/packages/finance/entry/entry?type=' + assessmentType.value }) }
const documentPath = (name: string): string => { const item = documents.value.find((file) => file.name == name); return item == null ? '' : item.path }
const documentExpired = (name: string) => {
documents.value = documents.value.filter((file) => file.name != name); rememberInput()
uni.showToast({ title: name + '已失效,请重新选择', icon: 'none' })
const cardDocument = (name: string): SelectedDocument | null => documents.value.find((file) => file.name == name && file.documentId.length == 0) ?? documents.value.find((file) => file.name == name) ?? null
const documentPath = (name: string): string => { const item = cardDocument(name); return item == null ? '' : item.path }
const documentState = (name: string): string => {
if (documentBusy.value == name) return '处理中…'
const item = cardDocument(name)
if (item == null) return '点击上传'
const count = documents.value.filter((file) => file.name == name && file.documentId.length > 0).length
if (item.documentId.length > 0 && count > 1) return count.toString() + ' 张照片 · 管理'
if (item.deletePending) return '删除未完成 · 重试'
return item.documentId.length > 0 ? '已上传 · 管理' : '未上传 · 重试'
}
const documentExpired = (name: string, id: string) => {
const item = id.length == 0 ? cardDocument(name) : documents.value.find((file) => file.name == name && file.documentId == id)
if (item != null) { if (item.documentId.length > 0) releaseDocumentTemp(item.path); item.path = '' }
rememberInput(); documentMessage.value = '预览已失效,已上传资料仍可重新查看或删除'
}
const toggleDocumentConsent = async () => {
if (!ensureOwner() || documentBusy.value.length > 0) return
if (documentConsent.value) { documentConsent.value = false; rememberInput(); return }
const ticket = documentGeneration; documentBusy.value = 'consent'; documentMessage.value = ''
try {
if (pendingAgreement.version.length == 0) {
const agreement = await fetchAgreement('document-use')
if (ticket != documentGeneration || !ensureOwner()) return
pendingAgreement = agreement; consentKey = consentRequestId()
}
const record = await grantConsent(pendingAgreement, consentKey)
if (ticket != documentGeneration || !ensureOwner()) return
if (record.revokedAt.length > 0) { pendingAgreement = emptyAgreement(); consentKey = ''; throw new Error('该同意已撤销,请重新阅读并确认') }
documentConsentId = record.consentId; documentConsent.value = true; pendingAgreement = emptyAgreement(); consentKey = ''; rememberInput()
} catch (error) { if (ticket == documentGeneration && ensureOwner()) documentMessage.value = error instanceof Error ? error.message : '同意记录未确认,请再次点击重试' }
finally { if (ticket == documentGeneration) documentBusy.value = '' }
}
const uploadSelectedDocument = async (name: string) => {
if (!ensureOwner() || documentBusy.value.length > 0) return
const item = documents.value.find((file) => file.name == name && file.documentId.length == 0)
if (item == null || item.path.length == 0 || item.documentId.length > 0) return
if (!documentConsent.value || documentConsentId.length == 0) { documentMessage.value = '请阅读并确认资料使用协议后上传'; return }
const ticket = documentGeneration; documentBusy.value = name; documentMessage.value = ''
try {
const id = await uploadDocument(item.path, documentCategory(name), documentConsentId)
if (ticket != documentGeneration || !ensureOwner()) return
item.documentId = id; item.consentId = documentConsentId; rememberInput()
documentMessage.value = '照片已单独保存,未核验、暂不参与评估'
} catch (error) { if (ticket == documentGeneration && ensureOwner()) documentMessage.value = error instanceof Error ? error.message : '保存结果未确认,请手动重试;可能另存一份照片' }
finally { if (ticket == documentGeneration) documentBusy.value = '' }
}
const chooseDocument = (name: string) => {
if (!ensureOwner()) return
const ticket = editGeneration
if (!ensureOwner() || documentBusy.value.length > 0) return
if (!documentConsent.value || documentConsentId.length == 0) { documentMessage.value = '请阅读并确认资料使用协议后上传'; return }
const ticket = documentGeneration; choosingImage = true
uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'], success: (result) => {
if (ticket != editGeneration || !ensureOwner() || result.tempFilePaths.length == 0) return
documents.value = documents.value.filter((file) => file.name != name)
const document: SelectedDocument = { name, path: result.tempFilePaths[0] }
documents.value.push(document); rememberInput()
}, fail: (error) => { if (ticket == editGeneration && ensureOwner() && !error.errMsg.includes('cancel')) uni.showToast({ title: '选择失败,请重试或检查相册权限', icon: 'none' }) } })
choosingImage = false
if (ticket != documentGeneration || !sameOwner() || result.tempFilePaths.length == 0) return
documents.value = documents.value.filter((file) => file.name != name || file.documentId.length > 0)
const document: SelectedDocument = { name, path: result.tempFilePaths[0], documentId: '', consentId: '', deletePending: false }
documents.value.unshift(document); rememberInput(); uploadSelectedDocument(name)
}, fail: (error) => { choosingImage = false; if (ticket == documentGeneration && sameOwner() && !error.errMsg.includes('cancel')) documentMessage.value = '选择失败,请重试或检查相册权限' } })
}
const previewDocument = async (name: string, id: string) => {
if (!ensureOwner() || documentBusy.value.length > 0) return
const item = documents.value.find((file) => file.name == name && (id.length == 0 || file.documentId == id))
if (item == null || item.deletePending || item.documentId.length == 0) return
const ticket = documentGeneration; documentBusy.value = name
try {
const path = await downloadDocument(item.documentId)
if (ticket != documentGeneration || !ensureOwner()) { releaseDocumentTemp(path); return }
releaseDocumentTemp(item.path); item.path = path; previewingImage = true
uni.previewImage({ urls: [path], fail: () => { previewingImage = false; documentExpired(name, item.documentId) } })
} catch (error) { if (ticket == documentGeneration && ensureOwner()) documentMessage.value = error instanceof Error ? error.message : '资料读取失败,请重试' }
finally { if (ticket == documentGeneration) documentBusy.value = '' }
}
const removeDocument = async (name: string, id: string) => {
if (!ensureOwner() || documentBusy.value.length > 0) return
const item = documents.value.find((file) => file.name == name && (id.length == 0 || file.documentId == id))
if (item == null) return
if (item.documentId.length == 0) { documents.value = documents.value.filter((file) => file.name != name || file.documentId.length > 0); rememberInput(); return }
const ticket = documentGeneration; documentBusy.value = name; item.deletePending = true; releaseDocumentTemp(item.path); item.path = ''; rememberInput()
try {
await deleteDocument(item.documentId)
if (ticket != documentGeneration || !ensureOwner()) return
documents.value = documents.value.filter((file) => file.documentId != item.documentId); rememberInput(); documentMessage.value = '照片已删除'
hydrateDocumentCards()
} catch (error) { if (ticket == documentGeneration && ensureOwner()) documentMessage.value = error instanceof Error ? error.message : '删除未完成,请对同一资料重试' }
finally { if (ticket == documentGeneration) documentBusy.value = '' }
}
const documentItemAction = (name: string, id: string) => {
if (!ensureOwner() || documentBusy.value.length > 0) return
const ticket = documentGeneration; const item = documents.value.find((file) => file.name == name && file.documentId == id)
if (item == null) { chooseDocument(name); return }
const list = item.deletePending ? ['重试删除'] : (item.documentId.length > 0 ? ['查看照片', '删除照片'] : ['重试上传', '重新选择', '移除未上传照片'])
uni.showActionSheet({ itemList: list, success: (result) => {
if (ticket != documentGeneration || !ensureOwner()) return
if (item.deletePending) removeDocument(name, item.documentId)
else if (item.documentId.length > 0) { if (result.tapIndex == 0) previewDocument(name, item.documentId); else removeDocument(name, item.documentId) }
else { if (result.tapIndex == 0) uploadSelectedDocument(name); else if (result.tapIndex == 1) chooseDocument(name); else removeDocument(name, item.documentId) }
} })
}
function documentPhotoAction(name: string, offset: number): void {
const saved = documents.value.filter((file) => file.name == name && file.documentId.length > 0)
const group = saved.slice(offset, offset + 5); const ticket = documentGeneration
const list = group.map((file, index): string => '照片 ' + (offset + index + 1).toString() + (file.deletePending ? '(删除未完成)' : ''))
const more = offset + group.length < saved.length
// 微信操作菜单最多六项,每次保留五张照片和一个翻页入口。
if (more || offset > 0) list.push(more ? '更多照片' : '返回第一组')
uni.showActionSheet({ itemList: list, success: (result) => {
if (ticket != documentGeneration || !ensureOwner() || result.tapIndex < 0 || result.tapIndex >= list.length) return
if (result.tapIndex == group.length) { documentPhotoAction(name, more ? offset + group.length : 0); return }
documentItemAction(name, group[result.tapIndex].documentId)
} })
}
const documentAction = (name: string) => {
if (!ensureOwner()) return
const ticket = editGeneration
const path = documentPath(name)
if (path.length == 0) { chooseDocument(name); return }
uni.showActionSheet({ itemList: ['预览图片', '重新选择', '移除资料'], success: (result) => {
if (ticket != editGeneration || !ensureOwner()) return
if (result.tapIndex == 0) uni.previewImage({ urls: [path], fail: () => { uni.showToast({ title: '本地图片已失效,请重新选择', icon: 'none' }) } })
else if (result.tapIndex == 1) chooseDocument(name)
else if (result.tapIndex == 2) { documents.value = documents.value.filter((file) => file.name != name); rememberInput() }
} })
if (!ensureOwner() || documentBusy.value.length > 0) return
const item = cardDocument(name)
if (item == null) { chooseDocument(name); return }
if (item.documentId.length == 0) { documentItemAction(name, ''); return }
const saved = documents.value.filter((file) => file.name == name && file.documentId.length > 0)
if (saved.length == 1) { documentItemAction(name, item.documentId); return }
documentPhotoAction(name, 0)
}
const validateStep = (): boolean => {
let message = ''
@@ -226,12 +366,12 @@
}
if (step.value == 1 && message.length == 0 && assessmentType.value == 'personal') message = contributionValidationMessage(form.value)
if (step.value == 2) message = assetValidationMessage(form.value)
if (step.value == 3 && documents.value.length > 0 && !documentConsent.value) message = '请阅读并同意资料使用协议,或移除资料'
if (step.value == 3 && (documentBusy.value.length > 0 || documents.value.some((item) => item.documentId.length == 0 || item.deletePending))) message = '请先完成上传、删除重试,或移除未上传照片'
if (step.value == 3 && authorizations.value.some((item) => item.selected) && !dataConsent.value) message = '请阅读并同意数据授权协议,或取消授权选择'
if (message.length == 0) return true
uni.showToast({ title: message, icon: 'none' }); return false
}
const next = () => { if (!ensureOwner() || editLoading.value || editError.value.length > 0) return; if (!validateStep()) return; if (step.value < 3) { step.value++; rememberInput() } else { rememberInput(); goingToConfirm = true; uni.navigateTo({ url: '/packages/finance/confirm/confirm', fail: () => { goingToConfirm = false } }) } }
const next = () => { if (!ensureOwner() || editLoading.value || editError.value.length > 0) return; if (!validateStep()) return; if (step.value < 3) { step.value++; rememberInput(); hydrateDocumentCards() } else { rememberInput(); goingToConfirm = true; uni.navigateTo({ url: '/packages/finance/confirm/confirm', fail: () => { goingToConfirm = false } }) } }
</script>
<style>
@@ -259,7 +399,7 @@
.upload-grid { flex-direction: row; margin-top: 22rpx; }
.upload-card { flex: 1; min-width: 0; height: 200rpx; margin-right: 14rpx; border-radius: 16rpx; background-color: #f1f6fd; align-items: center; justify-content: center; }
.upload-last { margin-right: 0; }
.document-thumb { width: 74rpx; height: 62rpx; border-radius: 8rpx; }
.document-thumb { width: 100%; height: 100rpx; border-radius: 10rpx; }
.upload-name { font-size: 25rpx; color: #304260; margin-top: 16rpx; }
.upload-state { color: #7a889e; font-size: 22rpx; margin-top: 9rpx; }
.selected-state { color: #0868f7; }
+21 -12
View File
@@ -16,7 +16,7 @@
<view class="overview-label overview-head"><text class="row-label">核心参数</text></view>
<scroll-view v-for="column in columns" :key="column.id" scroll-y class="overview-value overview-head">
<view class="overview-product">
<template v-if="usable(column)"><text class="overview-name">{{ column.data!.item.product.name }}</text><text class="overview-bank">{{ column.data!.item.product.bank }}</text><text class="overview-link" @click="detail(column.id)">产品详情 ›</text></template>
<template v-if="usable(column)"><image class="overview-logo" :src="column.data!.item.product.logo.length > 0 ? column.data!.item.product.logo : '/static/mobile/icons-generated/building.png'" mode="aspectFit" @error="column.data!.item.product.logo = ''" /><text class="overview-name">{{ column.data!.item.product.name }}</text><text v-if="column.data!.item.product.bank.length > 0" class="overview-bank">{{ column.data!.item.product.bank }}</text><text class="overview-link" @click="detail(column.id)">产品详情 ›</text></template>
<template v-else><text class="overview-name">产品 #{{ column.id }}</text><text class="overview-bank">{{ column.loading ? '读取中…' : problem(column) }}</text><text v-if="!column.loading" class="overview-link" @click="loadColumn(column.id)">重试此列</text></template>
<text class="overview-remove" @click="remove(column.id)">移除</text>
</view>
@@ -27,7 +27,7 @@
<view v-for="column in columns" :key="column.id" class="overview-value"><text :class="overviewClass(column, row.id)">{{ cell(column, row.id) }}</text></view>
</view>
</view>
<text class="overview-note">展示顺序不代表优劣,具体条件以机构核验为准。</text>
<text class="overview-note">{{ orderingNote }}</text>
<view class="details-toggle" @click="detailsOpen = !detailsOpen"><text class="summary-title">详细条件与匹配依据</text><text class="detail-toggle-label">{{ detailsOpen ? '收起' : '展开' }}</text></view>
<text v-if="detailsOpen" class="scroll-hint">左右滑动查看各产品 · 长内容可在单元格内上下滑动</text>
<view v-if="detailsOpen" class="table">
@@ -37,6 +37,7 @@
<view v-for="column in columns" :key="column.id" class="column">
<scroll-view scroll-y class="product-head"><view class="head-content">
<template v-if="usable(column)">
<image class="overview-logo" :src="column.data!.item.product.logo.length > 0 ? column.data!.item.product.logo : '/static/mobile/icons-generated/building.png'" mode="aspectFit" @error="column.data!.item.product.logo = ''" />
<text class="product-name">{{ column.data!.item.product.name }}</text>
<text class="hint">{{ column.data!.item.product.bank }} · v{{ column.data!.item.product.version }}</text>
<text :class="column.data!.item.state == 'eligible' ? 'state good' : (column.data!.item.state == 'ineligible' ? 'state blocked' : 'state pending')">{{ column.data!.item.stateLabel }}</text>
@@ -66,7 +67,7 @@
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { assistantRoute } from '@/packages/finance/services/assistant.uts'
import { MatchDetail, MatchReason, validRecordId, fetchMatchDetailRecord } from '@/packages/finance/services/matches.uts'
import { MatchDetail, MatchReason, validRecordId, fetchMatchDetailRecord, hasProductValue, matchPercentage } from '@/packages/finance/services/matches.uts'
import { parseCompareIds, saveCompareSelection, compareQuery, sameMatchSnapshot, compareReasonText } from '@/packages/finance/services/compare.uts'
type CompareColumn = { id: string, loading: boolean, error: string, data: MatchDetail | null }
type CompareRow = { id: string, label: string, height: number }
@@ -74,7 +75,7 @@
const revisionId = ref(''); const assessmentId = ref(''); const ids = ref<string[]>([]); const columns = ref<CompareColumn[]>([]); const routeError = ref('')
const detailsOpen = ref(false)
// 首屏并排展示短参数;长证据保留原宽表,展开后读取完整内容,不截断判断依据。
const overviewRows: CompareRow[] = [{ id: 'state', label: '匹配状态', height: 0 }, { id: 'amount', label: '额度范围', height: 0 }, { id: 'term', label: '期限范围', height: 0 }, { id: 'rate', label: '年化利率', height: 0 }]
const overviewDefinitions: CompareRow[] = [{ id: 'state', label: '匹配状态', height: 0 }, { id: 'score', label: '匹配度', height: 0 }, { id: 'amount', label: '额度范围', height: 0 }, { id: 'term', label: '期限范围', height: 0 }, { id: 'rate', label: '年化利率', height: 0 }]
let visible = false; let generation = 0
let owner = ''; let accountTimer = 0; let frozenDetail: MatchDetail | null = null
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
@@ -91,13 +92,14 @@
history.replaceState(history.state, '', location.pathname + location.search + '#/packages/finance/compare/compare' + compareQuery(assessmentId.value, ids.value, revisionId.value))
// #endif
}
const rows: CompareRow[] = [
const detailRows: CompareRow[] = [
{ id: 'availability', label: '当前状态', height: 160 },
{ id: 'score', label: '匹配度', height: 120 },
{ id: 'amount', label: '额度范围', height: 120 }, { id: 'term', label: '期限范围', height: 120 }, { id: 'rate', label: '年化利率', height: 120 },
{ id: 'objects', label: '适用对象', height: 120 }, { id: 'regions', label: '适用地区', height: 140 },
{ id: 'positive', label: '已满足条件', height: 360 }, { id: 'blocking', label: '不符条件', height: 300 }, { id: 'cautions', label: '待核实与提示', height: 300 },
{ id: 'positive', label: '推荐与参考理由', height: 360 }, { id: 'blocking', label: '不推荐原因', height: 300 }, { id: 'cautions', label: '注意与补充资料', height: 300 },
{ id: 'conditions', label: '准入条件', height: 300 }, { id: 'repayment', label: '还款方式', height: 120 },
{ id: 'materials', label: '准备材料', height: 260 }, { id: 'channel', label: '办理渠道', height: 120 }, { id: 'valid', label: '产品有效期', height: 160 }, { id: 'source', label: '资料来源', height: 260 }
{ id: 'materials', label: '准备材料', height: 260 }, { id: 'channel', label: '办理渠道', height: 120 }, { id: 'source', label: '资料来源', height: 260 }
]
const rowStyle = (row: CompareRow): string => 'height:' + row.height + 'rpx;'
const columnsStyle = (): string => 'width:' + (columns.value.length * 350) + 'rpx;'
@@ -120,21 +122,27 @@
const m = column.data!.item; const p = m.product
// 核心表也保留当前不可用提示,不能让历史符合条件被误读为现在仍可办理。
if (key == 'state') return m.stateLabel + (m.availability != 'available' ? ' · ' + m.availabilityLabel : '')
if (key == 'score') return matchPercentage(m.state, m.score) || '—'
if (key == 'availability') return m.availabilityLabel + ';不改变历史结论'
if (key == 'positive') return compareReasonText(m.positive, '暂无已确认满足的条件')
if (key == 'blocking') return compareReasonText(m.blocking, '未发现已知不符条件;仍以核验为准')
if (key == 'cautions') return compareReasonText(m.cautions, '暂无待核实提示;不代表已获审批')
if (key == 'conditions') return p.conditions.length > 0 ? p.conditions.join('\n\n') : '未提供'
if (key == 'amount') return p.amount
if (key == 'term') return p.term
if (key == 'rate') return p.rate
if (key == 'amount') return hasProductValue(p.amount) ? p.amount : '—'
if (key == 'term') return hasProductValue(p.term) ? p.term : '—'
if (key == 'rate') return hasProductValue(p.rate) ? p.rate : '—'
if (key == 'objects') return p.objects
if (key == 'regions') return p.regions
if (key == 'materials') return p.materials
if (key == 'valid') return p.valid
if (key == 'source') return p.source + '\n引用:' + p.sourceRef + '\n核对日期:' + p.checkedOn
if (key == 'repayment') return hasProductValue(p.repayment) ? p.repayment : '—'
if (key == 'channel') return hasProductValue(p.channel) ? p.channel : '—'
if (key == 'source') return [hasProductValue(p.source) ? p.source : '', hasProductValue(p.sourceRef) ? '引用:' + p.sourceRef : '', hasProductValue(p.checkedOn) ? '核对日期:' + p.checkedOn : ''].filter((value: string): boolean => value.length > 0).join('\n') || '—'
return '未提供'
}
const rowHasValue = (row: CompareRow): boolean => columns.value.some((column: CompareColumn): boolean => !usable(column) || hasProductValue(cell(column, row.id)))
const overviewRows = computed((): CompareRow[] => overviewDefinitions.filter((row: CompareRow): boolean => !['score', 'amount', 'term', 'rate'].includes(row.id) || rowHasValue(row)))
const rows = computed((): CompareRow[] => detailRows.filter((row: CompareRow): boolean => !['score', 'amount', 'term', 'rate', 'repayment', 'channel', 'source'].includes(row.id) || rowHasValue(row)))
const orderingNote = computed((): string => columns.value.some((column: CompareColumn): boolean => usable(column) && column.data!.orderingVersion == 'product-score-descending-v1') ? '匹配列表按匹配度排序,此处按所选顺序对比。匹配度不代表获批概率。' : '按所选顺序对比,具体条件以机构核验为准。')
const loadColumn = async (id: string) => {
if (!current(generation, owner)) return
const column = columns.value.find((c: CompareColumn): boolean => c.id == id)
@@ -204,6 +212,7 @@
.overview-link, .overview-remove { font-size: 22rpx; line-height: 34rpx; color: #0868f7; padding-top: 12rpx; }
.overview-remove { color: #788ba2; }
.overview-text { font-size: 23rpx; line-height: 36rpx; color: #455f7c; }
.overview-logo { width: 56rpx; height: 56rpx; margin-bottom: 10rpx; flex-shrink: 0; }
.overview-amount { font-weight: 700; color: #154779; }
.overview-note { color: #8494a7; font-size: 21rpx; line-height: 34rpx; margin-top: 14rpx; }
.details-toggle { flex-direction: row; align-items: center; justify-content: space-between; padding: 28rpx 0; }
+3 -3
View File
@@ -7,7 +7,7 @@
<view class="confirm-tip"><view class="tip-icon"><text class="tip-mark">✓</text></view><view class="tip-copy"><text class="tip-title">{{ assessmentType == 'enterprise' ? '企业融资信息' : '个人融资信息' }}</text><text class="tip-desc">请确认后再开始评估,可随时返回修改。</text></view></view>
<text v-if="!hasInput" class="empty-hint">还没有评估信息,请先完成信息填写。</text>
<view class="info-section" v-for="section in sections" :key="section.title"><view class="section-bar"><text class="section-name">{{ section.title }}</text><text class="edit" @click="edit(section.step)">编辑</text></view><template v-for="(row, index) in section.rows" :key="row.label"><view v-if="index < 3 || section.expanded" class="info-row"><text class="info-label">{{ row.label }}</text><text class="info-value">{{ row.value }}</text></view></template><text v-if="section.rows.length > 3" class="expand-link" @click="section.expanded = !section.expanded">{{ section.expanded ? '收起详情' : '查看其余信息' }}</text></view>
<text class="assessment-note">提交后保存评估资料并进行自填资料分析。评估结果不代表授信承诺。资料上传及外部查询暂未开放;如已选资料或授权,请先移除。不会向金融机构提交贷款申请。</text>
<text class="assessment-note">本次评估只分析自填信息。照片单独保存,尚未核验,暂不参与评估;外部查询尚未开放,请取消授权选择。评估结果不代表授信承诺,不会向金融机构提交贷款申请。</text>
<view class="scroll-spacer" :style="bottomStyle(24)"></view>
</view>
</scroll-view>
@@ -87,7 +87,7 @@
{ label: '是否有房', value: ownershipFromInput(saved, 'house').length > 0 ? ownershipFromInput(saved, 'house') : '未填写' },
{ label: '是否有车', value: ownershipFromInput(saved, 'car').length > 0 ? ownershipFromInput(saved, 'car') : '未填写' }
], expanded: false },
{ title: '资料与授权', step: 3, rows: [{ label: '本地资料', value: fileNames.length > 0 ? fileNames : '未选择' }, { label: '授权意向', value: grantNames.length > 0 ? grantNames.join('、') : '未选择' }, { label: '当前状态', value: '资料未上传 · 数据未查询' }], expanded: false }
{ title: '资料与授权', step: 3, rows: [{ label: '已上传照片', value: fileNames.length > 0 ? fileNames : '未上传' }, { label: '授权意向', value: grantNames.length > 0 ? grantNames.join('、') : '未选择' }, { label: '当前状态', value: '照片单独保存,未核验、未参与评估;数据未查询' }], expanded: false }
]
payload.value = { ...saved, source: 'mobile', confirmed: true }
}
@@ -121,7 +121,7 @@
if (assessmentType.value == 'enterprise' && (draftText(saved, 'enterpriseName').length < 2 || !/^[0-9A-Z]{18}$/.test(draftText(saved, 'businessCode').toUpperCase()))) { uni.showToast({ title: '请补全企业信息', icon: 'none' }); edit(0); return }
if (!wanAmountValid(amount) || parseFloat(amount) <= 0) { uni.showToast({ title: '请先完善收入信息', icon: 'none' }); edit(1); return }
const grants = saved['authorizations'] as string[] | null
if ((documentsFromInput(saved).length > 0 && saved['documentConsent'] != true) || (grants != null && grants.length > 0 && saved['dataConsent'] != true)) { uni.showToast({ title: '请确认资料与授权选择', icon: 'none' }); edit(3); return }
if (grants != null && grants.length > 0 && saved['dataConsent'] != true) { uni.showToast({ title: '请确认授权选择', icon: 'none' }); edit(3); return }
const contributionError = assessmentType.value == 'personal' ? contributionValidationMessage(formFromInput(payload.value)) : ''
if (contributionError.length > 0) { uni.showToast({ title: contributionError, icon: 'none' }); edit(1); return }
const assetError = assetValidationMessage(formFromInput(saved))
+9 -6
View File
@@ -12,16 +12,16 @@
<text v-if="loading && matches.length == 0" class="state-copy">正在读取本次匹配结果…</text>
<view v-if="error.length > 0" class="state-panel"><text class="state-title">暂时无法读取</text><text class="muted">{{ error }}</text><button class="secondary" @click="retry">重新加载</button><text class="link" @click="history">返回融资档案</text></view>
<view v-if="pageError.length > 0" class="state-panel"><text class="muted">{{ pageError }}</text><button class="secondary" :disabled="loading" @click="loadMore">重试加载更多</button></view>
<view v-if="!loading && error.length == 0 && matches.length == 0" class="state-panel"><text class="state-title">{{ status == 'unavailable' ? '本次未执行匹配' : (group == 'eligible' ? '暂无符合已知条件的产品' : (group == 'ineligible' ? '暂无条件不符的产品' : '暂无待核实的产品')) }}</text><text class="muted">{{ status == 'unavailable' ? '历史结果不会自动补算。可从融资档案发起新的评估。' : '可切换分组查看其他结果,或返回报告了解资料准备情况。' }}</text><text class="link" @click="report">查看本次报告</text></view>
<view v-if="!loading && error.length == 0 && matches.length == 0" class="state-panel"><text class="state-title">{{ status == 'unavailable' ? '本次未执行匹配' : (group == 'eligible' ? '暂无符合已知条件的产品' : (group == 'ineligible' ? '暂无条件不符的产品' : '暂无资料不足或待确认的产品')) }}</text><text class="muted">{{ status == 'unavailable' ? '历史结果不会自动补算。可从融资档案发起新的评估。' : '可切换分组查看其他结果,或返回报告了解资料准备情况。' }}</text><text class="link" @click="report">查看本次报告</text></view>
<view v-for="item in matches" :key="item.id" class="card" @click="openProduct(item.id)">
<view class="card-head"><view :class="bankMarkClass(item.product.bank)"><text class="mark-text">{{ item.product.bank.slice(0, 1) }}</text></view><view class="identity"><text class="bank-name">{{ item.product.bank }}</text><text class="product-name">{{ item.product.name }}</text></view><text :class="item.state == 'eligible' ? 'match-state good' : (item.state == 'ineligible' ? 'match-state blocked' : 'match-state pending')">{{ item.stateLabel }}</text><text class="card-arrow">›</text></view>
<view class="tags"><text class="tag">年化 {{ item.product.rate }}</text><text class="tag">{{ item.product.term }}</text><text class="tag">{{ item.product.objects }}</text></view>
<view class="card-head"><view :class="bankMarkClass(item.product.bank)"><image v-if="item.product.logo.length > 0" class="mark-logo" :src="item.product.logo" mode="aspectFit" @error="item.product.logo = ''" /><text v-else class="mark-text">{{ item.product.bank.length > 0 ? item.product.bank.slice(0, 1) : item.product.name.slice(0, 1) }}</text></view><view class="identity"><text v-if="item.product.bank.length > 0" class="bank-name">{{ item.product.bank }}</text><text class="product-name">{{ item.product.name }}</text></view><view class="card-status"><text :class="item.state == 'eligible' ? 'match-state good' : (item.state == 'ineligible' ? 'match-state blocked' : 'match-state pending')">{{ item.stateLabel }}</text><text v-if="matchPercentage(item.state, item.score).length > 0" class="match-score">匹配度 {{ matchPercentage(item.state, item.score) }}</text></view><text class="card-arrow">›</text></view>
<view class="tags"><text v-if="hasProductValue(item.product.rate)" class="tag">年化 {{ item.product.rate }}</text><text v-if="hasProductValue(item.product.term)" class="tag">{{ item.product.term }}</text><text class="tag">{{ item.product.objects }}</text></view>
<text v-if="item.availability != 'available'" class="availability">{{ item.availabilityLabel }};历史结果仍可查看。</text>
<view class="card-foot"><text class="detail-link">查看匹配依据</text><view class="choose-control" @click.stop="toggleCompare(item.id)"><text :class="selectedIds.includes(item.id) ? 'selection-box selected-box' : 'selection-box'">{{ selectedIds.includes(item.id) ? '✓' : '+' }}</text><text class="choose-label">{{ selectedIds.includes(item.id) ? '已选对比' : '加入对比' }}</text></view></view>
</view>
<button v-if="hasMore && pageError.length == 0" class="more-button" :disabled="loading" @click="loadMore">{{ loading ? '正在加载…' : '查看更多产品(共' + count(group) + '项)' }}</button>
<view v-if="error.length == 0 && matches.length > 0" class="compare-bar"><text class="compare-caption">已选 {{ selectedIds.length }} / 3 项</text><button class="compare-button" :disabled="selectedIds.length < 2" @click="openCompare">对比所选产品</button></view>
<text v-if="matches.length > 0 && !hasMore" class="end-note">已显示本组全部结果 · 顺序不代表产品优劣</text>
<text v-if="matches.length > 0 && !hasMore" class="end-note">已显示本组全部结果</text>
<text v-if="message.length > 0" class="muted notice">{{ message }}</text>
<text class="link" @click="report">返回本次报告</text>
</template>
@@ -33,11 +33,11 @@
import AppHeader from '@/components/AppHeader.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { MatchView, MatchPage, fetchMatchPage, validRecordId } from '@/packages/finance/services/matches.uts'
import { MatchView, MatchPage, fetchMatchPage, validRecordId, hasProductValue, matchPercentage } from '@/packages/finance/services/matches.uts'
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery, matchReturnPath } from '@/packages/finance/services/compare.uts'
const { pageStyle, contentStyle } = usePageLayout()
type MatchGroup = { id: string, label: string }
const groups: MatchGroup[] = [{ id: 'eligible', label: '符合条件' }, { id: 'ineligible', label: '条件不符' }, { id: 'unknown', label: '待核实' }]
const groups: MatchGroup[] = [{ id: 'eligible', label: '符合条件' }, { id: 'ineligible', label: '条件不符' }, { id: 'unknown', label: '待确认' }]
const revisionId = ref(''); const assessmentId = ref(''); const group = ref('eligible'); const matches = ref<MatchView[]>([])
const selectedIds = ref<string[]>([])
const pageActive = ref(false)
@@ -153,6 +153,7 @@
.card { padding: 24rpx 22rpx 18rpx; background-color: #fff; border: 1rpx solid #e2e8ef; border-radius: 10rpx; margin-bottom: 20rpx; }
.card-head { flex-direction: row; align-items: center; }
.mark { width: 66rpx; height: 66rpx; border-radius: 33rpx; align-items: center; justify-content: center; margin-right: 16rpx; flex-shrink: 0; }
.mark-logo { width: 52rpx; height: 52rpx; }
.mark-blue { background-color: #edf4fd; }
.mark-red { background-color: #fcedef; }
.mark-green { background-color: #eaf6f1; }
@@ -163,6 +164,8 @@
.bank-name { font-size: 28rpx; line-height: 40rpx; color: #24354b; font-weight: 600; }
.product-name { font-size: 22rpx; color: #7a899d; line-height: 34rpx; margin-top: 3rpx; }
.match-state { font-size: 20rpx; line-height: 30rpx; max-width: 140rpx; flex-shrink: 0; text-align: right; margin-left: 10rpx; }
.card-status { align-items: flex-end; margin-left: 8rpx; }
.match-score { font-size: 21rpx; line-height: 32rpx; color: #0868f7; margin-top: 4rpx; }
.card-arrow { font-size: 38rpx; color: #a3aebe; margin-left: 12rpx; }
.tags { flex-direction: row; flex-wrap: wrap; margin-top: 18rpx; }
.tag { background-color: #f2f5f9; color: #718197; font-size: 20rpx; line-height: 30rpx; padding: 4rpx 10rpx; border-radius: 4rpx; margin-right: 8rpx; margin-bottom: 6rpx; }
+25 -15
View File
@@ -7,13 +7,14 @@
<view v-if="error.length > 0" class="error-panel"><text class="heading">暂时无法读取</text><text class="body">{{ error }}</text><button class="secondary" @click="load">重新加载</button><text class="link" @click="back">返回选择产品</text></view>
<template v-if="product != null">
<view class="bank-header">
<view class="bank-mark"><image class="bank-icon" src="/static/mobile/icons-generated/building.png" mode="aspectFit" /></view>
<view class="bank-mark"><image class="bank-icon" :src="product!.logo.length > 0 ? product!.logo : '/static/mobile/icons-generated/building.png'" @error="product!.logo = ''" mode="aspectFit" /></view>
<view class="bank-info">
<view class="bank-title-row"><text class="bank-name">{{ product!.bank }}</text><text v-if="match != null" :class="match!.state == 'eligible' ? 'badge good' : (match!.state == 'ineligible' ? 'badge blocked' : 'badge pending')">{{ match!.state == 'eligible' ? '可供参考' : (match!.state == 'ineligible' ? '暂不推荐' : '待核实') }}</text></view>
<view class="bank-title-row"><text v-if="product!.bank.length > 0" class="bank-name">{{ product!.bank }}</text><text v-if="match != null" :class="match!.state == 'eligible' ? 'badge good' : (match!.state == 'ineligible' ? 'badge blocked' : 'badge pending')">{{ match!.state == 'eligible' ? (match!.score != null ? '匹配度 ' + match!.score!.toString() + '%' : '可供参考') : (match!.state == 'ineligible' ? '暂不推荐' : match!.stateLabel) }}</text></view>
<text v-if="match != null" :class="match!.state == 'eligible' ? 'state good-text' : 'state attention-text'">{{ match!.stateLabel }}</text>
<text class="product-name">{{ product!.name }}</text>
</view>
</view>
<view v-if="hasProductValue(product!.amount) || hasProductValue(product!.rate)" class="product-summary"><text v-if="hasProductValue(product!.amount)" class="summary-tag">额度范围 {{ product!.amount }}</text><text v-if="hasProductValue(product!.rate)" class="summary-tag">年化利率 {{ product!.rate }}</text></view>
<view class="tabs">
<view v-if="assessmentId.length > 0" :class="tab == 'explanation' ? 'tab active' : 'tab'" @click="switchTab('explanation')"><text :class="tab == 'explanation' ? 'tab-label selected' : 'tab-label'">AI解释</text></view>
<view :class="tab == 'details' ? 'tab active' : 'tab'" @click="switchTab('details')"><text :class="tab == 'details' ? 'tab-label selected' : 'tab-label'">产品详情</text></view>
@@ -21,7 +22,7 @@
</view>
<template v-if="tab == 'explanation' && match != null">
<view class="reason-section">
<text class="heading good-text">{{ match!.state == 'eligible' ? '推荐依据(' + match!.positive.length + '条已满足条件)' : '已满足的条件(' + match!.positive.length + '条)' }}</text>
<text class="heading good-text">{{ match!.state == 'eligible' ? '推荐理由(' + match!.positive.length + '条)' : '参考理由(' + match!.positive.length + '条)' }}</text>
<text v-if="match!.positive.length == 0" class="empty-note">暂无已确认满足的条件,不补充未经验证的推荐理由。</text>
<view v-for="(reason, index) in positiveVisible" :key="reason.id" class="reason-row">
<text class="number green-number">{{ index + 1 }}</text>
@@ -53,28 +54,27 @@
</template>
<template v-if="tab == 'details'">
<view class="details-section"><text class="heading">产品信息</text>
<view class="row"><text class="label">额度范围</text><text class="value emphasis">{{ product!.amount }}</text></view>
<view class="row"><text class="label">年化利率</text><text class="value">{{ product!.rate }}</text></view>
<view class="row"><text class="label">贷款期限</text><text class="value">{{ product!.term }}</text></view>
<view v-if="hasProductValue(product!.amount)" class="row"><text class="label">额度范围</text><text class="value emphasis">{{ product!.amount }}</text></view>
<view v-if="hasProductValue(product!.rate)" class="row"><text class="label">年化利率</text><text class="value">{{ product!.rate }}</text></view>
<view v-if="hasProductValue(product!.term)" class="row"><text class="label">贷款期限</text><text class="value">{{ product!.term }}</text></view>
<view class="row"><text class="label">适用主体</text><text class="value">{{ product!.objects }}</text></view>
<view class="row"><text class="label">适用地区</text><text class="value">{{ product!.regions }}</text></view>
<view class="row"><text class="label">有效期</text><text class="value">{{ product!.valid }}</text></view>
<view class="row"><text class="label">还款方式</text><text class="value">未提供</text></view>
<view class="row"><text class="label">办理渠道</text><text class="value">未提供</text></view>
<view class="row"><text class="label">审批时长</text><text class="value">未提供</text></view>
<view v-if="hasProductValue(product!.repayment)" class="row"><text class="label">还款方式</text><text class="value">{{ product!.repayment }}</text></view>
<view v-if="hasProductValue(product!.channel)" class="row"><text class="label">办理渠道</text><text class="value">{{ product!.channel }}</text></view>
<view v-if="hasProductValue(product!.approval)" class="row"><text class="label">审批时长</text><text class="value">{{ product!.approval }}</text></view>
</view>
<view class="details-section"><text class="heading">资料来源</text><text class="body">{{ product!.source }}</text><text class="evidence">引用:{{ product!.sourceRef }}</text><text class="evidence">核对日期:{{ product!.checkedOn }}</text></view>
<view v-if="hasProductValue(product!.source) || hasProductValue(product!.sourceRef) || hasProductValue(product!.checkedOn)" class="details-section"><text class="heading">资料来源</text><text v-if="hasProductValue(product!.source)" class="body">{{ product!.source }}</text><text v-if="hasProductValue(product!.sourceRef)" class="evidence">引用:{{ product!.sourceRef }}</text><text v-if="hasProductValue(product!.checkedOn)" class="evidence">核对日期:{{ product!.checkedOn }}</text></view>
</template>
<template v-if="tab == 'conditions'">
<view class="details-section"><text class="heading">产品准入条件</text><text class="muted">公开条件不代表个人已符合,缺少核验依据时仍需核实。</text><text v-for="(condition, index) in product!.conditions" :key="index" class="condition">{{ condition }}</text></view>
<view class="details-section"><text class="heading">资料准备说明</text><text class="body">{{ product!.materials }}</text><text class="muted">仅提供材料引导,不向机构提交申请。实际条件和材料须向机构核实。</text></view>
<view class="details-section"><text class="heading">资料准备说明</text><view v-for="(material, index) in product!.materialList" :key="index" class="material-item"><text class="condition">{{ material.name }} · {{ material.required ? '必需' : '补充' }} · {{ material.objects }}</text><text v-if="material.note.length > 0" class="evidence">{{ material.note }}</text></view><text v-if="product!.materialList.length == 0 || product!.materials != '未提供'" class="body">{{ product!.materials }}</text><text class="muted">仅提供材料引导,不向机构提交申请。实际条件和材料须向机构核实。</text></view>
</template>
<view class="footnotes"><text class="record-note">{{ assessmentId.length > 0 ? '本次评估 #' + assessmentId : '公共产品资料' }} · 产品版本 v{{ product!.version }}</text><text v-if="match != null" class="record-note">{{ match!.availabilityLabel }},历史参数与结论保留。</text><text v-if="match != null && !canPrepare" class="record-note">{{ match!.state != 'eligible' ? '请先核对不符条件或待核实项。' : '当前产品不可用或状态未知,暂不提供准备入口。' }}</text><text class="record-note">自填信息未经核验;本页面不承诺额度、放款或获批。</text><text v-if="assessmentId.length > 0" class="link" @click="openCompare">查看同类产品对比</text><text v-if="assessmentId.length > 0" class="link" @click="toMatches">返回本次匹配</text><text v-else class="link" @click="history">选择评估查看匹配结果</text></view>
<view class="footnotes"><text class="record-note">{{ assessmentId.length > 0 ? '本次评估 #' + assessmentId : '公共产品资料' }} · 产品版本 v{{ product!.version }}</text><text v-if="match != null" class="record-note">{{ match!.availabilityLabel }},历史参数与结论保留。</text><text v-if="match != null && !canPrepare" class="record-note">{{ match!.state != 'eligible' ? '请先核对不符条件或待核实项。' : '当前产品不可用或状态未知,暂不提供准备入口。' }}</text><text v-if="match != null && match!.score != null" class="record-note">匹配度表示条件匹配程度,不代表获批概率。</text><text class="record-note">自填信息未经核验;本页面不承诺额度、放款或获批。</text><text v-if="assessmentId.length > 0" class="link" @click="openCompare">查看同类产品对比</text><text v-if="assessmentId.length > 0" class="link" @click="toMatches">返回本次匹配</text><text v-else class="link" @click="history">选择评估查看匹配结果</text></view>
</template>
</view>
</scroll-view>
<view v-if="pageActive && product != null" class="action-bar" :style="bottomStyle(22)">
<template v-if="match != null && canPrepare"><button class="footer-button secondary" @click="openCompare">查看同类产品对比</button><button class="footer-button primary" @click="prepare">准备申请</button></template>
<template v-if="match != null && canPrepare"><button class="footer-button secondary" @click="consultAdvisor">咨询顾问</button><button class="footer-button primary" @click="prepare">申请办理</button></template>
<template v-else-if="match != null"><button class="footer-button secondary" @click="switchTab(tab == 'details' ? 'explanation' : 'details')">{{ tab == 'details' ? '查看匹配解释' : '查看产品详情' }}</button><button class="footer-button primary" @click="consult">我有疑问,咨询AI</button></template>
<button v-else class="footer-button primary" @click="switchTab(tab == 'conditions' ? 'details' : 'conditions')">{{ tab == 'conditions' ? '查看产品详情' : '查看申请条件' }}</button>
</view>
@@ -84,7 +84,7 @@
import AppHeader from '@/components/AppHeader.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { MatchView, MatchReason, MatchDetail, ProductView, fetchMatchDetailRecord, fetchPublicProduct, validRecordId, requestProductExplanation } from '@/packages/finance/services/matches.uts'
import { MatchView, MatchReason, MatchDetail, ProductView, fetchMatchDetailRecord, fetchPublicProduct, validRecordId, requestProductExplanation, hasProductValue } from '@/packages/finance/services/matches.uts'
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery, sameMatchSnapshot, matchReturnPath } from '@/packages/finance/services/compare.uts'
import { assistantRoute } from '@/packages/finance/services/assistant.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
@@ -189,6 +189,13 @@
uni.redirectTo({ url: '/packages/finance/product/product' + query + 'productId=' + productId.value + '&tab=' + value + '&from=' + from })
}
// 下一步页重新核对当前可用性,避免沿用产品详情进入时的旧状态。
const consultAdvisor = () => {
if (!ready() || product.value == null) return
if (match.value != null && match.value!.availability != 'available') { uni.showToast({ title: '产品当前不可用,请联系平台核对。', icon: 'none' }); return }
const phone = product.value!.consultPhone
if (phone.length == 0) { uni.showToast({ title: '暂未配置顾问电话,请联系平台。', icon: 'none' }); return }
uni.makePhoneCall({ phoneNumber: phone })
}
const prepare = () => { if (ready() && canPrepare.value) uni.navigateTo({ url: '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&productId=' + productId.value + '&section=prepare' }) }
const back = () => { if (owner.length > 0 && !current(generation, owner)) return; if (getCurrentPages().length > 1) uni.navigateBack(); else if (from == 'compare') uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }); else toMatches() }
const openCompare = () => {
@@ -238,6 +245,9 @@
.state { font-size: 24rpx; line-height: 36rpx; margin-top: 5rpx; }
.product-name { color: #788294; font-size: 24rpx; line-height: 36rpx; margin-top: 3rpx; }
.demo { color: #8d96a4; font-size: 21rpx; line-height: 32rpx; margin-top: 10rpx; }
.product-summary { flex-direction: row; flex-wrap: wrap; margin-top: 12rpx; }
.summary-tag { color: #1670ed; background-color: #edf4ff; font-size: 23rpx; line-height: 38rpx; padding: 4rpx 12rpx; border-radius: 6rpx; margin-right: 10rpx; margin-top: 6rpx; }
.material-item { margin-top: 12rpx; }
.tabs { flex-direction: row; border-bottom: 1rpx solid #e8edf4; margin-top: 18rpx; }
.tab { flex: 1; align-items: center; padding: 22rpx 0; }
.active { border-bottom: 4rpx solid #0868f7; }
+50 -18
View File
@@ -1,25 +1,30 @@
import { revisionParams } from './records.uts'
import { requestJson } from '@/services/api.uts'
import { API_BASE_URL } from '@/services/config.uts'
import { yuanToWan } from '@/services/money.uts'
export type ProductView = { id: string, version: number, name: string, bank: string, type: string, demo: boolean, amount: string, rate: string, term: string, objects: string, regions: string, valid: string, materials: string, source: string, sourceRef: string, checkedOn: string, conditions: string[] }
export type ProductMaterial = { name: string, note: string, required: boolean, objects: string }
export type ProductView = { logo: string, materialList: ProductMaterial[], consultPhone: string, repayment: string, channel: string, approval: string, id: string, version: number, name: string, bank: string, type: string, demo: boolean, amount: string, rate: string, term: string, objects: string, regions: string, valid: string, materials: string, source: string, sourceRef: string, checkedOn: string, conditions: string[] }
export type MatchReason = { id: string, text: string, evidence: string[], fields: string[], explanation: string }
export type MatchDetail = { assessmentId: string, revisionId: string, resultVersion: number, snapshotId: string, ruleVersion: string, orderingVersion: string, item: MatchView }
export type MatchView = { id: string, state: string, stateLabel: string, availability: string, availabilityLabel: string, product: ProductView, positive: MatchReason[], blocking: MatchReason[], cautions: MatchReason[], aiText: string, aiStatus: string, aiCanRetry: boolean, aiCanRequest: boolean, aiGeneratedAt: string }
export type MatchView = { score: number | null, id: string, state: string, stateLabel: string, availability: string, availabilityLabel: string, product: ProductView, positive: MatchReason[], blocking: MatchReason[], cautions: MatchReason[], aiText: string, aiStatus: string, aiCanRetry: boolean, aiCanRequest: boolean, aiGeneratedAt: string }
export type MatchPage = { assessmentId: string, revisionId: string, resultVersion: number, snapshotId: string, ruleVersion: string, orderingVersion: string, status: string, message: string, eligible: string, ineligible: string, unknown: string, items: MatchView[], hasMore: boolean, cursor: string }
function text(data: UTSJSONObject, key: string): string { return data.getString(key) ?? '' }
export function validRecordId(value: string): boolean { return /^[1-9][0-9]{0,9}$/.test(value) }
export function eligibilityLabel(value: string): string { return value == 'eligible' ? '符合已知条件' : (value == 'ineligible' ? '条件不符' : '待核实') }
const fieldNames: UTSJSONObject = { requestedAmountYuan: '融资金额', requestedTermMonths: '融资期限', applicantType: '申请主体', purposeCode: '资金用途', cityCode: '所在城市', monthlyIncome: '月收入', annualRevenue: '年营收', operatingMonths: '经营月数', totalDebtBalance: '贷款余额', monthlyLoanPayment: '每月还款额', creditLoanCount: '贷款笔数', creditCardCount: '信用卡数量', creditCardTotalLimit: '信用卡总额度', creditCardUsedLimit: '信用卡已用额度', socialSecurity: '社保情况', housingFund: '公积金情况', house: '房产情况', car: '车辆情况' }
const labels: UTSJSONObject = { personal: '个人', enterprise: '企业', daily_consumption: '日常消费', education: '教育进修', home_improvement: '家居装修', personal_business: '个体经营周转', working_capital: '经营周转', inventory_purchase: '采购备货', equipment_purchase: '设备购置', business_expansion: '经营扩展', other: '其他用途', none: '无', under_six_months: '不足六个月', six_months_plus: '六个月及以上', yes: '有', no: '无', '440300': '深圳市', '440100': '广州市', '110100': '北京市', '310100': '上海市', '330100': '杭州市' }
const moneyFields = ['requestedAmountYuan','monthlyIncome','annualRevenue','totalDebtBalance','monthlyLoanPayment','creditCardTotalLimit','creditCardUsedLimit']
export function eligibilityLabel(value: string): string { return value == 'eligible' ? '符合已知条件' : (value == 'ineligible' ? '条件不符' : '待确认') }
export function hasProductValue(value: string): boolean { return value.trim().length > 0 && !['未提供', '待核实', '—'].includes(value.trim()) }
export function matchPercentage(state: string, score: number | null): string { return state == 'eligible' && score != null ? score.toString() + '%' : '' }
const fieldNames: UTSJSONObject = { requestedAmountYuan: '融资金额', requestedTermMonths: '融资期限', applicantType: '申请主体', purposeCode: '资金用途', cityCode: '所在城市', monthlyIncome: '月收入', annualRevenue: '年营收', operatingMonths: '经营月数', totalDebtBalance: '贷款余额', monthlyLoanPayment: '每月还款额', creditLoanCount: '贷款笔数', creditCardCount: '信用卡数量', creditCardTotalLimit: '信用卡总额度', creditCardUsedLimit: '信用卡已用额度', socialSecurity: '社保情况', housingFund: '公积金情况', house: '房产情况', car: '车辆情况', establishedMonths: '企业成立月数', annualInvoiceAmount: '年开票金额', taxPaymentMonths: '连续纳税月数', taxpayerGrade: '纳税等级', enterpriseCreditStatus: '企业信用状态', annualRevenueGrowthPercent: '年营收增长率', debtRatioPercent: '负债率', cashFlowStability: '经营流水稳定性', industryCategory: '行业类别' }
const labels: UTSJSONObject = { personal: '个人', enterprise: '企业', daily_consumption: '日常消费', education: '教育进修', home_improvement: '家居装修', personal_business: '个体经营周转', working_capital: '经营周转', inventory_purchase: '采购备货', equipment_purchase: '设备购置', business_expansion: '经营扩展', other: '其他用途', none: '无', under_six_months: '不足六个月', six_months_plus: '六个月及以上', yes: '有', no: '无', normal: '正常', abnormal: '异常', stable: '稳定', unstable: '不稳定', manufacturing: '制造业', trade: '贸易', services: '服务业', construction: '建筑业', agriculture: '农业', finance: '金融业', real_estate: '房地产业', '440300': '深圳市', '440100': '广州市', '110100': '北京市', '310100': '上海市', '330100': '杭州市' }
const moneyFields = ['requestedAmountYuan','monthlyIncome','annualRevenue','totalDebtBalance','monthlyLoanPayment','creditCardTotalLimit','creditCardUsedLimit','annualInvoiceAmount']
function valueText(value: any | null, field: string): string {
if (value == null) return '未提供'
const raw = value.toString()
if (moneyFields.includes(field)) return yuanToWan(raw) + ' 万元'
if (field == 'requestedTermMonths' || field == 'operatingMonths') return raw + ' 个月'
if (field == 'requestedTermMonths' || field == 'operatingMonths' || field == 'establishedMonths' || field == 'taxPaymentMonths') return raw + ' 个月'
if (field == 'debtRatioPercent' || field == 'annualRevenueGrowthPercent') return raw + '%'
return labels.getString(raw) ?? raw
}
function ruleText(rule: UTSJSONObject): string {
@@ -33,6 +38,17 @@ function ruleText(rule: UTSJSONObject): string {
const operation = op == 'gte' ? '不少于 ' : (op == 'lte' ? '不超过 ' : (op == 'in' ? '属于 ' : ''))
return prefix + ':' + operation + expected + (text(rule, 'requiredSource') == 'verified' ? '(需核验依据)' : '')
}
function rateText(value: string): string { return value.indexOf('.') >= 0 ? value.replace(/0+$/, '').replace(/\.$/, '') : value }
function validityText(data: UTSJSONObject): string {
const from = text(data, 'validFrom'); const until = text(data, 'validUntil')
if (from.length == 0) return until.length == 0 ? '未设置有效期' : '有效至 ' + until
return until.length == 0 ? from + ' 起生效' : from + ' 至 ' + until
}
function rangeText(min: string, max: string, unit: string): string {
if (min.length == 0) return max.length == 0 ? '未提供' : '最高 ' + max + unit
if (max.length == 0) return '最低 ' + min + unit
return (min == max ? min : min + '–' + max) + unit
}
function productView(data: UTSJSONObject, publicMode: boolean): ProductView {
const source = data['source'] as UTSJSONObject
const objects = data['applicantTypes'] as string[]; const regions = data['regions'] as string[]
@@ -43,14 +59,20 @@ function productView(data: UTSJSONObject, publicMode: boolean): ProductView {
(groups[type] as UTSJSONObject[]).forEach((r: UTSJSONObject) => { conditions.push((labels.getString(type) ?? type) + ' · ' + ruleText(r)) })
})
} else (data['rules'] as UTSJSONObject[]).forEach((r: UTSJSONObject) => { conditions.push(ruleText(r)) })
return { id: text(data, 'productId'), version: data.getNumber('productVersion') ?? 0, name: text(data, 'productName'), bank: text(data, 'bankName'), type: text(data, 'productType'), demo: data.getBoolean('isDemo') ?? false,
amount: text(data, 'amountMinYuan').length == 0 || text(data, 'amountMaxYuan').length == 0 ? '未提供' : yuanToWan(text(data, 'amountMinYuan')) + '–' + yuanToWan(text(data, 'amountMaxYuan')) + ' 万元',
rate: text(data, 'annualRateMinPercent').length == 0 || text(data, 'annualRateMaxPercent').length == 0 ? '未提供' : text(data, 'annualRateMinPercent') + '%–' + text(data, 'annualRateMaxPercent') + '%',
term: data.getNumber('termMinMonths') == null || data.getNumber('termMaxMonths') == null ? '未提供' : data.getNumber('termMinMonths')!.toString() + '–' + data.getNumber('termMaxMonths')!.toString() + ' 个月',
objects: objects.map((v: string): string => labels.getString(v) ?? v).join(' / '), regions: regions.length == 0 ? '全国' : regions.map((v: string): string => labels.getString(v) ?? v).join('、'),
valid: text(data, 'validFrom') + ' 至 ' + text(data, 'validUntil'), materials: text(data, 'information').length > 0 ? text(data, 'information') : '未提供',
const rawLogo = text(data, 'bankLogo')
const logo = /^https:\/\/[^\s<>"']+$/.test(rawLogo) ? rawLogo : (/^\/uploads\/[a-zA-Z0-9/_.-]+$/.test(rawLogo) ? API_BASE_URL.replace(/\/api\/v1\/?$/, '') + rawLogo : '')
const materialList = ((data['materials'] as UTSJSONObject[] | null) ?? []).map((item: UTSJSONObject): ProductMaterial => {
return { name: text(item, 'name'), note: text(item, 'note'), required: item.getBoolean('required') ?? false, objects: ((item['applicantTypes'] as string[] | null) ?? []).map((v: string): string => labels.getString(v) ?? v).join(' / ') }
})
const rawPhone = text(data, 'consultPhone')
return { logo, materialList, consultPhone: /^[+0-9][0-9 -]{4,29}$/.test(rawPhone) ? rawPhone : '', repayment: text(data, 'repaymentMethod') || '未提供', channel: text(data, 'channel') || '未提供', approval: text(data, 'approvalDuration') || '未提供', id: text(data, 'productId'), version: data.getNumber('productVersion') ?? 0, name: text(data, 'productName'), bank: text(data, 'bankName'), type: text(data, 'productType'), demo: data.getBoolean('isDemo') ?? false,
amount: text(data, 'expectation') || rangeText(text(data, 'amountMinYuan').length == 0 ? '' : yuanToWan(text(data, 'amountMinYuan')), text(data, 'amountMaxYuan').length == 0 ? '' : yuanToWan(text(data, 'amountMaxYuan')), ' 万元'),
rate: rangeText(rateText(text(data, 'annualRateMinPercent')), rateText(text(data, 'annualRateMaxPercent')), '%'),
term: text(data, 'quota') || rangeText(data.getNumber('termMinMonths')?.toString() ?? '', data.getNumber('termMaxMonths')?.toString() ?? '', ' 个月'),
objects: objects.map((v: string): string => labels.getString(v) ?? v).join(' / '), regions: text(data, 'regionMode') == 'unverified' ? '待核实' : (regions.length == 0 ? '全国' : regions.map((v: string): string => labels.getString(v) ?? v).join('、')),
valid: validityText(data), materials: text(data, 'information').length > 0 ? text(data, 'information') : '未提供',
// 内部引用用于定位资料来源,不把测试环境标识当作面向用户的产品说明。
source: text(source, 'publisher') + ' · ' + text(source, 'title'), sourceRef: text(source, 'reference').startsWith('demo:airz-') ? '产品规则档案' : text(source, 'reference'), checkedOn: text(source, 'checkedOn'), conditions }
source: [text(source, 'publisher'), text(source, 'title')].filter((value: string): boolean => value.length > 0).join(' · ') || '待核实', sourceRef: text(source, 'reference').startsWith('demo:airz-') ? '产品规则档案' : (text(source, 'reference') || '未提供'), checkedOn: text(source, 'checkedOn') || '未提供', conditions }
}
function matchView(data: UTSJSONObject): MatchView {
const snapshot = data['productSnapshot'] as UTSJSONObject
@@ -61,20 +83,30 @@ function matchView(data: UTSJSONObject): MatchView {
const explanations = (ai['reasonExplanations'] as UTSJSONObject[] | null) ?? []
const reasons = (key: string): MatchReason[] => (data[key] as UTSJSONObject[]).map((r: UTSJSONObject): MatchReason => {
const reasonFields: string[] = []
const reasonText = ['AI_CONDITION_PASS', 'AI_CONDITION_FAIL'].includes(text(r, 'code')) ? text(r, 'text').replace('(根据自填资料判断)', '') : text(r, 'text')
const lines = (r['evidenceRefs'] as string[]).map((ref: string): string => {
const e = evidence.find((item: UTSJSONObject): boolean => text(item, 'evidenceId') == ref)
if (e == null) return '证据暂不可读取'
const fields = e['fieldRefs'] as string[]; const field = fields.length > 0 ? fields[0] : ''
fields.forEach((name: string) => { if (!reasonFields.includes(name)) reasonFields.push(name) })
if (text(e, 'source') == 'ai_self_reported') {
if (e['actual'] != null && fieldNames.getString(field) != null) return fieldNames.getString(field)! + ':' + valueText(e['actual'], field)
const names = fields.map((name: string): string => fieldNames.getString(name) ?? '').filter((name: string): boolean => name.length > 0)
return names.length > 0 ? '关联资料:' + names.join('、') : reasonText
}
const source = text(e, 'source') == 'verified' ? '外部核验' : (text(e, 'source') == 'product_snapshot' ? '产品快照' : (text(e, 'source') == 'self_reported' ? '用户自填,未经核验' : '来源未知'))
return (fieldNames.getString(field) ?? field) + ':' + valueText(e['actual'], field) + ' · ' + source
})
const explanation = explanations.find((item: UTSJSONObject): boolean => text(item, 'reasonId') == text(r, 'reasonId'))
return { id: text(r, 'reasonId'), text: text(r, 'text'), evidence: lines, fields: reasonFields, explanation: text(ai, 'status') == 'completed' && explanation != null ? text(explanation, 'text') : '' }
return { id: text(r, 'reasonId'), text: reasonText, evidence: lines, fields: reasonFields, explanation: text(ai, 'status') == 'completed' && explanation != null ? text(explanation, 'text') : '' }
})
const availability = text(data, 'currentAvailability')
return { id: text(data, 'productId'), state: text(data, 'eligibility'), stateLabel: eligibilityLabel(text(data, 'eligibility')), availability,
availabilityLabel: availability == 'available' ? '当前产品可查看' : (availability == 'unavailable' ? '当前产品已下架或不在有效期' : '当前产品状态暂无法确认'),
const score = data.getNumber('score')
if (score != null && (Math.floor(score) != score || score < 0 || score > 100 || text(data, 'scorePolicyVersion') != 'product-soft-score-v1')) throw new Error('匹配度资料无效,请重新加载。')
const pending = evidence.filter((item: UTSJSONObject): boolean => text(item, 'kind') == 'hard' && text(item, 'outcome') == 'unknown')
const missingData = text(data, 'eligibility') == 'unknown' && pending.length > 0 && pending.every((item: UTSJSONObject): boolean => ['FACT_MISSING', 'AI_CONDITION_UNKNOWN'].includes(text(item, 'reasonCode')))
return { score, id: text(data, 'productId'), state: text(data, 'eligibility'), stateLabel: missingData ? '资料不足' : eligibilityLabel(text(data, 'eligibility')), availability,
availabilityLabel: availability == 'available' ? '当前产品可查看' : (availability == 'unavailable' ? '当前产品已下架' : '当前产品状态暂无法确认'),
product: productView(data['productSnapshot'] as UTSJSONObject, false), positive: reasons('positiveReasons'), blocking: reasons('blockingReasons'), cautions: reasons('cautions'),
aiText: text(ai, 'status') == 'completed' ? text(ai, 'text') : (text(ai, 'status') == 'failed' ? 'AI解释暂不可用,已保留全部规则依据。' : '当前展示规则依据,尚未生成单产品AI解释。'),
aiStatus: text(ai, 'status'), aiCanRetry: ai.getBoolean('canRetry') ?? false, aiCanRequest: data.getBoolean('canRequestExplanation') ?? false, aiGeneratedAt: text(ai, 'generatedAt') }
@@ -100,7 +132,7 @@ export async function fetchMatchPage(id: string, group: string, cursor: string,
}
const reason = text(matching, 'reasonCode')
const count = (key: string): string => { const v = matching.getNumber(key); return v == null ? '—' : v.toString() }
const message = status == 'completed' ? '按本次评估时的产品条件判断,展示顺序不代表优劣。' : (reason == 'LEGACY_INPUT' ? '旧版报告未执行产品匹配,可重新评估获取新的结果。' : (reason == 'MATCH_RULES_UNPUBLISHED' ? '本次评估尚未启用产品匹配规则。' : '本次评估没有可用产品目录,未执行产品匹配。'))
const message = status == 'completed' ? (text(matching, 'orderingPolicyVersion') == 'product-score-descending-v1' ? '按本次条件匹配度排序,匹配度不代表获批概率。' : '按本次评估时的产品条件判断,展示顺序不代表优劣。') : (reason == 'LEGACY_INPUT' ? '旧版报告未执行产品匹配,可重新评估获取新的结果。' : (reason == 'MATCH_RULES_UNPUBLISHED' ? '本次评估尚未启用产品匹配规则。' : '本次评估没有可用产品目录,未执行产品匹配。'))
return { assessmentId: id, revisionId: resolvedRevision, resultVersion: version, snapshotId: text(matching, 'catalogSnapshotId'), ruleVersion: text(matching, 'ruleVersion'), orderingVersion: text(matching, 'orderingPolicyVersion'), status, message, eligible: count('candidateCount'), ineligible: count('ineligibleCount'), unknown: count('unknownCount'), items, hasMore, cursor: nextCursor }
}
export async function fetchMatchDetail(id: string, productId: string, revisionId: string = ''): Promise<MatchView> {
+4 -2
View File
@@ -175,7 +175,9 @@ export async function createAssessment(payload: UTSJSONObject): Promise<string>
if (session == null) throw new Error('请先登录')
const documents = payload['documents'] as UTSJSONObject[] | null
const grants = payload['authorizations'] as string[] | null
if ((documents != null && documents.length > 0) || (grants != null && grants.length > 0)) throw new Error('资料上传与外部查询授权尚未开放,请移除资料及授权选择后提交')
if (documents != null && documents.some((item) => !/^[1-9][0-9]{0,18}$/.test(item.getString('documentId') ?? '') || !/^[1-9][0-9]{0,18}$/.test(item.getString('consentId') ?? '') || item.getBoolean('deletePending') == true))
throw new Error('请先完成照片上传或移除未完成资料')
if (grants != null && grants.length > 0) throw new Error('外部查询尚未开放,请取消授权选择后提交')
const editId = draftText(payload, 'editAssessmentId'); const baseRevision = draftText(payload, 'baseRevisionId')
if (editId.length > 0 && (!validAssessmentId(editId) || !validAssessmentId(baseRevision))) throw new Error('修改版本无效,请返回档案重新读取。')
if (!['personal', 'enterprise'].includes(draftText(payload, 'type'))) throw new Error('评估类型无效,请重新填写。')
@@ -201,7 +203,7 @@ export async function createAssessment(payload: UTSJSONObject): Promise<string>
const cityCodes = ['440300', '440100', '110100', '310100', '330100']
const cityIndex = cityNames.indexOf(draftText(values, 'city'))
form['cityCode'] = cityIndex < 0 ? null : cityCodes[cityIndex]
// 输入v3恢复原资料表单,不提交已取消的资金需求字段;报告仍使用v2展示结构。
// 卡片资料单独保存,确认页明示暂不绑定评估;只发送自填信息,绝不把本机路径或附件送入分析。
const body: UTSJSONObject = { inputVersion: 3, type: values['type'], moneyUnit: 'yuan', form, documents: [] as UTSJSONObject[], consentIds: [] as string[] }
if (editId.length > 0) body['baseRevisionId'] = baseRevision
// 新建和修改使用不同幂等作用域,回执丢失时安全重放原提交。
+3 -1
View File
@@ -1,6 +1,7 @@
{
"pages": [
{ "path": "pages/home/home", "style": { "navigationStyle": "custom" } }
{ "path": "pages/home/home", "style": { "navigationStyle": "custom" } },
{ "path": "pages/agreement/agreement", "style": { "navigationStyle": "custom" } }
],
"subPackages": [
{
@@ -25,6 +26,7 @@
{ "path": "index/index", "style": { "navigationStyle": "custom" } },
{ "path": "login/login", "style": { "navigationStyle": "custom" } },
{ "path": "profile/profile", "style": { "navigationStyle": "custom" } },
{ "path": "consents/consents", "style": { "navigationStyle": "custom" } },
{ "path": "edit-profile/edit-profile", "style": { "navigationStyle": "custom" } },
{ "path": "security/security", "style": { "navigationStyle": "custom" } },
{ "path": "password/password", "style": { "navigationStyle": "custom" } },
+108
View File
@@ -0,0 +1,108 @@
<template>
<view class="page page-white" :style="pageStyle()">
<AppHeader :title="pageTitle" />
<scroll-view scroll-y :style="contentStyle(96, false)">
<view v-if="loading" class="empty-content"><text class="empty-copy">正在加载协议全文…</text></view>
<view v-else-if="unavailable" class="empty-content"><text class="empty-title">协议尚未正式发布</text><text class="empty-copy">正式正文和版本发布后可在此查看。当前无法记录此协议的同意。</text><button class="primary-button empty-button" @click="reload">重新查看</button></view>
<view v-else-if="error.length > 0" class="empty-content"><text class="empty-title">协议加载失败</text><text class="empty-copy">{{ error }}</text><button class="primary-button empty-button" @click="reload">重新加载</button></view>
<view v-else-if="available" class="agreement-content" :style="bottomStyle(40)">
<text class="agreement-title">{{ agreement.title }}</text>
<text class="agreement-meta">版本 {{ agreement.version }}</text>
<text class="agreement-meta">生效时间:{{ consentTime(agreement.effectiveAt) }}</text>
<text v-if="version.length > 0" class="history-note">您正在查看指定版本的协议全文。</text>
<text class="agreement-body">{{ agreement.content }}</text>
<view v-if="optionalConsentScope(agreement.scope)" class="consent-area">
<text class="consent-note">此处仅记录您的同意意向,不会上传资料、查询征信或发起外部数据查询。您可在“个人中心 → 隐私与授权”查看记录、撤销可选授权。</text>
<text v-if="version.length > 0" class="consent-note">同意新授权时,请查看当前有效版本。</text>
<text v-if="actionMessage.length > 0" class="action-message">{{ actionMessage }}</text>
<button v-if="version.length == 0 && !granted" class="primary-button action-button" :disabled="submitting" @click="confirmConsent">{{ submitting ? '正在记录…' : (pendingId.length > 0 ? '用原请求重试同意' : (loggedIn ? '我已阅读,同意并记录' : '登录后记录同意')) }}</button>
<button v-if="version.length > 0" class="secondary-button action-button" @click="openCurrent">查看当前版本</button>
<button class="secondary-button action-button" @click="openRecords">查看我的同意记录</button>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import AppHeader from '@/components/AppHeader.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { Agreement, emptyAgreement, fetchAgreement, agreementRoute, consentTitle, consentTime, optionalConsentScope, agreementUnavailable, agreementChanged, grantConsent, consentRequestId, sameConsentSession } from '@/services/agreements.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
const agreement = ref<Agreement>(emptyAgreement())
const pageTitle = ref('协议全文'); const loading = ref(false); const available = ref(false); const unavailable = ref(false); const error = ref('')
const loggedIn = ref(false); const submitting = ref(false); const granted = ref(false); const actionMessage = ref(''); const pendingId = ref('')
const version = ref('')
let code = ''; let visible = false; let generation = 0; let actionGeneration = 0; let accountTimer = 0
let pendingAgreement: Agreement | null = null; let ownerToken = ''; let ownerId = ''
const resetPending = () => { pendingId.value = ''; pendingAgreement = null; granted.value = false; submitting.value = false; actionMessage.value = ''; actionGeneration++ }
const syncSession = () => {
const session = getSession(); const token = session?.getString('token') ?? ''; const userId = session?.getString('userId') ?? ''
if (token != ownerToken || userId != ownerId) resetPending()
ownerToken = token; ownerId = userId; loggedIn.value = session != null
}
const sameOwner = (token: string, userId: string): boolean => {
const current = getSession()
return visible && sameConsentSession(token, userId, current?.getString('token') ?? '', current?.getString('userId') ?? '')
}
async function reload() {
const request = ++generation; loading.value = true; available.value = false; unavailable.value = false; error.value = ''
try {
const result = await fetchAgreement(code, version.value)
if (!visible || request != generation) return
agreement.value = result; available.value = true; pageTitle.value = consentTitle(result.code)
} catch (err) {
if (!visible || request != generation) return
if (agreementUnavailable(err)) unavailable.value = true
else error.value = err instanceof Error ? err.message : '请检查网络后重试'
} finally { if (request == generation) loading.value = false }
}
const loginRoute = (): string => '/packages/account/login/login?resumeAgreement=1&agreementCode=' + code
async function confirmConsent() {
if (!visible || submitting.value || !available.value || version.value.length > 0) return
syncSession()
if (!loggedIn.value) { uni.navigateTo({ url: loginRoute() }); return }
const token = ownerToken; const userId = ownerId; const request = ++actionGeneration
if (pendingAgreement == null) { pendingAgreement = agreement.value; pendingId.value = consentRequestId() }
const pending = pendingAgreement!
submitting.value = true; actionMessage.value = ''
try {
const result = await grantConsent(pending, pendingId.value)
if (request != actionGeneration || !sameOwner(token, userId)) return
pendingId.value = ''; pendingAgreement = null; granted.value = result.revokedAt.length == 0
actionMessage.value = result.revokedAt.length > 0 ? '此前同意已撤销;重新同意需再次点击,不会自动记录新的同意。' : '同意已记录,未发起外部查询。'
} catch (err) {
if (request != actionGeneration || !sameOwner(token, userId)) return
if (agreementChanged(err)) {
pendingId.value = ''; pendingAgreement = null; actionMessage.value = '协议已更新,请重新阅读当前版本后再次同意。'; await reload()
} else actionMessage.value = (err instanceof Error ? err.message : '记录失败') + ';请用原请求重试确认。'
} finally { if (request == actionGeneration) submitting.value = false }
}
const openRecords = () => { if (visible) uni.navigateTo({ url: getSession() == null ? '/packages/account/login/login?resumeConsents=1' : '/packages/account/consents/consents' }) }
const openCurrent = () => { const url = agreementRoute(code); if (visible && url.length > 0) uni.redirectTo({ url }) }
onLoad((options) => { code = options?.['code'] ?? ''; version.value = options?.['version'] ?? ''; pageTitle.value = consentTitle(code) })
onShow(() => {
visible = true; syncSession()
// 已完成回执可能已在记录页被撤销;返回后重新展示显式操作,未知请求仍沿用原键。
if (pendingAgreement == null) { granted.value = false; actionMessage.value = '' }
clearInterval(accountTimer); accountTimer = setInterval(() => { if (visible) syncSession() }, 1000); reload()
})
const hidePage = () => { visible = false; generation++; actionGeneration++; submitting.value = false; clearInterval(accountTimer) }
onHide(() => hidePage())
onUnload(() => { hidePage(); resetPending() })
</script>
<style>
.agreement-content { padding: 30rpx; }
.agreement-title { color: #172640; font-size: 38rpx; line-height: 56rpx; font-weight: 700; }
.agreement-meta { color: #66758a; font-size: 23rpx; line-height: 36rpx; margin-top: 12rpx; }
.history-note { padding: 20rpx; margin-top: 22rpx; background-color: #f2f6fc; color: #526680; font-size: 24rpx; line-height: 38rpx; border-radius: 12rpx; }
.agreement-body { color: #40536d; font-size: 28rpx; line-height: 50rpx; margin-top: 32rpx; white-space: pre-wrap; }
.consent-area { margin-top: 36rpx; padding-top: 24rpx; border-top: 1rpx solid #e8eef6; }
.consent-note { color: #66758a; font-size: 24rpx; line-height: 40rpx; margin-top: 12rpx; }
.action-message { color: #31567d; font-size: 25rpx; line-height: 40rpx; margin-top: 20rpx; }
.action-button { margin-top: 24rpx; font-size: 27rpx; }
.empty-content { padding: 100rpx 40rpx; align-items: center; }
.empty-title { color: #172640; font-size: 32rpx; font-weight: 700; }
.empty-copy { color: #66758a; font-size: 26rpx; line-height: 42rpx; margin-top: 20rpx; }
.empty-button { width: 100%; margin-top: 38rpx; }
</style>
+389
View File
@@ -0,0 +1,389 @@
// Pure UTS boundary tests. No network, database, real session or runtime installation.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require(process.env.AIRZ_TYPESCRIPT_PATH || 'D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dcloudio/uni-uts-v1/lib/typescript/lib/typescript.js');
const root = path.resolve(__dirname, '..');
class JsonObject {
constructor(value = {}) { Object.assign(this, value); }
getString(key) { return typeof this[key] === 'string' ? this[key] : null; }
getNumber(key) { return typeof this[key] === 'number' ? this[key] : null; }
getBoolean(key) { return typeof this[key] === 'boolean' ? this[key] : null; }
}
const json = value => Array.isArray(value) ? value.map(json) : value && typeof value === 'object' ? new JsonObject(Object.fromEntries(Object.entries(value).map(([k, v]) => [k, json(v)]))) : value;
let session = json({ token: 'synthetic-token-a', userId: '1' });
let calls = [];
let reply;
class ApiError extends Error { constructor(message, status, errorCode) { super(message); this.status = status; this.errorCode = errorCode; } }
const api = {
ApiRequestError: ApiError,
getSession: () => session,
requestJson: async (url, method, payload) => { calls.push({ url, method, payload: JSON.parse(JSON.stringify(payload)) }); return json(await reply(url, method, payload)); }
};
const modules = {};
const pages = [];
const redirects = [];
const styles = { pageStyle: () => '', contentStyle: () => '', statusStyle: () => '', bottomStyle: () => '' };
let pageShow, pageHide, pageLoad;
const pageUni = { showToast: () => {}, showModal: options => options.success?.({ confirm: true }), redirectTo: ({ url }) => redirects.push(url), navigateTo: ({ url }) => pages.push(url) };
api.saveSession = (token, phone, userId, expiresAt) => { session = json({ token, phone, userId, expiresAt }); };
api.API_BASE_URL = 'https://synthetic.invalid/api/v1';
function load(relative) {
if (modules[relative]) return modules[relative];
const file = path.join(root, relative);
if (!fs.existsSync(file)) return {};
const output = ts.transpileModule(fs.readFileSync(file, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
const exports = {};
modules[relative] = exports;
vm.runInNewContext(output, { exports, UTSJSONObject: JsonObject, Date, Math, Number, Error,
require: name => name.endsWith('/api.uts') || name === './api.uts' ? api : load(path.posix.normalize(path.posix.join(path.posix.dirname(relative), name)))
}, { filename: relative });
return exports;
}
function componentPage(relative, bindings) {
pageShow = null; pageHide = null; pageLoad = null;
const source = fs.readFileSync(path.join(root, relative), 'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
const output = ts.transpileModule(source + '\nexports.__test = {' + bindings + '};', { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
const exports = {};
vm.runInNewContext(output, { exports, UTSJSONObject: JsonObject, Date, Math, Number, Error, uni: pageUni,
ref: value => ({ value }), watch: () => {}, onLoad: callback => { pageLoad = callback; }, onShow: callback => { pageShow = callback; }, onHide: callback => { pageHide = callback; }, onUnload: () => {}, onUnmounted: () => {},
setInterval: () => 1, clearInterval: () => {},
require: name => name === '@/services/api.uts' ? api : name === '@/services/layout.uts' ? { usePageLayout: () => styles } : name.endsWith('.uvue') ? {} : name === '@/services/launch.uts' ? { consumeInitialLaunch: () => {} } : load(name.replace(/^@\//, ''))
}, { filename: relative });
return exports.__test;
}
const loginPage = () => componentPage('packages/account/login/login.uvue', 'login, phone, password, agreed, errorMessage, loading, toggleAgreement: typeof toggleAgreement === "function" ? toggleAgreement : null, loadAgreements: typeof loadAgreements === "function" ? loadAgreements : null');
const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); };
const route = load('services/agreements-route.uts');
const service = load('services/agreements.uts');
let checks = 0;
async function test(name, action) { await action(); checks++; console.log('PASS ' + name); }
const hash = 'a'.repeat(64);
const terms = { revisionId: '1', code: 'terms', version: '2026.10-v1', scope: 'account-terms', title: '合成用户协议', content: '合成全文\n第二段 <script>只是纯文本</script>', contentHash: hash, effectiveAt: '2026-10-01T00:00:00Z' };
const consent = { consentId: '9', scope: 'account-terms', agreementCode: 'terms', agreementVersion: '2026.10-v1', contentHash: hash, grantedAt: '2026-10-12T00:00:00Z', revokedAt: null, canRevoke: false };
(async () => {
await test('unknown agreement code cannot produce a route', () => {
assert.equal(typeof route.agreementRoute, 'function', 'agreement route validation is not implemented');
for (const code of ['', 'unknown', 'terms?x=1', 'https://outside.invalid']) assert.equal(route.agreementRoute(code), '');
});
await test('versioned agreement links preserve the immutable version', () => {
assert.equal(route.agreementRoute('terms', '2026.10-v1'), '/pages/agreement/agreement?code=terms&version=2026.10-v1');
assert.equal(route.agreementRoute('privacy'), '/pages/agreement/agreement?code=privacy');
for (const version of ['x&code=terms', '\n', 'x'.repeat(65), '../draft']) assert.equal(route.agreementRoute('terms', version), '');
});
await test('scope mapping separates account confirmations from optional grants', () => {
assert.equal(route.agreementScope('terms'), 'account-terms');
assert.equal(route.agreementScope('privacy'), 'account-privacy');
assert.equal(route.agreementScope('document-use'), 'documents');
assert.equal(route.agreementScope('data-authorization'), 'data');
assert.equal(route.optionalConsentScope('account-privacy'), false);
assert.equal(route.optionalConsentScope('documents'), true);
});
await test('BIGINT agreement ids stay as strings and reject overflow', () => {
for (const value of ['1', '9223372036854775807']) assert.equal(route.agreementRecordIdValid(value), true);
for (const value of ['0', '01', '-1', '9223372036854775808', '99999999999999999999']) assert.equal(route.agreementRecordIdValid(value), false);
});
await test('account guard rejects another account, new token and anonymous state', () => {
assert.equal(route.sameConsentSession('a', '1', 'a', '1'), true);
for (const args of [['a', '1', 'b', '1'], ['a', '1', 'a', '2'], ['', '', '', ''], ['a', '1', '', '']]) assert.equal(route.sameConsentSession(...args), false);
});
await test('public fetch emits only a validated code and requested version', async () => {
calls = []; reply = () => terms;
const result = await service.fetchAgreement('terms', '2026.10-v1');
assert.equal(result.content, terms.content);
assert.deepEqual(calls, [{ url: '/agreements/terms', method: 'GET', payload: { version: '2026.10-v1' } }]);
await assert.rejects(service.fetchAgreement('unknown'));
assert.equal(calls.length, 1);
});
await test('wrong version, scope, hash and invalid timestamp cannot become seen agreement', async () => {
for (const patch of [{ version: 'old' }, { code: 'privacy' }, { scope: 'data' }, { contentHash: 'bad' }, { content: '' }, { effectiveAt: 'bad' }]) {
reply = () => ({ ...terms, ...patch });
await assert.rejects(service.fetchAgreement('terms', '2026.10-v1'));
}
});
await test('not-published detection does not hide a network or server failure', () => {
assert.equal(service.agreementUnavailable(new ApiError('未发布', 404, 'AGREEMENT_NOT_AVAILABLE')), true);
for (const error of [new Error('network'), new ApiError('server', 503, 'AGREEMENT_NOT_AVAILABLE'), new ApiError('not found', 404, 'OTHER')]) assert.equal(service.agreementUnavailable(error), false);
});
await test('grant sends the exact seen version and stable request key', async () => {
calls = []; reply = () => consent;
const result = await service.grantConsent(terms, 'consent_request_123');
assert.equal(result.consentId, '9');
assert.deepEqual(calls, [{ url: '/consents', method: 'POST', payload: { scope: 'account-terms', agreementCode: 'terms', agreementVersion: '2026.10-v1', requestId: 'consent_request_123' } }]);
});
await test('anonymous or malformed consent cannot reach the network', async () => {
calls = []; session = null;
await assert.rejects(service.grantConsent(terms, 'consent_request_123'));
session = json({ token: 'synthetic-token-a', userId: '1' });
await assert.rejects(service.grantConsent(terms, 'bad'));
await assert.rejects(service.grantConsent({ ...terms, scope: 'data' }, 'consent_request_123'));
assert.equal(calls.length, 0);
});
await test('consent request keys require the server minimum of sixteen characters', async () => {
calls = []; reply = () => consent;
for (const value of ['abcdefgh', 'abcdefghijklmno', 'a'.repeat(65), 'request key spaces']) await assert.rejects(service.grantConsent(terms, value));
assert.equal(calls.length, 0);
await service.grantConsent(terms, 'abcdefghijklmnop');
assert.equal(calls.length, 1);
});
await test('a response from the previous session cannot confirm consent', async () => {
reply = () => { session = json({ token: 'synthetic-token-b', userId: '1' }); return consent; };
await assert.rejects(service.grantConsent(terms, 'consent_request_123'));
session = json({ token: 'synthetic-token-a', userId: '1' });
});
await test('receipt must match seen hash, scope and version', async () => {
for (const patch of [{ agreementVersion: 'old' }, { contentHash: 'b'.repeat(64) }, { scope: 'data' }, { agreementCode: 'privacy' }, { revokedAt: '2026-10-12T01:00:00Z' }]) {
reply = () => ({ ...consent, ...patch });
await assert.rejects(service.grantConsent(terms, 'consent_request_123'));
}
});
await test('a revoked optional grant replay is a valid receipt of the original command', async () => {
session = json({ token: 'synthetic-token-a', userId: '1' });
const agreement = { ...terms, code: 'document-use', scope: 'documents' };
reply = () => ({ ...consent, scope: 'documents', agreementCode: 'document-use', revokedAt: '2026-10-12T01:00:00Z', canRevoke: false });
const result = await service.grantConsent(agreement, 'original_request_123');
assert.equal(result.revokedAt, '2026-10-12T01:00:00Z');
assert.equal(result.canRevoke, false);
});
await test('account confirmations cannot be revoked even with an incorrect permission flag', async () => {
calls = [];
await assert.rejects(service.revokeConsent({ ...consent, canRevoke: true }, 'revoke_request_123'));
assert.equal(calls.length, 0);
});
await test('optional revocation preserves the original receipt and only sends a request key', async () => {
const item = { ...consent, scope: 'documents', agreementCode: 'document-use', canRevoke: true, revokedAt: '' };
calls = []; reply = () => ({ ...item, canRevoke: false, revokedAt: '2026-10-12T01:00:00Z' });
assert.equal((await service.revokeConsent(item, 'revoke_request_123')).revokedAt, '2026-10-12T01:00:00Z');
assert.deepEqual(calls, [{ url: '/consents/9/revoke', method: 'POST', payload: { requestId: 'revoke_request_123' } }]);
});
await test('list validates scope and pagination before querying', async () => {
calls = []; reply = () => ({ items: [consent], page: 1, pageSize: 20, total: 1, hasMore: false });
const result = await service.fetchConsents('account-terms', 1);
assert.equal(result.items.length, 1);
assert.deepEqual(calls[0], { url: '/consents', method: 'GET', payload: { scope: 'account-terms', page: '1', pageSize: '20' } });
for (const args of [['unknown', 1], ['', 0], ['', 1.5], ['', 1, 51]]) await assert.rejects(service.fetchConsents(...args));
assert.equal(calls.length, 1);
});
await test('wrong-page and wrong-scope list responses cannot replace user records', async () => {
for (const patch of [{ page: 2 }, { pageSize: 10 }, { total: -1 }, { hasMore: null }, { items: [{ ...consent, scope: 'data', agreementCode: 'data-authorization' }] }]) {
reply = () => ({ items: [consent], page: 1, pageSize: 20, total: 1, hasMore: false, ...patch });
await assert.rejects(service.fetchConsents('account-terms', 1));
}
});
await test('failed post-login consent retains authentication and retries the same command without logging in again', async () => {
session = null; calls = []; redirects.length = 0;
let fail = true;
reply = (url, method, payload) => {
if (url === '/agreements/terms') return terms;
if (url === '/agreements/privacy') return { ...terms, revisionId: '2', code: 'privacy', scope: 'account-privacy' };
if (url === '/auth/password-login') return { token: 'synthetic-login-token', userId: '1', expiresAt: '2099-01-01T00:00:00Z' };
if (url === '/consents') {
if (fail) { fail = false; throw new Error('synthetic lost receipt'); }
return { ...consent, agreementCode: payload.agreementCode, scope: payload.scope, agreementVersion: payload.agreementVersion };
}
throw Error('Unexpected login request ' + url);
};
const page = loginPage();
if (pageShow) await pageShow();
if (page.loadAgreements) await page.loadAgreements();
page.phone.value = '13800000000'; page.password.value = 'synthetic-password';
if (page.toggleAgreement) page.toggleAgreement(); else page.agreed.value = true;
await page.login();
assert.equal(session.getString('token'), 'synthetic-login-token');
assert.equal(redirects.length, 0, 'login must wait for consent receipts');
assert.ok(page.errorMessage.value.length > 0, 'failed consent remains visible for manual retry');
await page.login();
const authCalls = calls.filter(call => call.url === '/auth/password-login');
const consentCalls = calls.filter(call => call.url === '/consents');
assert.equal(authCalls.length, 1, 'manual retry must not authenticate again');
assert.equal(consentCalls.length, 3, 'one failed grant, its retry and privacy confirmation');
assert.equal(consentCalls[0].payload.requestId, consentCalls[1].payload.requestId, 'unknown result must use the original key');
assert.notEqual(consentCalls[1].payload.requestId, consentCalls[2].payload.requestId);
assert.deepEqual(redirects, ['/pages/home/home']);
});
await test('unpublished agreements allow existing password login without manufacturing consent records', async () => {
session = null; calls = []; redirects.length = 0;
reply = url => {
if (url.startsWith('/agreements/')) throw new ApiError('not published', 404, 'AGREEMENT_NOT_AVAILABLE');
if (url === '/auth/password-login') return { token: 'synthetic-existing-token', userId: '1', expiresAt: '2099-01-01T00:00:00Z' };
throw Error('No consent write is allowed before publication: ' + url);
};
const page = loginPage(); await pageShow();
page.phone.value = '13800000000'; page.password.value = 'synthetic-password';
await page.login();
assert.equal(calls.filter(call => call.url === '/auth/password-login').length, 1);
assert.equal(calls.filter(call => call.url === '/consents').length, 0);
assert.deepEqual(redirects, ['/pages/home/home']);
});
await test('network failure loading agreements is never treated as not published', async () => {
session = null; calls = []; redirects.length = 0;
reply = () => { throw new Error('synthetic network failure'); };
const page = loginPage(); await pageShow();
page.phone.value = '13800000000'; page.password.value = 'synthetic-password'; page.agreed.value = true;
await page.login();
assert.equal(calls.filter(call => call.url.startsWith('/auth/')).length, 0);
assert.equal(redirects.length, 0);
assert.ok(page.errorMessage.value.length > 0);
});
await test('an account change while confirmation is pending never grants the previous choice to the new account', async () => {
session = null; calls = []; redirects.length = 0;
reply = (url, method, payload) => {
if (url === '/agreements/terms') return terms;
if (url === '/agreements/privacy') return { ...terms, revisionId: '2', code: 'privacy', scope: 'account-privacy' };
if (url === '/auth/password-login') return { token: 'synthetic-login-token', userId: '1', expiresAt: '2099-01-01T00:00:00Z' };
if (url === '/consents') throw new Error('synthetic lost receipt');
throw Error('Unexpected account-change request ' + url);
};
const page = loginPage(); await pageShow(); page.phone.value = '13800000000'; page.password.value = 'synthetic-password'; page.toggleAgreement();
await page.login();
const writes = calls.filter(call => call.url === '/consents').length;
session = json({ token: 'synthetic-other-token', userId: '2' });
await page.login();
assert.equal(calls.filter(call => call.url === '/consents').length, writes);
assert.equal(page.agreed.value, false, 'old account checkbox must be cleared');
assert.equal(redirects.length, 0);
});
await test('late public agreement responses cannot replace a newer loaded version', async () => {
session = null; calls = [];
let release;
reply = () => new Promise(resolve => { release = resolve; });
const page = componentPage('pages/agreement/agreement.uvue', 'reload, agreement, available, loading, error');
pageLoad({ code: 'terms' }); pageShow(); await flush();
assert.equal(typeof release, 'function');
reply = () => ({ ...terms, revisionId: '3', version: '2026.10-v2', content: '新合成正文' });
await page.reload();
release(terms); await flush();
assert.equal(page.agreement.value.version, '2026.10-v2');
assert.equal(page.agreement.value.content, '新合成正文');
assert.equal(page.available.value, true);
});
await test('an agreement response arriving after leaving cannot fill the hidden page', async () => {
session = null;
let release; reply = () => new Promise(resolve => { release = resolve; });
const page = componentPage('pages/agreement/agreement.uvue', 'agreement, available, error');
pageLoad({ code: 'terms' }); pageShow(); await flush(); pageHide(); release(terms); await flush();
assert.equal(page.available.value, false);
assert.equal(page.agreement.value.content, '');
assert.equal(page.error.value, '');
});
await test('optional revocation retries an unknown receipt with the original request key', async () => {
session = json({ token: 'synthetic-token-a', userId: '1' }); calls = [];
const item = { ...consent, scope: 'documents', agreementCode: 'document-use', canRevoke: true, revokedAt: null };
let failed = false;
reply = (url, method) => {
if (url === '/consents' && method === 'GET') return { items: [item], page: 1, pageSize: 20, total: 1, hasMore: false };
if (url === '/consents/9/revoke') {
if (!failed) { failed = true; throw Error('synthetic lost revoke receipt'); }
return { ...item, revokedAt: '2026-10-12T01:00:00Z', canRevoke: false };
}
throw Error('Unexpected revocation request ' + url);
};
const page = componentPage('packages/account/consents/consents.uvue', 'records, pending, actionError, confirmRevoke, retryRevoke');
pageShow(); await flush(); assert.equal(page.records.value.length, 1);
page.confirmRevoke(page.records.value[0]); await flush();
assert.ok(page.pending.value); assert.ok(page.actionError.value.length > 0);
await page.retryRevoke();
const writes = calls.filter(call => call.url === '/consents/9/revoke');
assert.equal(writes.length, 2);
assert.equal(writes[0].payload.requestId, writes[1].payload.requestId);
assert.equal(page.records.value[0].revokedAt, '2026-10-12T01:00:00Z');
assert.equal(page.pending.value, null);
});
await test('late consent records from a former account never enter the new account view', async () => {
session = json({ token: 'synthetic-token-a', userId: '1' }); redirects.length = 0;
let release; reply = () => new Promise(resolve => { release = resolve; });
const page = componentPage('packages/account/consents/consents.uvue', 'records, error');
pageShow(); await flush(); session = json({ token: 'synthetic-token-b', userId: '2' });
release({ items: [consent], page: 1, pageSize: 20, total: 1, hasMore: false }); await flush();
assert.equal(page.records.value.length, 0); assert.equal(page.error.value, '');
assert.deepEqual(redirects, ['/packages/account/profile/profile']);
});
await test('pagination never renders duplicate consent ids after records shift between requests', async () => {
session = json({ token: 'synthetic-token-a', userId: '1' });
const first = Array.from({ length: 20 }, (_, index) => ({ ...consent, consentId: String(index + 1) }));
reply = (url, method, payload) => payload.page === '1'
? { items: first, page: 1, pageSize: 20, total: 21, hasMore: true }
: { items: [{ ...consent, consentId: '20' }, { ...consent, consentId: '21' }], page: 2, pageSize: 20, total: 21, hasMore: false };
const page = componentPage('packages/account/consents/consents.uvue', 'records, loadMore');
pageShow(); await flush(); page.loadMore(); await flush();
assert.equal(page.records.value.length, 21);
assert.equal(new Set(page.records.value.map(item => item.consentId)).size, 21);
});
await test('a list started before revocation cannot restore the revoked consent after its receipt', async () => {
session = json({ token: 'synthetic-token-a', userId: '1' });
const item = { ...consent, scope: 'documents', agreementCode: 'document-use', canRevoke: true, revokedAt: null };
let listCount = 0, release, releaseRevoke, revoked = false;
reply = (url, method) => {
if (url === '/consents' && method === 'GET') {
listCount++;
const currentItem = revoked ? { ...item, canRevoke: false, revokedAt: '2026-10-12T01:00:00Z' } : { ...item };
const result = { items: [currentItem], page: 1, pageSize: 20, total: 1, hasMore: false };
if (listCount === 2) return new Promise(resolve => { release = () => resolve(result); });
return result;
}
if (url === '/consents/9/revoke') return new Promise(resolve => { releaseRevoke = () => { revoked = true; resolve({ ...item, canRevoke: false, revokedAt: '2026-10-12T01:00:00Z' }); }; });
throw Error('Unexpected revoke/list race request ' + url);
};
const page = componentPage('packages/account/consents/consents.uvue', 'records, loading, reload, confirmRevoke');
pageShow(); await flush(); page.confirmRevoke(page.records.value[0]); await flush(); page.reload(); await flush();
assert.equal(page.loading.value, true);
releaseRevoke(); await flush(); release(); await flush();
const visible = page.records.value.find(row => row.consentId === '9');
assert.ok(visible, 'receipt should retain the record while invalidating its in-flight refresh');
assert.equal(visible.revokedAt, '2026-10-12T01:00:00Z');
assert.equal(visible.canRevoke, false);
assert.equal(page.loading.value, false);
});
await test('returning to an optional page settles a revoked original grant and waits for a new explicit click', async () => {
session = json({ token: 'synthetic-token-a', userId: '1' }); calls = [];
const agreement = { ...terms, code: 'document-use', scope: 'documents' };
const original = { ...consent, agreementCode: 'document-use', scope: 'documents', revokedAt: null, canRevoke: true };
let originalKey = '', revoked = false;
reply = (url, method, payload) => {
if (url === '/agreements/document-use') return agreement;
if (url === '/consents' && method === 'POST') {
if (!originalKey) { originalKey = payload.requestId; throw Error('synthetic accepted but lost receipt'); }
if (payload.requestId === originalKey) return { ...original, revokedAt: revoked ? '2026-10-12T01:00:00Z' : null, canRevoke: !revoked };
return { ...original, consentId: '10' };
}
throw Error('Unexpected optional replay request ' + url);
};
const page = componentPage('pages/agreement/agreement.uvue', 'confirmConsent, pendingId, granted, actionMessage');
pageLoad({ code: 'document-use' }); pageShow(); await flush(); await page.confirmConsent();
assert.ok(page.pendingId.value);
pageHide(); revoked = true; pageShow(); await flush();
assert.equal(calls.filter(call => call.url === '/consents').length, 1, 'return must not automatically submit another grant');
await page.confirmConsent();
assert.equal(page.pendingId.value, '', 'revoked replay settles the pending command');
assert.equal(page.granted.value, false, 'revoked original receipt must not show currently granted');
assert.match(page.actionMessage.value, /此前同意已撤销/);
await page.confirmConsent();
const writes = calls.filter(call => call.url === '/consents');
assert.equal(writes.length, 3);
assert.equal(writes[0].payload.requestId, writes[1].payload.requestId);
assert.notEqual(writes[1].payload.requestId, writes[2].payload.requestId);
assert.equal(page.granted.value, true);
});
await test('returning after revoking a successful optional grant restores an explicit fresh consent action', async () => {
session = json({ token: 'synthetic-token-a', userId: '1' }); calls = [];
const agreement = { ...terms, code: 'document-use', scope: 'documents' };
reply = (url, method) => {
if (url === '/agreements/document-use') return agreement;
if (url === '/consents' && method === 'POST') return { ...consent, agreementCode: 'document-use', scope: 'documents', revokedAt: null, canRevoke: true };
throw Error('Unexpected successful-grant return request ' + url);
};
const page = componentPage('pages/agreement/agreement.uvue', 'confirmConsent, pendingId, granted');
pageLoad({ code: 'document-use' }); pageShow(); await flush(); await page.confirmConsent();
assert.equal(page.granted.value, true);
assert.equal(page.pendingId.value, '');
pageHide(); pageShow(); await flush();
assert.equal(page.granted.value, false, 'return must expose an explicit consent action instead of retaining old success state');
assert.equal(calls.filter(call => call.url === '/consents').length, 1, 'return must not automatically regrant');
await page.confirmConsent();
const writes = calls.filter(call => call.url === '/consents');
assert.equal(writes.length, 2);
assert.notEqual(writes[0].payload.requestId, writes[1].payload.requestId);
});
console.log(checks + ' checks passed; no network calls.');
})().catch(error => { console.error(error); process.exitCode = 1; });
+48
View File
@@ -0,0 +1,48 @@
// Execute the UTS service in memory; no network or real local files.
const fs=require('node:fs'),vm=require('node:vm'),assert=require('node:assert/strict');
const ts=require('D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dcloudio/uni-uts-v1/lib/typescript/lib/typescript.js');
class Json {constructor(v={}){Object.assign(this,v)}getString(k){return typeof this[k]==='string'?this[k]:null}getNumber(k){return typeof this[k]==='number'?this[k]:null}getBoolean(k){return typeof this[k]==='boolean'?this[k]:null}getArray(k){return Array.isArray(this[k])?this[k]:null}}
let owner=new Json({token:'synthetic-a',userId:'1'}),calls=[],download,upload,reply,listReply=async()=>({documents:[]});
const api={API_BASE_URL:'https://synthetic.invalid/api/v1',getSession:()=>owner,clearSession:()=>{owner=null},uploadJson:async(...args)=>{calls.push(args);return new Json(await upload(...args))},requestJson:async(...args)=>{calls.push(args);return new Json(await (args[0]==='/consents'&&args[1]==='GET'?listReply:reply)(...args))}};
const uni={downloadFile:options=>{download=options},getFileSystemManager:()=>({unlink:options=>{calls.push(['unlink',options.filePath]);options.success?.({})}})};
const modules={};const path=require('node:path');
function load(name){if(modules[name])return modules[name];const exports={};modules[name]=exports;vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(__dirname,'..',name),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText,{exports,require:p=>p==='@/services/api.uts'||p==='./api.uts'?api:load(p.startsWith('@/')?p.slice(2):path.posix.normalize(path.posix.dirname(name)+'/'+p)),uni,UTSJSONObject:Json,Error,Number,Date,Promise,URL},{filename:name});return exports}
let hooks={},picker,sheet,navigated,preview;
function page(){hooks={};const exports={};const source=fs.readFileSync(path.join(__dirname,'../packages/finance/assessment/assessment.uvue'),'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
vm.runInNewContext(ts.transpileModule(source+'\nexports.page={documents,documentConsent,documentBusy,documentMessage,step,form,chooseDocument,uploadSelectedDocument,previewDocument,removeDocument,documentAction,toggleDocumentConsent,validateStep,showAgreement,next,restoreDocuments,documentPath};',{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText,{exports,require:p=>p==='@/services/api.uts'?api:p==='@/services/layout.uts'?{usePageLayout:()=>({bottomStyle:()=>'',contentStyle:()=>'',pageStyle:()=>''})}:p.endsWith('.uvue')?{}:load(p.slice(2)),UTSJSONObject:Json,Error,Date,Math,Number,uni:Object.assign(uni,{showToast:()=>{},redirectTo:()=>{},navigateTo:o=>{navigated=o.url},chooseImage:o=>{picker=o},showActionSheet:o=>{sheet=o},previewImage:o=>{preview=o.urls},getCurrentPages:()=>[]}),getCurrentPages:()=>[],ref:value=>({value}),computed:f=>({get value(){return f()}}),onLoad:f=>hooks.load=f,onShow:f=>hooks.show=f,onHide:f=>hooks.hide=f,onUnload:f=>hooks.unload=f,onBackPress:()=>{},setInterval:()=>1,clearInterval:()=>{}},{filename:'assessment.uvue'});
hooks.load({});hooks.show();return exports.page;
}
const flush=async()=>{for(let i=0;i<18;i++)await Promise.resolve()};
const agreement={revisionId:'1',code:'document-use',version:'synthetic-v1',scope:'documents',title:'合成协议',content:'仅供测试',contentHash:'a'.repeat(64),effectiveAt:'2026-01-01T00:00:00Z'};
function consentReply(url){if(url==='/agreements/document-use')return agreement;return {consentId:'9',scope:'documents',agreementCode:'document-use',agreementVersion:'synthetic-v1',contentHash:'a'.repeat(64),grantedAt:'2026-01-01T00:00:00Z',revokedAt:null,canRevoke:true}}
let checks=0;async function test(name,f){await f();checks++;console.log('PASS '+name)}
(async()=>{
const s=load('services/documents.uts');
await test('saved IDs recover after leaving flow, including duplicates and pending deletion',async()=>{owner=new Json({token:'synthetic-a',userId:'1'});reply=async url=>consentReply(url);let p=page();await flush();p.documents.value.push({name:'身份证正面',path:'',documentId:'7',consentId:'9',deletePending:false});hooks.hide();hooks.unload();listReply=async()=>({documents:[new Json({documentId:'8',category:'id-front',consentId:'9',deletePending:true}),new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false}),new Json({documentId:'10',category:'business-license',consentId:'9',deletePending:false})]});p=page();await flush();assert.equal(p.documents.value.length,3);assert.equal(p.documents.value[0].deletePending,true);reply=async()=>({documentId:'8',deleted:true});await p.removeDocument('身份证正面','8');assert.equal(p.documents.value.length,2);assert.equal(p.documents.value[0].documentId,'7');listReply=async()=>({documents:[]})});
await test('recovery validates IDs, categories and forbids storage paths',async()=>{for(const item of [{documentId:'/tmp/x',category:'id-front',consentId:'9',deletePending:false},{documentId:'7',category:'unknown',consentId:'9',deletePending:false},{documentId:'7',category:'id-front',consentId:'9',deletePending:false,path:'/private/x'}]){listReply=async()=>({documents:[new Json(item)]});await assert.rejects(s.fetchOwnedDocuments())}listReply=async()=>({documents:[]})});
await test('late recovery after owner change is refused',async()=>{let done;listReply=()=>new Promise(r=>done=r);const pending=s.fetchOwnedDocuments();owner=new Json({token:'synthetic-b',userId:'2'});done({documents:[]});await assert.rejects(pending);owner=new Json({token:'synthetic-a',userId:'1'});listReply=async()=>({documents:[]})});
await test('recovery failure is retryable without losing unsent local photo',async()=>{reply=async url=>consentReply(url);const p=page();await flush();p.documents.value.push({name:'收入证明',path:'C:/local-pending.png',documentId:'',consentId:'',deletePending:false});listReply=async()=>{throw new Error('synthetic recovery failure')};await p.restoreDocuments();assert.ok(p.documentMessage.value.includes('recovery failure'));assert.equal(p.documents.value[0].path,'C:/local-pending.png');listReply=async()=>({documents:[new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false})]});await p.restoreDocuments();assert.equal(p.documents.value.length,2);assert.equal(p.documents.value.find(x=>x.documentId==='').path,'C:/local-pending.png');listReply=async()=>({documents:[]})});
await test('late recovery after leaving page never repopulates hidden form',async()=>{let done;listReply=()=>new Promise(r=>done=r);const p=page();hooks.hide();done({documents:[new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false})]});await flush();assert.equal(p.documents.value.length,0);listReply=async()=>({documents:[]})});
await test('recovered remote same-category photo does not trap unsent local selection',async()=>{reply=async url=>consentReply(url);const p=page();await flush();p.documents.value.push({name:'身份证正面',path:'C:/unknown-receipt.png',documentId:'',consentId:'',deletePending:false});listReply=async()=>({documents:[new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false})]});await p.restoreDocuments();p.documentAction('身份证正面');assert.equal(sheet.itemList[2],'移除未上传照片');sheet.success({tapIndex:2});assert.equal(p.documents.value.length,1);assert.equal(p.documents.value[0].documentId,'7');p.step.value=3;assert.equal(p.validateStep(),true);listReply=async()=>({documents:[]})});
await test('uploaded photo stays visible in its original card',async()=>{reply=async url=>consentReply(url);const p=page();await flush();await p.toggleDocumentConsent();upload=async()=>({documentId:'7',category:'id-front',mimeType:'image/png',byteSize:10});p.chooseDocument('身份证正面');picker.success({tempFilePaths:['C:/selected.png']});await flush();assert.equal(p.documentPath('身份证正面'),'C:/selected.png')});
await test('same category saved photos remain individually deletable through original card',async()=>{reply=async url=>consentReply(url);listReply=async()=>({documents:[new Json({documentId:'8',category:'id-front',consentId:'9',deletePending:false}),new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false})]});const p=page();await flush();p.documentAction('身份证正面');assert.equal(sheet.itemList.length,2);assert.ok(sheet.itemList[0].includes('照片 1'));sheet.success({tapIndex:1});assert.equal(sheet.itemList[1],'删除照片');reply=async()=>({documentId:'7',deleted:true});sheet.success({tapIndex:1});await flush();assert.equal(p.documents.value.length,1);assert.equal(p.documents.value[0].documentId,'8');listReply=async()=>({documents:[]})});
await test('restored card thumbnail uses protected download and late thumbnail is cleaned',async()=>{reply=async url=>consentReply(url);const p=page();await flush();p.step.value=3;listReply=async()=>({documents:[new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false})]});await p.restoreDocuments();assert.ok(download.url.endsWith('/documents/7/content'));download.success({statusCode:200,tempFilePath:'/tmp/recovered-thumb.png'});await flush();assert.equal(p.documentPath('身份证正面'),'/tmp/recovered-thumb.png');await p.restoreDocuments();hooks.hide();download.success({statusCode:200,tempFilePath:'/tmp/late-thumb.png'});await flush();assert.equal(p.documents.value.length,0);assert.ok(calls.some(c=>c[0]==='unlink'&&c[1]==='/tmp/late-thumb.png'));listReply=async()=>({documents:[]})});
await test('late automatic thumbnail cannot delete the current manual preview',async()=>{reply=async url=>consentReply(url);const p=page();await flush();p.step.value=3;listReply=async()=>({documents:[new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false})]});await p.restoreDocuments();const automatic=download;const manual=p.previewDocument('身份证正面','7');download.success({statusCode:200,tempFilePath:'/tmp/manual-preview.png'});await manual;const count=calls.length;automatic.success({statusCode:200,tempFilePath:'/tmp/automatic-thumb.png'});await flush();assert.equal(p.documentPath('身份证正面'),'/tmp/manual-preview.png');assert.ok(!calls.slice(count).some(c=>c[0]==='unlink'&&c[1]==='/tmp/manual-preview.png'));assert.ok(calls.slice(count).some(c=>c[0]==='unlink'&&c[1]==='/tmp/automatic-thumb.png'));listReply=async()=>({documents:[]})});
await test('many recovered photos stay manageable within native action-sheet limits',async()=>{reply=async url=>consentReply(url);listReply=async()=>({documents:Array.from({length:7},(_,i)=>new Json({documentId:String(i+1),category:'id-front',consentId:'9',deletePending:false}))});const p=page();await flush();p.documentAction('身份证正面');assert.ok(sheet.itemList.length<=6);assert.equal(sheet.itemList.at(-1),'更多照片');sheet.success({tapIndex:sheet.itemList.length-1});assert.ok(sheet.itemList[1].includes('照片 7'));sheet.success({tapIndex:1});reply=async()=>({documentId:'7',deleted:true});sheet.success({tapIndex:1});await flush();assert.equal(p.documents.value.length,6);assert.ok(!p.documents.value.some(x=>x.documentId==='7'));listReply=async()=>({documents:[]})});
await test('valid server receipt required; local path is not ID',async()=>{upload=async()=>({documentId:'C:/photo.jpg',category:'id-front',mimeType:'image/png',byteSize:10});await assert.rejects(s.uploadDocument('C:/local.jpg','id-front','9'))});
await test('multipart receives category and consent ID',async()=>{upload=async()=>({documentId:'7',category:'id-front',mimeType:'image/png',byteSize:10});const r=await s.uploadDocument('C:/local.jpg','id-front','9');assert.equal(r,'7');assert.equal(calls.at(-1)[2].consentId,'9')});
await test('unknown or public receipts refused',async()=>{upload=async()=>({documentId:'7',category:'income',mimeType:'image/png',byteSize:10,url:'/uploads/x'});await assert.rejects(s.uploadDocument('x','id-front','9'))});
await test('protected download carries bearer without URL token',async()=>{const p=s.downloadDocument('7');assert.equal(download.header.Authorization,'Bearer synthetic-a');assert.equal(download.url,'https://synthetic.invalid/api/v1/documents/7/content');download.success({statusCode:200,tempFilePath:'/tmp/private.png'});assert.equal(await p,'/tmp/private.png')});
await test('late download after account switch discarded and temp cleaned',async()=>{const p=s.downloadDocument('7');owner=new Json({token:'synthetic-b',userId:'2'});download.success({statusCode:200,tempFilePath:'/tmp/late.png'});await assert.rejects(p);assert.ok(calls.some(c=>c[0]==='unlink'&&c[1]==='/tmp/late.png'))});
await test('old 401 cannot clear new session',async()=>{owner=new Json({token:'synthetic-a',userId:'1'});const p=s.downloadDocument('7');owner=new Json({token:'synthetic-b',userId:'2'});download.success({statusCode:401,tempFilePath:'/tmp/error'});await assert.rejects(p);assert.equal(owner.userId,'2')});
await test('logout during upload cannot accept receipt',async()=>{owner=new Json({token:'synthetic-a',userId:'1'});let done;upload=()=>new Promise(r=>done=r);const p=s.uploadDocument('x','id-front','9');owner=null;done({documentId:'7',category:'id-front',mimeType:'image/png',byteSize:10});await assert.rejects(p)});
await test('delete only accepts same ID confirmed true',async()=>{owner=new Json({token:'synthetic-a',userId:'1'});reply=async()=>({documentId:'8',deleted:true});await assert.rejects(s.deleteDocument('7'));reply=async()=>({documentId:'7',deleted:false});await assert.rejects(s.deleteDocument('7'));reply=async()=>({documentId:'7',deleted:true});await s.deleteDocument('7')});
await test('not logged in means no download/upload/delete dispatch',async()=>{owner=null;const n=calls.length;await assert.rejects(s.uploadDocument('x','id-front','9'));await assert.rejects(s.downloadDocument('7'));await assert.rejects(s.deleteDocument('7'));assert.equal(calls.length,n)});
await test('card requires server consent before picker; failed upload remains retryable',async()=>{owner=new Json({token:'synthetic-a',userId:'1'});reply=async url=>consentReply(url);const p=page();await flush();picker=null;p.chooseDocument('身份证正面');assert.equal(picker,null);await p.toggleDocumentConsent();upload=async()=>{throw new Error('synthetic network loss')};p.chooseDocument('身份证正面');picker.success({tempFilePaths:['C:/local.png']});await flush();assert.equal(p.documents.value[0].documentId,'');assert.equal(p.documents.value[0].path,'C:/local.png');p.step.value=3;assert.equal(p.validateStep(),false);upload=async()=>({documentId:'7',category:'id-front',mimeType:'image/png',byteSize:10});await p.uploadSelectedDocument('身份证正面');assert.equal(p.documents.value[0].documentId,'7');assert.equal(p.validateStep(),true)});
await test('delete failure keeps original ID and forbids preview until explicit retry',async()=>{reply=async url=>consentReply(url);const p=page();await flush();p.documents.value.push({name:'身份证正面',path:'',documentId:'7',consentId:'9',deletePending:false});reply=async()=>{throw new Error('synthetic delete failure')};await p.removeDocument('身份证正面','');assert.equal(p.documents.value[0].documentId,'7');assert.equal(p.documents.value[0].deletePending,true);download=null;await p.previewDocument('身份证正面','');assert.equal(download,null);reply=async()=>({documentId:'7',deleted:true});await p.removeDocument('身份证正面','');assert.equal(p.documents.value.length,0)});
await test('late upload after leaving page never restores card',async()=>{reply=async url=>consentReply(url);const p=page();await flush();await p.toggleDocumentConsent();let done;upload=()=>new Promise(r=>done=r);p.chooseDocument('身份证正面');picker.success({tempFilePaths:['C:/local.png']});hooks.hide();done({documentId:'7',category:'id-front',mimeType:'image/png',byteSize:10});await flush();assert.equal(p.documents.value.length,0)});
await test('late download after leaving page never previews and cleans temp',async()=>{const p=page();await flush();p.documents.value.push({name:'身份证正面',path:'',documentId:'7',consentId:'9',deletePending:false});const pending=p.previewDocument('身份证正面','');hooks.hide();preview=null;download.success({statusCode:200,tempFilePath:'/tmp/late-page.png'});await pending;assert.equal(preview,null);assert.ok(calls.some(c=>c[0]==='unlink'&&c[1]==='/tmp/late-page.png'))});
await test('viewing agreement preserves form and server IDs; temps are not put into flow',async()=>{const p=page();await flush();p.form.value.name='合成姓名';p.documents.value.push({name:'身份证正面',path:'/tmp/private.png',documentId:'7',consentId:'9',deletePending:false});p.showAgreement('资料使用协议');hooks.hide();hooks.show();assert.equal(p.form.value.name,'合成姓名');assert.equal(p.documents.value[0].documentId,'7');assert.equal(p.documents.value[0].path,'');const saved=load('services/assessment-flow.uts').readAssessmentFlow('synthetic-a');assert.equal(saved.documents[0].path,'')});
await test('uploaded photos stay out of assessment JSON; local paths and external grants refused',async()=>{const records=load('packages/finance/services/records.uts');const payload=new Json({...load('services/assessment.uts').emptyAssessmentForm(),type:'personal',documents:[new Json({name:'身份证正面',path:'C:/private.png',documentId:'7',consentId:'9',deletePending:false})],authorizations:[]});reply=async()=>({assessmentId:'2',revisionId:'3',submissionStatus:'accepted'});await records.createAssessment(payload);const body=calls.at(-1)[2];assert.equal(body.documents.length,0);assert.equal(body.consentIds.length,0);assert.ok(!JSON.stringify(body).includes('private.png'));payload.documents=[new Json({path:'C:/local.png'})];await assert.rejects(records.createAssessment(payload));payload.documents=[];payload.authorizations=['credit'];await assert.rejects(records.createAssessment(payload))});
console.log('PASS: '+checks+' private document service scenarios');
})().catch(e=>{console.error(e.message);process.exitCode=1});
+1
View File
@@ -49,6 +49,7 @@ const vendor = {
UTSJSONObject: JsonObject, UTS: { UTSType: UtsType },
defineComponent: component => component,
ref: value => ({ value }), watch: () => {}, onMounted: () => {}, onUnmounted: () => {},
onShow: () => {}, onHide: () => {}, onUnload: () => {},
onLoad: callback => { onLoad = callback; },
index: {
getWindowInfo: () => ({ windowWidth: 390, windowHeight: 844, statusBarHeight: 24 }),
+40
View File
@@ -0,0 +1,40 @@
// Pure UTS data-service regression; no network, account or model access.
const fs = require('node:fs'), vm = require('node:vm'), path = require('node:path'), assert = require('node:assert/strict');
const ts = require('D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dcloudio/uni-uts-v1/lib/typescript/lib/typescript.js');
class Json { constructor(v = {}) { for (const [k,x] of Object.entries(v)) this[k] = Array.isArray(x) ? x.map(wrap) : wrap(x); } getString(k) { return typeof this[k] === 'string' ? this[k] : null; } getNumber(k) {return typeof this[k] === 'number' ? this[k] : null;} getBoolean(k) {return typeof this[k] === 'boolean' ? this[k] : null;} }
function wrap(x) { return x && typeof x === 'object' && !Array.isArray(x) ? new Json(x) : x; }
let reply;
const api = {requestJson:async()=>new Json(reply)};
function load(file) {
const exports = {};
const source = fs.readFileSync(path.join(__dirname,'..',file),'utf8').replace(/const (fieldNames|labels): UTSJSONObject = (.*)/g,'const $1: UTSJSONObject = new UTSJSONObject($2)');
vm.runInNewContext(ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText,
{exports,UTSJSONObject:Json,require:p=>p.endsWith('api.uts')?api:p.endsWith('config.uts')?{API_BASE_URL:'https://synthetic.invalid/api/v1'}:p.endsWith('records.uts')?{revisionParams:v=>({revisionId:v})}:load('services/money.uts'),Error,Number,Math},{filename:file});
return exports;
}
function fixture() {
return {assessmentId:'1',revisionId:'2',resultVersion:3,matching:{status:'completed',catalogSnapshotId:'synthetic-hash',ruleVersion:'product-matching-v2',orderingPolicyVersion:'product-score-descending-v1'},item:{productId:'4',productVersion:7,eligibility:'eligible',score:92,scorePolicyVersion:'product-soft-score-v1',rank:1,currentAvailability:'available',positiveReasons:[],blockingReasons:[],cautions:[],evidence:[],aiExplanation:{status:'not_requested'},productSnapshot:{productId:'4',productVersion:7,productName:'合成产品',bankName:'合成机构',productType:'信用贷',amountMinYuan:'500000.00',amountMaxYuan:'3000000.00',annualRateMinPercent:'3.60',annualRateMaxPercent:'8.50',termMinMonths:6,termMaxMonths:36,applicantTypes:['enterprise'],regions:[],validFrom:'2026-01-01',validUntil:'2030-01-01',source:{publisher:'合成机构',title:'仅供测试'},rules:[],bankLogo:'/uploads/synthetic.png',consultPhone:'400-123-4567',repaymentMethod:'按月付息',materials:[{name:'营业执照',required:true,note:'准备有效副本',applicantTypes:['enterprise']}]}}};
}
(async()=>{
const service=load('packages/finance/services/matches.uts'); let checks=0;
reply=fixture();let result=await service.fetchMatchDetailRecord('1','4','2');
assert.equal(result.item.score,92);checks++;
assert.equal(result.item.product.amount,'50–300 万元');checks++;
assert.equal(result.item.product.logo,'https://synthetic.invalid/uploads/synthetic.png');checks++;
assert.equal(result.item.product.materialList[0].objects,'企业');checks++;
assert.equal(result.item.product.consultPhone,'400-123-4567');checks++;
assert.equal(result.item.product.repayment,'按月付息');checks++;
reply=fixture();reply.item.productSnapshot.expectation='额度按银行审批';reply.item.productSnapshot.quota='期限按合同';reply.item.productSnapshot.source={};reply.item.productSnapshot.regionMode='unverified';reply.item.productSnapshot.annualRateMinPercent=null;reply.item.productSnapshot.annualRateMaxPercent='8.50';
result=await service.fetchMatchDetailRecord('1','4','2');assert.equal(result.item.product.amount,'额度按银行审批');assert.equal(result.item.product.term,'期限按合同');assert.equal(result.item.product.rate,'最高 8.5%');assert.equal(result.item.product.source,'待核实');assert.equal(result.item.product.regions,'待核实');checks++;
for (const [from,until,want] of [[null,null,'未设置有效期'],[null,'2030-01-01','有效至 2030-01-01'],['2026-01-01',null,'2026-01-01 起生效'],['2026-01-01','2030-01-01','2026-01-01 至 2030-01-01']]) {
reply=fixture();reply.item.productSnapshot.validFrom=from;reply.item.productSnapshot.validUntil=until;
result=await service.fetchMatchDetailRecord('1','4','2');assert.equal(result.item.product.valid,want);checks++;
}
reply=fixture();reply.item.score=null;reply.item.scorePolicyVersion=null;reply.matching.ruleVersion='product-matching-v1';reply.matching.orderingPolicyVersion='product-id-ascending-v1';delete reply.item.productSnapshot.materials;delete reply.item.productSnapshot.bankLogo;
result=await service.fetchMatchDetailRecord('1','4','2');assert.equal(result.item.score,null);assert.equal(result.item.product.materialList.length,0);assert.equal(result.item.product.logo,'');checks++;
for(const [score,policy] of [[101,'product-soft-score-v1'],[-1,'product-soft-score-v1'],[92.2,'product-soft-score-v1'],[80,'unknown-policy']]) {reply=fixture();reply.item.score=score;reply.item.scorePolicyVersion=policy;await assert.rejects(service.fetchMatchDetailRecord('1','4','2'));checks++;}
reply=fixture();reply.item.productSnapshot.bankLogo='javascript:alert(1)';reply.item.productSnapshot.consultPhone='unsafe';result=await service.fetchMatchDetailRecord('1','4','2');assert.equal(result.item.product.logo,'');assert.equal(result.item.product.consultPhone,'');checks++;
reply=fixture();reply.revisionId='3';await assert.rejects(service.fetchMatchDetailRecord('1','4','2'));checks++;
reply=fixture();reply.item.productSnapshot.productId='5';await assert.rejects(service.fetchMatchDetailRecord('1','4','2'));checks++;
console.log('PASS: '+checks+' product presentation UTS checks');
})().catch(e=>{console.error(e);process.exitCode=1;});
+20
View File
@@ -0,0 +1,20 @@
// 主包共享协议路由与会话守卫,账户分包不依赖融资分包。
export function agreementScope(code: string): string {
if (code == 'terms') return 'account-terms'
if (code == 'privacy') return 'account-privacy'
if (code == 'document-use') return 'documents'
if (code == 'data-authorization') return 'data'
return ''
}
export function agreementVersionValid(value: string): boolean { return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value) }
export function agreementRecordIdValid(value: string): boolean {
return /^[1-9][0-9]{0,18}$/.test(value) && !(value.length == 19 && value > '9223372036854775807')
}
export function agreementRoute(code: string, version: string = ''): string {
if (agreementScope(code).length == 0 || (version.length > 0 && !agreementVersionValid(version))) return ''
return '/pages/agreement/agreement?code=' + code + (version.length > 0 ? '&version=' + encodeURIComponent(version) : '')
}
export const optionalConsentScope = (scope: string): boolean => scope == 'documents' || scope == 'data'
export function sameConsentSession(token: string, userId: string, currentToken: string, currentUserId: string): boolean {
return token.length > 0 && userId.length > 0 && token == currentToken && userId == currentUserId
}
+120
View File
@@ -0,0 +1,120 @@
import { ApiRequestError, getSession, requestJson } from './api.uts'
import { agreementScope, agreementVersionValid, agreementRecordIdValid, agreementRoute, optionalConsentScope, sameConsentSession } from './agreements-route.uts'
export { agreementScope, agreementRoute, optionalConsentScope, sameConsentSession } from './agreements-route.uts'
export type Agreement = { revisionId: string, code: string, version: string, scope: string, title: string, content: string, contentHash: string, effectiveAt: string }
export type Consent = { consentId: string, scope: string, agreementCode: string, agreementVersion: string, contentHash: string, grantedAt: string, revokedAt: string, canRevoke: boolean }
export type ConsentPage = { items: Consent[], page: number, pageSize: number, total: number, hasMore: boolean }
const scopes = ['account-terms', 'account-privacy', 'documents', 'data']
const validHash = (value: string): boolean => /^[a-f0-9]{64}$/.test(value)
const validTime = (value: string): boolean => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(value) && !isNaN(Date.parse(value))
const validKey = (value: string): boolean => /^[A-Za-z0-9_-]{16,64}$/.test(value)
export function consentRequestId(): string { return 'consent_' + Date.now().toString() + '_' + Math.floor(Math.random() * 1000000000).toString() }
export function emptyAgreement(): Agreement { return { revisionId: '', code: '', version: '', scope: '', title: '', content: '', contentHash: '', effectiveAt: '' } }
export function agreementUnavailable(error: any | null): boolean { return error instanceof ApiRequestError && error.status == 404 && error.errorCode == 'AGREEMENT_NOT_AVAILABLE' }
export function agreementChanged(error: any | null): boolean { return error instanceof ApiRequestError && error.status == 409 && error.errorCode == 'AGREEMENT_CHANGED' }
export const consentTitle = (code: string): string => {
if (code == 'terms') return '用户协议'
if (code == 'privacy') return '隐私政策'
if (code == 'document-use') return '资料使用协议'
if (code == 'data-authorization') return '数据授权协议'
return '协议'
}
export const consentTime = (value: string): string => value.length == 0 ? '' : value.replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC').replace('Z', ' UTC')
function validateAgreement(item: Agreement): void {
if (!agreementRecordIdValid(item.revisionId) || agreementScope(item.code).length == 0 || item.scope != agreementScope(item.code)
|| !agreementVersionValid(item.version) || !validHash(item.contentHash) || item.title.trim().length == 0
|| item.content.trim().length == 0 || !validTime(item.effectiveAt)) throw new Error('协议响应不完整,请重新加载')
}
function agreementView(data: UTSJSONObject): Agreement {
const item: Agreement = { revisionId: data.getString('revisionId') ?? '', code: data.getString('code') ?? '', version: data.getString('version') ?? '',
scope: data.getString('scope') ?? '', title: data.getString('title') ?? '', content: data.getString('content') ?? '',
contentHash: data.getString('contentHash') ?? '', effectiveAt: data.getString('effectiveAt') ?? '' }
validateAgreement(item)
return item
}
function consentView(data: UTSJSONObject): Consent {
const revokedValue = data['revokedAt']
if (revokedValue != null && typeof revokedValue != 'string') throw new Error('同意记录撤销时间无效')
const revokedAt = revokedValue == null ? '' : revokedValue as string
const flag = data.getBoolean('canRevoke')
const item: Consent = { consentId: data.getString('consentId') ?? '', scope: data.getString('scope') ?? '', agreementCode: data.getString('agreementCode') ?? '',
agreementVersion: data.getString('agreementVersion') ?? '', contentHash: data.getString('contentHash') ?? '',
grantedAt: data.getString('grantedAt') ?? '', revokedAt, canRevoke: flag ?? false }
if (!agreementRecordIdValid(item.consentId) || agreementScope(item.agreementCode).length == 0 || item.scope != agreementScope(item.agreementCode)
|| !agreementVersionValid(item.agreementVersion) || !validHash(item.contentHash) || !validTime(item.grantedAt)
|| (revokedAt.length > 0 && !validTime(revokedAt)) || flag == null
|| (item.canRevoke && (!optionalConsentScope(item.scope) || revokedAt.length > 0))) throw new Error('同意记录响应不完整,请重新加载')
return item
}
function sessionOwner(): UTSJSONObject {
const session = getSession()
if (session == null) throw new Error('请先登录后管理同意记录')
return session
}
function checkOwner(owner: UTSJSONObject): void {
const current = getSession()
if (!sameConsentSession(owner.getString('token') ?? '', owner.getString('userId') ?? '', current?.getString('token') ?? '', current?.getString('userId') ?? ''))
throw new Error('登录状态已变化,请重新操作')
}
export async function fetchAgreement(code: string, version: string = ''): Promise<Agreement> {
if (agreementRoute(code, version).length == 0) throw new Error('协议参数无效,请返回重新选择')
const query: UTSJSONObject = {}
if (version.length > 0) query['version'] = version
const item = agreementView(await requestJson('/agreements/' + code, 'GET', query))
if (item.code != code || (version.length > 0 && item.version != version)) throw new Error('协议版本不一致,请重新加载')
return item
}
export async function grantConsent(agreement: Agreement, requestId: string): Promise<Consent> {
const owner = sessionOwner()
validateAgreement(agreement)
if (!validKey(requestId)) throw new Error('同意请求标识无效')
const item = consentView(await requestJson('/consents', 'POST', { scope: agreement.scope, agreementCode: agreement.code, agreementVersion: agreement.version, requestId }))
checkOwner(owner)
if (item.scope != agreement.scope || item.agreementCode != agreement.code || item.agreementVersion != agreement.version || item.contentHash != agreement.contentHash
|| (item.revokedAt.length > 0 && !optionalConsentScope(item.scope)))
throw new Error('同意回执未能核对,请用原请求重试')
return item
}
export async function fetchConsents(scope: string = '', page: number = 1, pageSize: number = 20): Promise<ConsentPage> {
const owner = sessionOwner()
if ((scope.length > 0 && !scopes.includes(scope)) || !Number.isInteger(page) || page < 1 || page > 1000 || !Number.isInteger(pageSize) || pageSize < 1 || pageSize > 50)
throw new Error('同意记录查询参数无效')
const query: UTSJSONObject = { page: page.toString(), pageSize: pageSize.toString() }
if (scope.length > 0) query['scope'] = scope
const data = await requestJson('/consents', 'GET', query)
checkOwner(owner)
const raw = data['items']; const total = data.getNumber('total'); const hasMore = data.getBoolean('hasMore')
if (raw == null || !Array.isArray(raw) || data.getNumber('page') != page || data.getNumber('pageSize') != pageSize
|| total == null || !Number.isInteger(total) || total < 0 || hasMore == null) throw new Error('同意记录分页响应无效')
const rows = raw as UTSJSONObject[]
const items = rows.map((row: UTSJSONObject): Consent => {
if (row == null || typeof row != 'object' || Array.isArray(row)) throw new Error('同意记录格式无效')
const item = consentView(row)
if (scope.length > 0 && item.scope != scope) throw new Error('同意记录范围不一致')
return item
})
return { items, page, pageSize, total, hasMore }
}
export async function revokeConsent(consent: Consent, requestId: string): Promise<Consent> {
const owner = sessionOwner()
if (!agreementRecordIdValid(consent.consentId) || !optionalConsentScope(consent.scope) || !consent.canRevoke || consent.revokedAt.length > 0 || !validKey(requestId))
throw new Error('仅可撤销当前有效的可选授权')
const item = consentView(await requestJson('/consents/' + consent.consentId + '/revoke', 'POST', { requestId }))
checkOwner(owner)
if (item.consentId != consent.consentId || item.scope != consent.scope || item.agreementCode != consent.agreementCode
|| item.agreementVersion != consent.agreementVersion || item.contentHash != consent.contentHash || item.grantedAt != consent.grantedAt || item.revokedAt.length == 0 || item.canRevoke)
throw new Error('撤销回执未能核对,请用原请求重试')
return item
}
export function showAgreementNotice(title: string): void {
let code = ''
if (title == '用户协议') code = 'terms'
else if (title == '隐私政策' || title == '隐私说明') code = 'privacy'
else if (title == '资料使用协议') code = 'document-use'
else if (title == '数据授权协议') code = 'data-authorization'
const url = agreementRoute(code)
if (url.length > 0) uni.navigateTo({ url })
}
+4 -4
View File
@@ -100,14 +100,14 @@ const requestJson = (path: string, method: RequestMethod, payload: UTSJSONObject
export { apiMessage, requestJson, wait }
export function uploadJson(path: string, filePath: string): Promise<UTSJSONObject> {
export function uploadJson(path: string, filePath: string, formData: UTSJSONObject = {}, failureMessage: string = '头像上传失败'): Promise<UTSJSONObject> {
return new Promise<UTSJSONObject>((resolve, reject) => {
const sentToken = currentToken()
if (sentToken.length == 0) { reject(new Error('请先登录')); return }
if (API_BASE_URL.length == 0 || (APP_ENVIRONMENT == 'production' && !API_BASE_URL.startsWith('https://'))) {
reject(new Error('服务地址尚未配置')); return
}
uni.uploadFile({ url: API_BASE_URL + path, filePath, name: 'file', timeout: REQUEST_TIMEOUT,
uni.uploadFile({ url: API_BASE_URL + path, filePath, name: 'file', formData, timeout: REQUEST_TIMEOUT,
header: { Authorization: 'Bearer ' + sentToken, 'X-Requested-With': 'XMLHttpRequest' },
success: (response) => {
if (currentToken() != sentToken) { reject(new Error('登录状态已变化,请重新操作')); return }
@@ -117,13 +117,13 @@ export function uploadJson(path: string, filePath: string): Promise<UTSJSONObjec
if (body == null) throw new Error('上传响应格式错误')
if (response.statusCode < 200 || response.statusCode >= 300 || body['code'] != 1) {
if (body['code'] == 401) clearSession()
throw new Error((body['msg'] as string | null) ?? '头像上传失败')
throw new Error((body['msg'] as string | null) ?? failureMessage)
}
const data = body['data'] as UTSJSONObject | null
if (data == null) throw new Error('上传响应不完整')
resolve(data)
} catch (error) { reject(error) }
}, fail: () => { reject(new Error('头像上传失败,请检查网络后重试')) }
}, fail: () => { reject(new Error(failureMessage + ',请检查网络后手动重试')) }
})
})
}
+8 -4
View File
@@ -3,6 +3,7 @@ import { readAssessmentFlow, writeAssessmentFlow, clearAssessmentFlow } from './
export { clearAssessmentFlow } from './assessment-flow.uts'
import { contributionBaseVisible, contributionBaseValid, contributionBaseYuan } from './contribution.uts'
import { wanAmountValid, wanToYuan, wanToCents } from './money.uts'
import { showAgreementNotice as openAgreementNotice } from './agreements.uts'
export type AssessmentForm = {
name: string, idCard: string, phone: string, city: string, maritalStatus: string,
@@ -12,7 +13,7 @@ export type AssessmentForm = {
creditCardCount: string, creditCardTotalLimit: string, creditCardUsedLimit: string,
otherAssets: string, enterpriseName: string, businessCode: string, annualRevenue: string, operatingYears: string, operatingMonths: string
}
export type SelectedDocument = { name: string, path: string }
export type SelectedDocument = { name: string, path: string, documentId: string, consentId: string, deletePending: boolean }
export type DataAuthorization = { key: string, name: string, selected: boolean }
export function emptyAssessmentForm(): AssessmentForm {
@@ -91,12 +92,15 @@ export function documentsFromInput(saved: UTSJSONObject): SelectedDocument[] {
if (items == null) return results
items.forEach((item) => {
const name = draftText(item, 'name'); const path = draftText(item, 'path')
if (name.length > 0 && path.length > 0) { const document: SelectedDocument = { name, path }; results.push(document) }
const documentId = draftText(item, 'documentId'); const consentId = draftText(item, 'consentId')
if (name.length > 0 && (path.length > 0 || documentId.length > 0)) {
const document: SelectedDocument = { name, path, documentId, consentId, deletePending: item.getBoolean('deletePending') ?? false }; results.push(document)
}
})
return results
}
export function setAssessmentInput(type: string, step: number, form: AssessmentForm, documents: SelectedDocument[], authorizations: DataAuthorization[], documentConsent: boolean, dataConsent: boolean, editAssessmentId: string = '', baseRevisionId: string = ''): void {
const files: UTSJSONObject[] = documents.map((item): UTSJSONObject => ({ name: item.name, path: item.path }))
const files: UTSJSONObject[] = documents.map((item): UTSJSONObject => ({ name: item.name, path: '', documentId: item.documentId, consentId: item.consentId, deletePending: item.deletePending }))
const grants: string[] = authorizations.filter((item) => item.selected).map((item) => item.key)
const payload: UTSJSONObject = {
type, step, editAssessmentId, baseRevisionId, moneyUnit: 'wan', name: form.name, idCard: form.idCard, phone: form.phone, city: form.city, maritalStatus: form.maritalStatus,
@@ -110,7 +114,7 @@ export function setAssessmentInput(type: string, step: number, form: AssessmentF
writeAssessmentFlow(flowToken(), payload)
}
export function showAgreementNotice(title: string): void {
uni.showModal({ title, content: '完整协议将在相关服务开放时提供。当前勾选仅保存您的选择,不会发起实名认证、查询外部数据或上传资料。', showCancel: false, confirmText: '我知道了' })
openAgreementNotice(title)
}
export type AssessmentProgress = { percent: number, basicDone: boolean, incomeDone: boolean, assetsDone: boolean, authorizationSelected: boolean }
+7 -7
View File
@@ -1,8 +1,8 @@
// 发布前选择目标环境并配置 HTTPS 地址;未配置时请求失败,不回退模拟服务。
// 小程序体验版连接远程测试服;切回本机联调时改为 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://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)
// 当前仅使用既有正式服务器;小程序从当前源码重新编译后由用户发行。
// 本机联调副本显式使用 development,test 留空,不自动切换其他环境。
export const APP_ENVIRONMENT: string = 'production' as string
const DEVELOPMENT_API_URL = 'http://127.0.0.1:18580/api/v1'
const TEST_API_URL = ''
const PRODUCTION_API_URL = 'https://airz.hqling.cloud/api/v1'
export const API_BASE_URL = APP_ENVIRONMENT == 'development' ? DEVELOPMENT_API_URL : (APP_ENVIRONMENT == 'test' ? TEST_API_URL : (APP_ENVIRONMENT == 'production' ? PRODUCTION_API_URL : ''))
export const API_DEMO_ENABLED = false
+83
View File
@@ -0,0 +1,83 @@
import { API_BASE_URL, getSession, clearSession, uploadJson, requestJson } from './api.uts'
import { agreementRecordIdValid, sameConsentSession } from './agreements-route.uts'
export const documentCategory = (name: string): string => {
const names = ['身份证正面', '身份证反面', '银行卡', '收入证明', '营业执照', '银行流水', '经营证明']
const categories = ['id-front', 'id-back', 'bank-card', 'income', 'business-license', 'bank-statement', 'business-proof']
const index = names.indexOf(name)
return index < 0 ? '' : categories[index]
}
export type OwnedDocument = { name: string, documentId: string, consentId: string, deletePending: boolean }
export async function fetchOwnedDocuments(): Promise<OwnedDocument[]> {
const sent = owner()
const data = await requestJson('/consents', 'GET', { scope: 'documents', page: 1, pageSize: 1 })
if (!sameOwner(sent)) throw new Error('登录状态已变化,请重新操作')
const rows = data.getArray<UTSJSONObject>('documents')
if (rows == null) throw new Error('已保存照片读取未能确认,请重新读取')
const names = ['身份证正面', '身份证反面', '银行卡', '收入证明', '营业执照', '银行流水', '经营证明']
const items: OwnedDocument[] = []; const ids: string[] = []
rows.forEach((row) => {
const id = row.getString('documentId') ?? ''; const consentId = row.getString('consentId') ?? ''
const category = row.getString('category') ?? ''; const pending = row.getBoolean('deletePending')
const name = names.find((item) => documentCategory(item) == category) ?? ''
if (!agreementRecordIdValid(id) || !agreementRecordIdValid(consentId) || name.length == 0 || pending == null
|| ids.includes(id) || row['path'] != null || row['url'] != null || row['storage_key'] != null)
throw new Error('已保存照片响应无效,请重新读取')
ids.push(id); items.push({ name, documentId: id, consentId, deletePending: pending })
})
return items
}
function owner(): UTSJSONObject {
const current = getSession()
if (current == null) throw new Error('请先登录')
return current
}
function sameOwner(sent: UTSJSONObject): boolean {
const current = getSession()
return sameConsentSession(sent.getString('token') ?? '', sent.getString('userId') ?? '', current?.getString('token') ?? '', current?.getString('userId') ?? '')
}
export async function uploadDocument(path: string, category: string, consentId: string): Promise<string> {
const sent = owner()
if (path.length == 0 || !['id-front', 'id-back', 'bank-card', 'income', 'business-license', 'bank-statement', 'business-proof'].includes(category) || !agreementRecordIdValid(consentId))
throw new Error('照片类别或同意记录无效')
const data = await uploadJson('/documents', path, { category, consentId }, '保存结果未确认,重试可能另存一份照片')
if (!sameOwner(sent)) throw new Error('登录状态已变化,请重新操作')
const id = data.getString('documentId') ?? ''; const size = data.getNumber('byteSize') ?? 0
if (!agreementRecordIdValid(id) || data.getString('category') != category || data.getString('mimeType') != 'image/png'
|| !Number.isInteger(size) || size <= 0 || size > 5242880 || data['url'] != null || data['path'] != null)
throw new Error('保存回执未能确认,请手动重试;可能另存一份照片')
return id
}
export function releaseDocumentTemp(path: string): void {
if (path.length == 0) return
// 不保存到相册或持久缓存。原生端尽力移除下载临时文件;H5对象URL由运行时管理。
// #ifndef WEB
uni.getFileSystemManager().unlink({ filePath: path, fail: () => {} })
// #endif
}
export function downloadDocument(id: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
let sent: UTSJSONObject
try { sent = owner(); if (!agreementRecordIdValid(id)) throw new Error('资料编号无效') }
catch (error) { reject(error); return }
uni.downloadFile({ url: API_BASE_URL + '/documents/' + id + '/content', timeout: 10000,
header: { Authorization: 'Bearer ' + (sent.getString('token') ?? ''), 'X-Requested-With': 'XMLHttpRequest' },
success: (result) => {
if (!sameOwner(sent)) { releaseDocumentTemp(result.tempFilePath); reject(new Error('登录状态已变化,请重新操作')); return }
if (result.statusCode != 200 || result.tempFilePath.length == 0) {
releaseDocumentTemp(result.tempFilePath)
if (result.statusCode == 401) clearSession()
reject(new Error('资料读取失败,请重新登录或稍后重试')); return
}
resolve(result.tempFilePath)
}, fail: () => { reject(new Error('资料读取失败,请检查网络后重试')) }
})
})
}
export async function deleteDocument(id: string): Promise<void> {
const sent = owner()
if (!agreementRecordIdValid(id)) throw new Error('资料编号无效')
const data = await requestJson('/documents/' + id + '/delete', 'POST', {})
if (!sameOwner(sent)) throw new Error('登录状态已变化,请重新操作')
if (data.getString('documentId') != id || data.getBoolean('deleted') != true) throw new Error('删除未能确认,请对同一资料重试')
}