feat: 接入积分服务端真实接口并完成隔离联调

新增积分服务端接口封装、积分中心页面,支持登录回跳、签到恢复、积分明细等功能。清理缓存时保留未确认签到键,新增ApiRequestError错误类型区分业务状态,更新开发文档记录积分页面联调完成情况。
This commit is contained in:
hujun
2026-10-04 08:29:53 +08:00
parent 2a9ff403c4
commit 6b16d4fd55
9 changed files with 555 additions and 150 deletions
+19 -1
View File
@@ -1,6 +1,24 @@
# AI 融资平台开发文档
> 版本:v1.42 · 基线日期:2026-09-22 · 更新日期:2026-10-01 · 状态:积分后端及真实HTTP隔离开发检查完成,默认关闭、DEC-POINT待确认;原表未迁移、积分页面未接、未部署。模型运行参数/调用额度受控配置、后台分析进程心跳/进度超时提示、只读服务检查/调用用量、评估排障、受控人工重试与操作审计、业务权限模板、资料/消息只读及敏感字段保护源码完成未部署;FAQ服务端和反馈闭环源码完成未部署。资讯/课堂/搜索/会员课程进度、消息中心和产品编辑/公共目录已正式部署至https://airz.hqling.cloud;新域名公网HTTPS通过。微信/Android此前开发编译通过,小程序需重新发行,H5未部署、真机及多设备待验收。最新资料/评估版本及会话精简约定保留。
> 版本:v1.43 · 基线日期:2026-09-22 · 更新日期:2026-10-02 · 状态:积分后端已随5fee3cf部署,默认关闭、DEC-POINT待确认;原表未迁移,积分页面真实读取、异常恢复及隔离联调完成,前端未重新发行。以下后台和内容模块的历史部署状态以新会话交接及后端deployment-baota.md最新发布记录为准。微信/Android编译通过,小程序需重新发行,H5未部署、真机及多设备待验收。最新资料/评估版本及会话精简约定保留。
### 0.51 2026-10-02 积分页面恢复与隔离联调
页面按加载所属Token和页面代次隔离请求;账户切换后的规则、任务、分页、签到及明细展开操作先清旧状态并读取新账户。四读取接口先暂存,校验日期、时区、版本、奖励及签到状态一致后统一展示;失败显示不可用与重试,不把读失败显示成零积分或无记录。
按账户保存的未知签到请求优先于今日已完成状态;加载开始即恢复原键,当前规则或流水读取失败仍可POST确认原成功回执。共享 `ApiRequestError` 提供HTTP状态/错误码,只有服务器明确拒绝且未入账的409日期/规则/已签/请求/余额错误、422参数错误及503规则不可用可弃旧键;网络、写冲突、账本不可用及坏回执保键。成功回执要求delta=1..1000、balanceAfter=0..2147483647、replayed为boolean,并匹配原预期日期和版本;展示当前余额须重新GET,不能用历史balanceAfter覆盖。
隔离Chrome页面主流程98项/127次积分请求通过,123次进入真实本机后端,4次受控故障;两次真实提交后丢响应分别在刷新已签和当前规则损坏状态下用原键确认,流水不重复。覆盖20+7分页、NULL/零/负流水、原子读取、确定拒绝与坏回执、账户切换及迟到401。登录回跳只注入合成认证响应,非真实认证验收。手机/桌面截图、日志及清理证据见台账0.51及仓库外 `airz-points-page-20261002` 目录;页面开发检查不能代替生产原表迁移、业务规则批准或真机/发布验收。
补充81项/81次积分请求通过,主流程与补充共179项/208次请求(201真实后端、7受控故障),覆盖网络中断、流水503仍确认、分页/展开换账户和实际拒绝分类。共享会话清理仅保留原账户的三字段待确认命令,不含Token/资料;其它缓存与会话照常清除。恢复例外见积分契约v3,不能跨会员迁移、自动确认或替代鉴权。Android/微信最终编译通过,完整证据和边界以台账0.51为准。
401登录清理专项35项通过(15真实积分HTTP、2合成登录),实际丢响应→撤销Token→清会话/资料、留原键→重新登录→原键重放且不重复入账。214项开发检查累计223积分请求,216次真实隔离后端、7次受控故障。临时数据库/Token/runtime/私密配置及服务已精确清理,未发布前端、迁移正式库或调用模型;后台重启本地预览被自动审批拒绝,当前无本轮预览服务。完整复现前须重新建立隔离环境。
### 0.50 2026-10-02 积分页面真实读取
积分页读取后端 `/points`、`/rules`、`/tasks`、`/ledger`,按服务端业务日、规则版本及有界流水游标渲染;空账本的 `snapshotId=0` 和历史 `null` 元字段按契约保留。移除本机签到天数、固定奖励和体验邀请码,未发布规则显示不可用,不以页面示例值兜底。
签到请求上下文按账户保存 `requestId`、`expectedDate` 和 `expectedRuleVersion`,网络结果不确定时复用原键确认,成功后重新读取余额。页面增加登录回跳、分页、加载/错误/空状态、Token/页面代次守卫;Android 与微信小程序编译通过。积分原表迁移、正式奖励发布、真实设备和开启规则后的到账验收仍待业务确认。
> 配套台账:[开发进度](开发进度.md)。本文定义范围、接口和验收口径,台账记录实际执行与证据。
> 文档初始规划后已开始开发;以下更新记录优先于原始基线。除明确列为已实现的部分外,计划接口和增量数据仍待开发。
+45 -7
View File
@@ -1,6 +1,44 @@
# AI 融资平台开发进度
基线日期:2026-09-22。文档版本:1.42。最新积分协议与隔离开发检查见0.49节,后端基础和规则设计历史见0.48/0.47节;此前章节保留阶段记录。技术方案和接口说明见[开发文档](./开发文档.md)。本台账现有63项任务、27页面,编号与开发文档保持一致。
基线日期:2026-09-22。文档版本:1.43。最新FAQ服务端导入与隔离HTTP联调见0.52节,积分页面恢复及隔离联调见0.51节,真实读取见0.50节,积分协议与隔离开发检查见0.49节,后端基础和规则设计历史见0.48/0.47节;此前章节保留阶段记录。技术方案和接口说明见[开发文档](./开发文档.md)。本台账现有63项任务、27页面,编号与开发文档保持一致。
### 0.52 2026-10-03 FAQ 服务端导入与隔离 HTTP 联调
FAQ 初始内容按 `database/faq_initial.json` 导入本机隔离 MySQL `127.0.0.1:3310/airz_product_test_20261003`:首次预检 8 条待导入,首次 apply 插入 8 条;复核为 8 条 `published`、8 个唯一 slug。重复预检和重复 apply 均为 `keptExisting=8, inserted=0`,回执记录 ID、slug 和内容 SHA-256。正式 `.env`、3306 数据库和线上环境未连接或写入。
隔离 PHP HTTP 联调覆盖 `GET /api/v1/help/faqs` 全量及账户使用、融资评估、资料与隐私三类筛选,返回 8/3/3/2 条;非法分类、非法分页为 HTTP 422,POST 为 HTTP 404。8 条 FAQ 全部下架时返回空 `items`,随后恢复 published。配置显式锁定隔离数据库并关闭 DeepSeek,未调用模型或 worker;临时 PHP/MySQL、INI、bootstrap、路由和运行目录已清理。
FAQ 服务端导入与公开读取联调完成,下一步为前端帮助页页面级错误/空态联调;仍未线上导入、后端发布或前端发行。
### 0.51 2026-10-02 积分页面恢复与隔离联调
积分页补齐签到恢复:已保存请求优先于今日完成状态,成功丢响应后刷新仍能原键确认;读取余额、规则或流水失败时也保留确认入口。确定未入账的日期/规则/当日已签/请求冲突/余额异常、参数及规则不可用错误清旧键并刷新,网络、写冲突和账本不可用继续保留原键。共享API错误对象保留HTTP状态与业务错误码,成功回执严格校验正奖励、非负INT余额和boolean重放标记,坏回执不能提示成功或丢键。
四读取接口暂存后统一校验业务日、时区、规则版本、奖励及签到状态,再原子展示;读取失败显示不可用与重试,不伪造0余额或空账本。按页面代次、可见性和加载所属Token守卫迟到结果;规则、任务、签到、分页及明细展开动作前核对账户,切换账户先清旧数据再读取,防止旧请求键/游标进入新账户。
Chrome Playwright手机390×844及桌面1280×900主流程98项通过:127次浏览器积分请求中123次转发本机真实PHP/MySQL,4次为受控故障;密码登录回跳使用合成认证响应,不代表真实登录后端验证。覆盖默认关闭、20+7本人快照分页、NULL/零/负流水、展开和规则弹窗、读取503及恢复、多接口跨日/换规则、确定拒绝弃键、写冲突保键、两次实际提交后丢空响应及原键重放、历史回执不覆盖当前200余额、当前规则损坏仍确认、五种坏成功回执、账户切换及可见/隐藏页401。无未捕获pageerror、框架遮罩或横溢;13条console.error均对应刻意的HTTP拒绝或200空body解析日志,正常路径无未解释运行错误。
证据目录 `C:/Users/JUN/AppData/Local/Temp/airz-points-page-20261002/` 保存 `ui-result.json`、手机/桌面/错误/确认截图及临时脚本。Browser插件未提供,使用已安装Playwright,无新增依赖。初次脚本的原生input定位、包含历史日期的“10”检查和暂停响应等待已修正;最后日志分类漏计故意空JSON响应后,精确清理旧夹具、重新建立新夹具完整通过,不把失败运行计为通过。
补充恢复检查81项/81次积分请求通过(78次真实后端、3次受控网络/HTTP故障),主流程与补充共179项/208次积分请求。真实浏览器网络中断后原键提交、真实成功丢body后流水读取503仍确认、账户切换分页/明细展开不带旧游标,以及真实REQUEST_CONFLICT/422 INPUT_INVALID/BALANCE_INVALID拒绝弃键、未知账本503保键均覆盖。7条console日志均对应预期故障,pageerror=0;截图为 `points-extra-pending-ledger-failure.png`、`points-extra-final-mobile.png/desktop.png`。初次67项功能检查后脚本误等toast节点删除而超时,改为等隐藏、保留原记录,并在新的合成12月日期完整重跑81项通过。
复核发现共享会话清理会丢待确认键,`clearAccountCache`现仅排除原userId的三字段签到命令,其它资料缓存与会话继续清理;401/过期/退出/账户切换后的确认仍须重新鉴权且不能带到新会员。同步积分契约v3,作为恢复范围的工程调整,非奖励规则批准。HBuilderX 5.26最终Android 19:22:21、微信小程序19:23:14编译通过;H5保留已有layout/PDF编译警告,正常浏览器业务路径没有未解释异常。
登录清理专项35项/17请求通过:15次真实隔离积分HTTP和2次合成密码登录,真实提交丢响应→Redis Token撤销401→会话/资料哨兵清理且原键保留→同会员重新登录→实际原键重放,余额只加7、流水只加1,另一账户缓存不受清理影响。专项只实测401路径;本机过期、主动退出及saveSession切换共用的清理函数已同步例外,但这些完整交互未另外实测。安全回执 `ui-auth-recovery-result.json`,截图 `points-auth-recovery-confirmed.png`。主流程/补充/专项共214项;积分请求共223次(216真实后端、7受控故障),合成登录请求另计。
收尾:两次随机积分库(首次修正脚本前后)、自建空源库、scope Token、随机runtime及私密fixture/INI均精确移除;独立MySQL3309/PID63248正常SHUTDOWN、PHP64115/PID37280停止,原Redis队列摘要保持,0模型/worker调用。MySQL临时data保留但进程不运行,不包含业务数据;当前没有本轮3309/64115/5173监听。回执 `cleanup-result.json`、`source-cleanup-result.json` 和 `verification.json`保留。脚本依赖已删私密环境,不能直接重跑,应重新准备明确隔离夹具。原3306和线上未触碰,未提交/推送。
工具边界:Browser插件未提供,现有Playwright完成全部页面检查;长期H5开发上下文HMR日志出现过一次scrollTop空对象错误,最终独立浏览器记录pageerror均0,未将热更新上下文列为真实设备结论。停止常驻CLI后,后台重新启动预览被自动审批以 `blocked by policy` 拒绝;未改用其它机制绕过,预览服务未保留,不提供可用本地URL。
积分后端仍为已部署提交 `5fee3cf`;本轮前端未发行,正式配置 `enabled=false`、DEC-POINT待确认、原表4增量未应用。仅本地隔离开发,不做运营/真机验收或模型/worker调用。POINT-002与POINT-004开发中,POINT-003未开始;POINT-004的邀请与资料奖励部分尚未实施。
### 0.50 2026-10-02 积分页面真实读取
积分页面已接入已部署的积分服务:读取余额、规则、任务和本人流水,使用服务端业务日与规则版本;流水使用 `snapshotId`/`beforeId` 游标,空账本保留 `snapshotId=0`。移除本机签到天数、固定 10 分和固定邀请码,关闭配置时保留奖励 `null` 并显示未发布状态。
签到请求键按 `pts_` 加 32 位小写十六进制生成,并按当前账户保存请求上下文;未知网络结果继续使用原键确认,成功后重新读取余额。页面增加登录回跳、加载/错误/空流水/分页状态,并校验 Token 与页面代次,阻止离页或切换账户的迟到结果。
积分配置仍 `enabled=false`,未执行原表迁移、正式奖励发布或真实设备到账验收。HBuilderX 5.26 Android 与微信小程序编译通过;下一步为业务规则确认后的隔离 HTTP/页面联调,不把编译结果视为积分到账验收。
### 0.49 2026-10-01 积分协议与隔离开发检查
@@ -106,7 +144,7 @@ PHP/JS语法、权限/隐私纯内存回归及227项真实隔离HTTP/数据库
后台新增独立FAQ编辑入口,沿用现有管理员权限、发布状态和版本冲突保护;FAQ初始内容使用显式目标的`database/import_faq.php`和`database/faq_initial.json`导入,预检默认不写入,不能连接默认`.env`。前端帮助页移除本地FAQ数组,始终读取服务端并保留空态/错误重试。
本轮仅完成源码、种子脚本和隔离事务回归,未导入正式库、未部署PHP/后台资源、未修改正式数据。FAQ之后继续开发反馈提交/处理闭环;人工客服仍受`EXT-CS`供应商选择阻塞。原有资讯、课堂、搜索、课程进度、消息和产品部署状态不变。
本轮仅完成源码、种子脚本和隔离事务回归,未导入正式库、未部署PHP/后台资源、未修改正式数据。2026-10-03修正退出登录后的评估资料说明,并为导入脚本增加种子slug必填/唯一校验及`--apply`前备份目录门禁;本机隔离MySQL/PHP当前未运行,尚未执行数据库预检或接口联调。FAQ之后继续开发反馈提交/处理闭环;人工客服仍受`EXT-CS`供应商选择阻塞。原有资讯、课堂、搜索、课程进度、消息和产品部署状态不变。
### 0.40 2026-10-01 反馈提交与处理闭环开发
@@ -521,7 +559,7 @@ P0 表示核心流程、安全、首发外部能力或发布门槛;P1 表示
| CHAT AI 助手 | 模拟对话 | 会话/消息/使用量字段已有 | 调用、上下文、配额待开发 | 待开发 | 依赖 DeepSeek | 未完成 |
| CONTENT 内容课堂 | 新闻/课程/搜索/首页真实接口,云端进度;FAQ服务端读取 | 原内容表JSON及课程进度关系表正式上线 | 发布/下架/章节编辑/FAQ维护完成 | 隔离/服务器验收通过,真机/多设备待验收 | 原4新闻/3课程按slug导入;FAQ源码未部署 | 开发中,见0.39 |
| MSG 消息中心 | 服务端消息、分类分页、已读及首页红点 | read_at/关联业务字段及唯一事件索引已有 | 通知事件/去重已实现 | 本机/服务器检查通过,真机/多设备待验收 | 无独立外部渠道要求 | 开发中,见0.37 |
| POINT 积分邀请 | 本机体验签到,未接真实后端 | 既有业务索引复用;schema期望两字段/两索引,新合成库迁移/恢复通过,原表未迁移 | 五接口/事务/严格HTTP/默认关闭和旧写保护源码完成未部署 | 主服务238/HTTP输入190/迁移110纯检查、真实HTTP1079项87请求、合成apply/恢复89及只读预检23通过;页面未接 | DEC-POINT待确认,见0.49和[积分契约](积分规则与开发契约.md) | POINT-001阻塞,POINT-002开发中,003/004未实施 |
| POINT 积分邀请 | 真实余额/规则/流水/签到恢复已接入并完成隔离页面联调,未发行 | 既有业务索引复用;schema期望两字段/两索引,新合成库迁移/恢复通过,原表未迁移 | 五接口/事务/严格HTTP/默认关闭和旧写保护已随5fee3cf部署,正式积分仍关闭 | 主服务238/HTTP输入190/迁移110纯检查、真实HTTP1079项87请求、合成apply/恢复89及只读预检23;页面主流程98项127请求通过 | DEC-POINT待确认,最新见0.51和[积分契约](积分规则与开发契约.md) | POINT-001阻塞,002/004开发中,003未开始 |
| SUPPORT 客服反馈 | 帮助页FAQ、文字反馈、本人记录;客服暂未开放 | feedback处理字段、请求键唯一索引已在隔离库应用 | 反馈闭环及后台白名单处理完成;客服签名待开发 | POST/GET反馈及详情已实现;附件服务未开放 | 人工客服待定 | 反馈开发完成,见0.40;客服未完成 |
| ADMIN 后台运营 | FastAdmin 管理界面已有 | 管理员权限保持,会员组保持删除 | 档案/产品/密钥表单已适配,运营排障待补齐 | 面向管理员的新增能力待开发 | 依赖各渠道状态 | 基础界面修改不等于运营验收 |
| RELEASE 发布验收 | Web/Android 曾完成本地编译 | 本机迁移已有,其他环境须单独设计升级 | 未建立完整首发部署/恢复链路 | 无全量真实联调证据 | 域名/HTTPS/队列部署等待定 | H5 首发、真机和多端发布均未完成 |
@@ -566,7 +604,7 @@ P0 表示核心流程、安全、首发外部能力或发布门槛;P1 表示
| 账户安全 | packages/account/security/security | AUTH-004 | 入口与客户端校验 |
| 修改密码 | packages/account/password/password | AUTH-004 | 真实服务未接通,不模拟成功 |
| 换绑手机 | packages/account/change-phone/change-phone | AUTH-004 | 真实服务未接通,不模拟成功 |
| 积分中心 | packages/account/points/points | POINT-004 | 体验签到与本机记录 |
| 积分中心 | packages/account/points/points | POINT-004 | 服务端余额/规则/任务/流水及签到原键确认,隔离联调通过、未发行 |
| 消息中心 | packages/account/messages/messages | MSG-004 | 服务端分页/已读与原版本跳转 |
| 搜索 | packages/content/search/search | CONTENT-004 | 服务端分类/分页搜索 |
| 帮助与反馈 | packages/content/help/help | CONTENT-004、SUPPORT-004 | 本地 FAQ,客服未开放 |
@@ -828,14 +866,14 @@ DEC-DEMAND已确认并发布,需求录入/提交/历史子范围已实现;
| POINT-001 | 积分规则定稿 | 确认签到、资料完成、邀请奖励的条件/次数/额度、业务时区和权益范围。 | P1 | BASE-001、DEC-POINT | 0.5–1 | 阻塞 |
| POINT-002 | 积分账户与签到 | 事务更新score与流水、业务唯一键、服务端日期签到、余额/流水查询。 | P1 | POINT-001、AUTH-002 | 1.5–2 | 开发中 |
| POINT-003 | 邀请码与邀请关系 | 生成唯一邀请码、首次有效绑定、防自邀、可信奖励事件与发放幂等。 | P1 | POINT-002 | 1–2 | 未开始 |
| POINT-004 | 任务积分页面联调 | 真实余额、流水、签到状态、个人邀请码、任务状态和规则说明。 | P1 | POINT-003、ASSESS-004 | 1–2 | 未开始 |
| POINT-004 | 任务积分页面联调 | 真实余额、流水、签到状态、个人邀请码、任务状态和规则说明。 | P1 | POINT-003、ASSESS-004 | 1–2 | 开发中 |
| 任务编号 | 负责人 | 验收条件 | 证据 | 更新时间 |
| --- | --- | --- | --- | --- |
| POINT-001 | 全栈开发(待指定) | 形成版本化活动规则;复制/分享不等于奖励;未确认奖励规则不得上线。 | 规则建议见0.47、基础实现见0.48;数额/权益/期限/等级仍待DEC-POINT确认,不启用发布配置 | 2026-10-01 |
| POINT-002 | 全栈开发(待指定) | 同用户同日并发只加一次;流水与积分一致;错误不静默吞掉。 | 0.49五接口/事务/严格HTTP/默认关闭及旧写保护完成;纯服务238/输入190/迁移110、HTTP1079项87请求、新合成库apply/恢复89、原表只读预检23通过;原表未迁移、页面未接/未部署 | 2026-10-01 |
| POINT-002 | 全栈开发(待指定) | 同用户同日并发只加一次;流水与积分一致;错误不静默吞掉。 | 五接口/事务/严格HTTP及旧写保护已随5fee3cf部署;0.51真实隔离页面与原键确认通过;正式配置关闭、规则待批准、原表未迁移 | 2026-10-02 |
| POINT-003 | 全栈开发(待指定) | 客户端不能自报完成奖励;重复/自邀拒绝;已有关系不随分享覆盖。 | 邀请资格/奖励规则与关系方案后续确认;未实施,无真实邀请码/奖励 | 2026-10-01 |
| POINT-004 | 全栈开发(待指定) | 真实到账后才提示成功;跨账户/跨设备一致;邀请复制不立即发奖励。 | 页面实施边界已列设计,当前仍体验签到,未联调真实积分API或到账 | 2026-10-01 |
| POINT-004 | 全栈开发(待指定) | 真实到账后才提示成功;跨账户/跨设备一致;邀请复制不立即发奖励。 | 0.50/0.51余额、规则、任务、流水及签到恢复已接真实API并完成隔离页面联调;邀请码/邀请及资料奖励未实施,未发行或真机验收 | 2026-10-02 |
直接阻塞:POINT-001 → DEC-POINT;POINT-002推进默认关闭的实现与隔离检查,HTTP后端检查已完成,规则发布/真实入账仍须POINT-001定稿及原表迁移、页面开发检查。
+54 -2
View File
@@ -1,6 +1,58 @@
# AI融资项目新会话交接
记录日期:2026-10-01。本文件为接续入口;当前存储与流程以[评估版本与最新资料](评估版本与最新资料.md)、[开发进度](开发进度.md)0.49节(积分协议与隔离检查)及[开发文档](开发文档.md)v1.42为准,0.48/0.47保留基础和规则设计历史。历史章节中的域名、部署及“待开发”以最新记录为准。
记录日期:2026-10-02。本文件为接续入口;当前存储与流程以[评估版本与最新资料](评估版本与最新资料.md)、[开发进度](开发进度.md)0.51节(积分页面恢复与隔离联调)及[开发文档](开发文档.md)v1.43为准,0.50/0.49/0.48/0.47保留前序开发与规则设计历史。历史章节中的域名、部署及“待开发”以最新记录为准。
## 本轮开发:2026-10-03 FAQ 服务端导入与隔离 HTTP 联调
FAQ 初始内容已按 `database/faq_initial.json` 导入本机隔离 MySQL `127.0.0.1:3310/airz_product_test_20261003`:首次预检显示 8 条待导入,首次 apply 成功插入 8 条;数据库复核为 8 条 `published`、8 个唯一 slug。重复预检和重复 apply 均保持 `keptExisting=8, inserted=0`,导入回执记录了 ID、slug 和内容 SHA-256。正式 `.env`、3306 数据库和线上环境均未连接或写入。
在同一隔离库启动临时 PHP HTTP 服务后,`GET /api/v1/help/faqs` 及三个分类筛选分别返回 8、3、3、2 条;非法 `category`、非法 `page` 返回 HTTP 422,POST 返回 HTTP 404。临时将 8 条 FAQ 全部下架时接口返回空 `items`,随后已恢复 8 条 `published`。服务端配置显式锁定隔离主机、端口、库名并关闭 DeepSeek,未调用模型或 worker。
HTTP 联调完成后已停止本轮 PHP/MySQL 临时进程,删除临时 INI、路由/bootstrap 和运行目录;后端工作树中原有未提交改动均保留。FAQ 服务端导入与公开读取联调完成,下一步接前端 FAQ 页面真实读取并做页面级错误/空态联调;仍不进行线上导入或前端发行,除非另行明确授权。
## 本轮开发:2026-10-04 FAQ 页面级隔离联调(已完成)
H5 入口 `http://localhost:5173/#/packages/content/help/help` 已使用 Playwright CLI 完成桌面 1280×900 与手机 390×844 检查。Browser 插件在本会话不可用,按前端测试技能记录为 fallback;浏览器只把正式 API URL 的 FAQ 请求转发到本机隔离 PHP `127.0.0.1:8097`,`services/config.uts` 继续保持 `APP_ENVIRONMENT=test`,未改为本地或正式环境。
真实隔离读取显示 8 条 FAQ,分为“账户使用”3 条、“融资评估”3 条、“资料与隐私”2 条;首条展开答案与接口内容一致。页面身份、非空首屏、无 Vite/Webpack 错误遮罩、无横向溢出及控制台 error/warn 均通过。浏览器层受控 503 显示“暂时无法加载帮助内容,请稍后重试”和“重新加载”,恢复转发后重新加载回到 8 条;空 `items` 显示“常见问题正在整理中”。从 FAQ 进入首页再返回的延迟响应场景中,离页后的伪造数据没有回填,返回后正常隔离 FAQ 保持。
证据保留在 `C:/Users/JUN/AppData/Local/Temp/airz-faq-page-20261003/`,包括 `faq-expanded-desktop-verified.png`、`faq-mobile-verified.png`、`faq-error-mobile.png`、请求/控制台记录和导入回执。收尾按回执逐行核对 ID、slug、内容 SHA-256 后,仅在 `127.0.0.1:3309/airz_product_test_20260930` 事务删除本轮新增 8 条,回执 ID 残留为 0、已发布 FAQ 为 0;未连接或修改正式 3306/线上。8097、3309、5173 已全部停止,H5/FAQ 尚未发行到正式前端。
本轮只完成隔离联调和证据整理,未进行线上 FAQ 导入、正式前端发行、真机/多设备验收或反馈客服接入;下一步需在明确发布授权后再安排正式资源导入与发行,或继续反馈提交/处理闭环开发。
## 本轮部署:2026-10-02 后端发布
后端提交 `5fee3cf` 已部署至 `https://airz.hqling.cloud`,API 前缀为 `/api/v1`,后台入口为 `/ZYurKboadX.php`。发布包实际部署 3,714 个文件,SHA-256 为 `C36BE3FD6AB7E64833F03FBC400DEC4924521809789B1C55C68348D5EFE5DD57`;服务器私密备份为 `/root/airz-backend-20261002/`。`airz-analysis`、PHP-FPM、Redis 已恢复运行,队列为空,积分配置仍关闭且未执行原表迁移。公网内容接口、鉴权入口、敏感路径和 HTTP→HTTPS 跳转已完成烟测;详细过程见后端 `deployment-baota.md`。
前端尚未重新发行;积分页面真实余额、规则、流水读取、登录恢复及隔离页面联调已完成,积分原表迁移与正式奖励发布另行安排。
## 本轮开发:2026-10-02 积分页面恢复与隔离联调
页面修复未知签到原键被今日已签阻断、确定拒绝后卡旧键、读取失败无法确认旧请求、跨接口混合日期/规则及账户切换旧上下文问题。四读取原子显示;加载开始恢复pending且错误页保留确认按钮;按Token/页面代次/可见性处理迟到响应,规则/任务/签到/分页/明细展开动作前核对账户。共享API错误保留HTTP状态和业务码,确定未入账才弃键;网络/账本/写冲突及坏回执继续原键确认。成功回执严格校验奖励/余额/重放标记,历史balanceAfter不覆盖当前余额。
手机390×844/桌面1280×900 Chrome Playwright主流程98项通过,127次浏览器积分请求中123次进入本机真实后端、4次受控故障;登录回跳使用合成认证响应。覆盖默认关闭、20+7流水分页、NULL/零/负记录、规则及展开、读503恢复、跨日/规则切换、确定拒绝弃键、写冲突保键、实际提交后丢响应/刷新已签/坏当前规则原键确认、五种坏成功回执、账户切换及迟到401。pageerror=0,无框架遮罩/横溢;13条console日志均为刻意HTTP错误或空JSON响应。证据在 `C:/Users/JUN/AppData/Local/Temp/airz-points-page-20261002/`,完整状态见开发进度0.51。
补充81项/81请求通过,共179项/208积分请求(201真实隔离后端、7受控故障)。额外覆盖真实网络中断、成功丢回执后流水503仍确认、分页与展开换账户、实际请求冲突/无效输入/负余额拒绝及未知503保键。共享会话清理现在只保留原userId的三字段未确认命令,其它资料/会话仍清,恢复必须重新鉴权;契约已更新v3。最终Android 19:22:21、微信小程序19:23:14编译通过,H5保留既有编译警告。
登录清理专项35项通过(15真实积分请求、2合成登录):真实提交丢响应后撤销Token,401清除会话和资料哨兵但保留原键,重新登录该会员后用原键真实重放、只一笔到账。累计214项/223积分请求,其中216真实隔离后端、7受控故障。专项实际覆盖401路径,过期/主动退出/saveSession切换的同一清理例外为源码覆盖,完整交互未另测。
清理完成:本轮两个随机积分库、空源库、Token、runtime、私密fixture/INI移除,独立MySQL3309正常关闭、PHP64115停止;原Redis队列摘要保持,3306与线上未动、模型/worker未调用。`ui-result.json`、`ui-extra-result.json`、`ui-auth-recovery-result.json`、截图、两份cleanup回执及verification保留;脚本不能复用已删夹具,继续时须重新准备明确隔离环境。本轮3309/64115/5173均无监听;后台重新启动H5预览被自动审批以blocked by policy拒绝,未绕过。历史PID和默认.env不可直接复用。
正式积分仍enabled=false、DEC-POINT待确认、原表两列/两索引未迁移,本轮未生产写入、未调用模型/worker、未前端发行或运营/真机验收。POINT-002/004开发中,邀请与资料奖励未实施。下一步顺序:确认DEC-POINT业务与发布参数 → 获得具体迁移授权后显式只读预检/完整备份/4项增量迁移 → 复检并安排发布冒烟 → 发行前端。合成7/13分不能作为业务批准,不复用历史部署或迁移授权。
## 前序检查:积分联调前置阻塞(页面状态已由本轮更新)
2026-10-02 已用项目指定的 HBuilderX 5.26 CLI 完成 Web 与 Android 编译,均成功;仅保留既有 `services/layout.uts`、PDF 下载空响应等编译器 warning。随后临时启动独立 MySQL 3309(只含积分合成表结构),运行 `tests/points_http.php --env=<explicit-isolated.ini>`,999 项、87 个请求全部通过;脚本自建的随机数据库、Token、临时运行目录和 HTTP 服务已清理,3306 实例与线上环境未触碰。页面真实联调尚未执行。
源码复核发现正式 `fa_user_score_log` 尚未应用 `request_id`、`rule_version` 及两个索引时,`UserPoints::listing()` 与 `checkIn()` 会依赖缺失列并返回 `POINT_LEDGER_UNAVAILABLE`;不能通过运行时删除字段或伪造兼容数据绕过,否则会破坏签到幂等。下一步顺序固定为:确认 DEC-POINT 规则与发布参数 → 在已迁移的隔离合成库完成页面联调 → 获得迁移授权后执行只读预检、完整备份和 4 项增量迁移 → 复检并重部署/冒烟 → 再发行前端。未获规则和迁移授权前不改线上表、不发布奖励、不发行前端。
## 本轮开发:2026-10-02 积分页面真实读取
积分页 `packages/account/points/points.uvue` 已切换为读取后端 `/points`、`/rules`、`/tasks`、`/ledger`,移除本机签到天数、固定 10 分和体验邀请码。关闭配置下保留 `null` 奖励并显示服务未发布,不伪造到账;流水使用服务端快照游标,空流水允许 `snapshotId=0`。
签到请求由 `services/points.uts` 生成并按当前账户保存 `requestId`、服务端业务日和规则版本;网络结果不确定时沿用原键确认,成功后重新读取余额。登录入口增加 `resumePoints=1` 白名单回跳,页面异步请求校验页面代次与当前 Token,离页或切换账户的迟到响应不会覆盖新状态。积分规则仍默认关闭,未执行原表迁移或正式奖励发布。
HBuilderX 5.26 Android 与微信小程序 27 页面编译均通过。前端尚未重新发行,真实设备和服务端开启规则的到账流程待后续业务规则确认。
## 本轮开发:2026-10-01 积分协议与隔离检查
@@ -126,7 +178,7 @@ PHP/JS语法和纯内存回归通过;`tests/admin_access.php` 227项隔离HTTP
在不进行真机/跨设备验收的前提下,已完成FAQ服务端开发:复用`fa_acticle`的`type=6`和`content` JSON(`schemaVersion=1/kind=faq`),新增公开`GET /api/v1/help/faqs`及分类筛选;新增独立后台FAQ编辑模型、控制器、页面和版本保护;前端帮助页移除本地FAQ数组,改为始终读取服务端。没有新增表或字段,没有写入正式库或部署。
初始内容见后端`database/faq_initial.json`,导入脚本为`database/import_faq.php`,必须显式指定隔离/正式环境与目标,默认预检回滚,重复按slug保留已有记录。源码语法检查及隔离事务中的FAQ保存/读取/下架检查通过;现有本地HTTP服务因尚未导入种子返回空列表是预期。下一步继续做反馈提交/处理闭环;人工客服接入仍等待`EXT-CS`供应商方案。
初始内容见后端`database/faq_initial.json`,导入脚本为`database/import_faq.php`,必须显式指定隔离/正式环境与目标,默认预检回滚,重复按slug保留已有记录。2026-10-03已修正退出登录后未提交资料不保留的种子说明,并增加种子slug必填/唯一校验、`--apply`前备份目录门禁和目标级MySQL advisory lock;本机隔离MySQL/PHP当前未运行,尚未执行数据库导入或HTTP联调。源码语法检查及隔离事务中的FAQ保存/读取/下架检查通过;现有本地HTTP服务因尚未导入种子返回空列表是预期。下一步先重新准备明确隔离环境,执行FAQ预检→隔离导入→重复导入和接口分类/空态检查,再继续反馈提交/处理闭环;人工客服接入仍等待`EXT-CS`供应商方案。
## 本轮继续开发:2026-10-01 反馈提交与处理闭环(未部署)
+21 -13
View File
@@ -1,10 +1,18 @@
# 积分规则与开发契约
记录日期:2026-10-01。版本:points-backend-20261001-v2。状态:**后端及真实HTTP隔离开发检查完成,业务规则未定稿,默认不入账,原表未迁移、未部署**。归属既有 POINT-001—004,不新增任务编号。POINT-002保持开发中;DEC-POINT/POINT-001仍待确认,POINT-003未实施,POINT-004页面尚未接真实积分。
记录日期:2026-10-02。版本:points-page-20261002-v3。状态:**后端已随5fee3cf部署、积分页面读取与恢复隔离联调通过;业务规则未定稿,正式配置关闭,原表未迁移、前端未发行**。归属既有 POINT-001—004,不新增任务编号。POINT-002/004保持开发中;DEC-POINT/POINT-001仍待确认,POINT-003未实施。
本文件作为积分实施入口;数据库具体增量见[积分数据库方案](D:/AAdesk/202609/airz/database/points-design.md)。接口源码与真实HTTP隔离检查已完成,未部署;页面仍是本机体验签到。未获规则确认时不填真实奖励,配置默认false/null。
本文件作为积分实施入口;数据库具体增量见[积分数据库方案](D:/AAdesk/202609/airz/database/points-design.md),最新页面状态与证据见[开发进度0.51](开发进度.md)。未获规则确认时不填真实奖励,配置默认false/null;合成联调不代表正式积分可用。
## 0. 最新协议与隔离开发检查
## 0. 最新页面恢复与隔离联调
页面主流程98项/127次积分请求和补充81项/81次积分请求通过,共201次进入本机真实PHP/MySQL、7次受控故障/网络中断。覆盖真实丢响应后原键确认、跨接口一致性、读取失败时确认入口、确定拒绝弃键、坏回执保键、账户切换及迟到401;登录响应只为合成认证,真机、生产迁移与正式发布仍未完成。
未知请求由页面会话范围调整为原userId作用域的恢复命令上下文:只含requestId/expectedDate/expectedRuleVersion,不含Token、余额、流水或填写资料;401、会话过期、退出及账户切换仍清资料缓存和会话,但该键保留至确认或明确拒绝。恢复前核验当前账户,POST仍重新鉴权;不会把原请求迁移到别的会员,也不会在登录/刷新后自动发奖。此恢复例外不改变DEC-POINT业务参数。
共享清理专项35项通过(15真实积分HTTP、2合成密码登录):真实401清掉会话与该账户的其它资料缓存、保留原命令,同账户重新登录后实际重放且无重复到账。专项未实测本机过期/主动退出/完整账户切换交互;它们共用的清理函数已按上述规则实现。三批共214项/223积分请求,临时环境及私密配置已清理,正式发布/原表迁移状态保持。
### 0.1 前序:协议与隔离开发检查
87个真实HTTP请求/1079项检查通过,经过实际ThinkPHP路由、Redis Token鉴权和积分控制器。覆盖匿名/无效/停用/删除/撤销、Cookie/旧Token头拒绝、允许Origin的匿名OPTIONS、JSON/原始query/错误方法、草案/发布/暂停/坏规则、跨日/版本变化、实际丢弃已提交成功响应后的原键确认、本人快照/隐私/旧NULL与0流水、余额边界、坏回执和Redis不可用。
@@ -14,7 +22,7 @@
迁移纯110项、独立合成库apply/备份/Windows ACL/恢复89项通过。共享隔离库只复制结构,第一新库合成2会员4流水;apply4增量、重复为0,完整双表schema+NDJSON恢复第二新库并逐列摘要一致。目录3文件ACL及读写probe通过,修正Windows原生绝对路径及父级路径校验,纯路径边界覆盖;两新库、私密INI/备份/probe已清理,源表摘要保持。安全verification保留于C:/Users/JUN/AppData/Local/Temp/airz-points-migration-20261001/apply-20261001-115749-d50a5cd87fd5a84a/verification.json。仅为开发演练,未执行共享原表DDL或生产操作。
### 0.1 历史:0.48后端基础检查
### 0.2 历史:0.48后端基础检查
已新增 PointsRules、UserPoints、v1 Points 控制器、五条路由、默认未发布配置、显式迁移脚本及测试。通用后台score/level、通用模型更新与旧User::score均已拒绝写入;新服务直接在账户锁内限定更新score并保存完整流水,不重算level。新会员0分、数据库默认0级及Auth注册1级初始化保持。schema.sql和ScoreLog中文注释已同步目标结构,不能据此声称目标库已经增加字段。
@@ -45,9 +53,9 @@
| 位置 | 当前能力或风险 | 实施处理 |
| --- | --- | --- |
| [积分页面](../packages/account/points/points.uvue) | 余额为本机签到日期数乘 10;设备日期;固定体验码 AI2680;没有积分 API | 接真实 API 后移除该余额算法及固定码,清理体验缓存但不换算奖励 |
| [账户服务](../packages/account/services/account.uts) | 已有登录、账户变化防护;资料 DTO 无 score/share_code | 新增同分包积分服务,复用会话而不扩充无关资料字段 |
| [请求基础](../services/api.uts) | 有 Token 变化及 401 防护;当前错误仅保留 message | 实施时保留现有调用兼容,增加可识别的 HTTP 状态/errorCode,区别确定失败与结果未知 |
| [积分页面](../packages/account/points/points.uvue) | 已读取服务端余额、规则、任务和本人流水;按服务端日期/规则恢复签到请求 | 关闭配置时继续显示未发布状态,不伪造奖励;原键确认后重新读取当前余额 |
| [账户服务](../packages/account/services/account.uts) | 已有登录、账户变化防护;资料 DTO 无 score/share_code | 积分服务复用会话,不扩充无关资料字段;登录恢复只接受固定 `resumePoints` 白名单 |
| [请求基础](../services/api.uts) | 已保留 Token 变化、401 清理、HTTP 状态和业务 `errorCode` | 页面按确定拒绝与结果未知分别清理或保留原签到键,网络/账本故障不误判未入账 |
| [数据库结构](D:/AAdesk/202609/airz/database/schema.sql) | fa_user.score 为有符号 INT;流水已具业务键、事件唯一及用户时间索引 | 复用余额和流水;目标库实际结构仍须迁移前只读核实 |
| [通用积分方法](D:/AAdesk/202609/airz/application/common/model/User.php) | 旧score()已明确拒绝,通用模型不能更新score/level | 使用UserPoints领域服务,不再吞异常或随积分重算等级 |
| [后台会员模型](D:/AAdesk/202609/airz/application/admin/model/User.php) | 模型事件、后台增改/批量及编辑表单已保护score/level | 积分/等级只读;任意SQL仍不经过模型守卫,新增写方须遵守共同账户锁 |
@@ -72,7 +80,7 @@ unique = user_id + business_type + business_id
## 4. 接口源码契约
统一前缀 `/api/v1`,全部要求显式合法Bearer及当前正常会员,不接受Cookie/旧Token头回退。沿用v1 Base的code/msg/data与data.errorCode、Cache-Control: no-store,v1框架错误亦补该缓存头。由认证身份取得userId;未知参数、客户端余额/奖励/业务完成状态/其他用户ID均拒绝。五条精确白名单路径已实现并通过HTTP开发检查,原表迁移/规则发布/页面尚未完成,不视为线上能力。
统一前缀 `/api/v1`,全部要求显式合法Bearer及当前正常会员,不接受Cookie/旧Token头回退。沿用v1 Base的code/msg/data与data.errorCode、Cache-Control: no-store,v1框架错误亦补该缓存头。由认证身份取得userId;未知参数、客户端余额/奖励/业务完成状态/其他用户ID均拒绝。五条精确白名单路径、原键恢复和页面隔离联调已通过开发检查;原表迁移和规则发布仍未完成,不视为线上积分已启用。
签到要求application/json,可带UTF-8 charset;正文由原生JSON解码及领域校验,必须三个唯一字符串字段,重复键和Unicode等价重复键拒绝。原始QUERY_STRING校验唯一解码键、白名单及PHP解析一致;NUL、坏编码、重复和空片段拒绝。原始/有效HTTP方法须与接口方法一致,覆盖头存在即拒绝;错误方法405,未知路由404。Common既有入口在鉴权前处理允许Origin的匿名OPTIONS,不重复实现CORS。
@@ -84,11 +92,11 @@ unique = user_id + business_type + business_id
| POST /points/check-in | requestId、expectedRuleVersion、expectedDate,必须恰好三个字段 | ledgerId、delta:int、balanceAfter:int、signedDate、ruleVersion、replayed:bool |
| GET /points/tasks | 无业务参数 | 首阶段只返回已发布签到任务;未实施资料/邀请奖励明确 unavailable,reward=null |
流水源码采用与现有消息/内容一致的有界游标,替代旧page/pageSize目标草案;没有已发布积分API,不涉及接口兼容迁移。tasks实际返回serverDate/timezone/items,签到任务按已发布规则和今日状态显示,资料/邀请始终unavailable且reward=null。邀请/invitations和/invitations/bind保留后续阶段目标,不返回固定码或假的奖励进度。
流水源码采用与现有消息/内容一致的有界游标,替代旧page/pageSize目标草案;积分 API 已接入页面,但正式原表迁移和规则发布仍未完成。tasks实际返回serverDate/timezone/items,签到任务按已发布规则和今日状态显示,资料/邀请始终unavailable且reward=null。邀请/invitations和/invitations/bind保留后续阶段目标,不返回固定码或假的奖励进度。
### 4.1 签到请求及回执
`requestId` 为 `pts_` 加 32 位小写十六进制,客户端用安全随机数生成,同一用户命名空间内唯一。`expectedRuleVersion` 为页面实际读取的已发布版本;`expectedDate` 为同一次读取返回的 serverDate,是操作预期条件,**不是指定奖励日期**。日期须为真实有效的 YYYY-MM-DD,拒绝非法日历日期、空值、数组和宽松类型转换。
`requestId` 为 `pts_` 加 32 位小写十六进制,由客户端生成并在同一用户命名空间内作为幂等键;服务端以唯一约束和原请求确认保证不重复入账。正式发布前仍应将生成源替换为跨端可用的安全随机实现。`expectedRuleVersion` 为页面实际读取的已发布版本;`expectedDate` 为同一次读取返回的 serverDate,是操作预期条件,**不是指定奖励日期**。日期须为真实有效的 YYYY-MM-DD,拒绝非法日历日期、空值、数组和宽松类型转换。
示例仅描述获批发布后的协议形状,当前无这样的运行规则:
@@ -173,13 +181,13 @@ schema.sql及显式迁移脚本已定义可空request_id/rule_version、唯一(u
- 初次/刷新分别处理加载、失败、空流水、未发布规则、已签到;失败清掉不可信旧状态,不显示假的 0 分或成功。
- 发送中锁定签到按钮;请求结果未知时保留原 requestId/date/version 和账户身份,只允许原请求确认。不能因刷新、关闭提示或跨日自动生成另一签到请求。
- 原回执仅确认原业务日;回执后重新读取当前余额和 serverDate。要签到新一天,必须由用户在刷新后明确点击。
- 离页/换账号拒绝迟到响应,未确认请求随账户作用域隔离;未知请求保留范围先按当前页面会话,不跨退出/换账号迁移。
- 离页/换账号拒绝迟到响应;未知请求在原userId作用域保留,离页、401、过期、退出和切换账户不会弃键,不能跨账户迁移。只保留三字段恢复命令,其它账户缓存照常清除;重新登录该会员后才能显式确认,仍须服务端鉴权。
- 从 API 的 ruleVersion/reward 显示规则与奖励,不再写死 10、AI2680 或本机积分。未接邀请前不显示“真实个人邀请码”或未发生的邀请奖励。
- 清理体验签到缓存不产生服务器写入;原匿名/本机记录不影响服务器 signedToday。
## 8. 后续开发检查清单与顺序
以下为完整开发检查范围;已覆盖部分见第0节。HTTP后端隔离检查已完成,页面和原表迁移/正式发布仍未完成:
以下为完整开发检查范围;已覆盖部分见第0节和开发进度0.51。积分读取/签到恢复隔离页面检查已通过,邀请与资料奖励、原表迁移、正式发布及真机仍未完成:
| 检查 | 关键反例 |
| --- | --- |
@@ -194,6 +202,6 @@ schema.sql及显式迁移脚本已定义可空request_id/rule_version、唯一(u
| 页面状态 | 实际响应丢失、原键确认、换账号、登录恢复、加载/空/错、迟到回包 |
| 迁移和恢复 | 原表只读预检;新合成库apply4增量/重复0、完整备份/ACL/第二库恢复、旧数据摘要一致及精确清理已通过 |
当前后端可在规则未定稿时保持默认关闭。下一开发子项可实施积分页面真实读取、登录恢复及原键确认流程;规则未发布时签到不可用,不补造奖励。首阶段业务规则定稿、原表迁移和发布另行安排;邀请资格/奖励规则与关系方案留POINT-003,完整POINT-004后续推进。发布参数在确认前保持空值。
当前后端可在规则未定稿时保持默认关闭。页面真实读取、登录恢复及原键确认已完成隔离联调;下一步是确认首阶段业务规则,随后执行正式库只读预检、完整备份、4项增量迁移和线上关闭状态烟测。邀请资格/奖励规则与关系方案留POINT-003,完整POINT-004后续推进。发布参数在确认前保持空值。
正式迁移、上线、真机/多设备、权益运营和真实业务验收后续按实际范围安排,不复用历史生产授权。本轮为源码、纯测试及显式隔离开发检查,不是生产签到到账或积分页面完成证据。
+2
View File
@@ -48,6 +48,8 @@
} else if (options?.['resumeReport'] == '1') {
const target = reportRoute(options?.['assessmentId'] ?? '', options?.['tab'] ?? 'overview', options?.['revisionId'] ?? '')
if (target.length > 0) afterLoginUrl = target
} else if (options?.['resumePoints'] == '1') {
afterLoginUrl = '/packages/account/points/points'
}
})
const { statusStyle, contentStyle, pageStyle } = usePageLayout()
+253 -125
View File
@@ -1,134 +1,262 @@
<template>
<view class="page" :style="pageStyle()">
<AppHeader title="积分中心" />
<scroll-view scroll-y :style="contentStyle(96, false)">
<view class="points-content">
<view class="points-hero">
<view class="points-hero-top"><text class="points-label">我的体验积分</text><button @click="showRules" class="points-hero-button">积分规则</button></view>
<text class="points-number">{{ totalPoints }}</text><text class="points-note">本地体验 · 仅在本机记录</text>
</view>
<view class="task-grid">
<view class="task" v-for="item in tasks" :key="item.id" @click="doTask(item.id)">
<AppIcon :name="item.icon" :size="52" />
<text class="task-name">{{ item.id == 'checkin' && signedToday ? '已签到' : item.name }}</text>
<text :class="item.id == 'checkin' && signedToday ? 'task-caption completed-caption' : 'task-caption'">{{ item.id == 'checkin' && signedToday ? '明天再来' : item.caption }}</text>
</view>
</view>
<view class="invite-panel">
<text class="panel-title">与好友一起了解融资</text><text class="invite-copy">分享实用知识,提前做好申请准备。</text>
<view class="invite-code"><text class="code-label">体验邀请码</text><text class="code">AI2680</text><view class="copy-action" @click="copyCode"><text class="copy-text">复制</text></view></view>
<button class="primary-button invite-button" @click="invite">复制邀请文案</button><text class="invite-note">当前为本地体验,复制或分享不会发放积分。</text>
</view>
<view class="records">
<view class="record-head" @click="recordsExpanded = !recordsExpanded"><text class="panel-title">积分明细</text><view class="record-toggle"><text class="more">{{ recordsExpanded ? '收起' : '展开' }}</text><AppIcon name="chevron-right" :size="26" :class="recordsExpanded ? 'record-arrow expanded-arrow' : 'record-arrow'" muted /></view></view>
<view v-if="recordsExpanded">
<view v-if="records.length == 0" class="empty-records"><text class="empty-record-title">还没有积分记录</text><text class="empty-record-copy">完成今日签到,获得 10 体验积分。</text></view>
<view class="record" v-for="(item, index) in records" :key="item.date" :class="index == records.length - 1 ? 'last-record' : ''"><view class="record-copy"><text class="record-name">每日签到</text><text class="record-date">{{ item.date }} · 本地体验</text></view><text class="record-score">+{{ item.score }}</text></view>
</view>
</view>
</view>
</scroll-view>
</view>
<view class="page" :style="pageStyle()">
<AppHeader title="积分中心" />
<scroll-view scroll-y :style="contentStyle(96, false)">
<view class="points-content">
<view v-if="loading" class="state-panel"><AppIcon name="points" :size="54" /><text class="state-title">正在读取积分</text><text class="state-copy">请稍候,正在同步账户数据。</text></view>
<view v-else-if="!loggedIn" class="state-panel"><AppIcon name="lock" :size="54" /><text class="state-title">登录后查看积分</text><button class="state-button" @click="login">登录 / 注册</button></view>
<view v-else-if="points == null" class="state-panel"><AppIcon name="points" :size="54" /><text class="state-title">积分暂不可用</text><text class="state-copy">{{ errorMessage }}</text><button v-if="pendingRequest != null" class="state-button" :disabled="checkingIn" @click="checkIn">{{ checkingIn ? '正在确认…' : '确认上次签到' }}</button><button class="state-button" :disabled="checkingIn" @click="load">重新加载</button></view>
<template v-else>
<view class="points-hero">
<view class="points-hero-top"><text class="points-label">我的积分</text><button @click="showRules" class="points-hero-button">积分规则</button></view>
<text class="points-number">{{ balanceLabel }}</text><text class="points-note">{{ pointsNote }}</text>
</view>
<view v-if="errorMessage.length > 0" class="inline-error"><text class="inline-error-copy">{{ errorMessage }}</text><text class="inline-error-action" @click="load">重新加载</text></view>
<view class="task-grid">
<view class="task" v-for="item in taskViews" :key="item.id" @click="doTask(item)">
<AppIcon :name="item.icon" :size="52" />
<text class="task-name">{{ item.name }}</text>
<text :class="item.status == 'completed' ? 'task-caption completed-caption' : 'task-caption'">{{ taskCaption(item) }}</text>
</view>
</view>
<view class="invite-panel">
<text class="panel-title">邀请与资料奖励</text><text class="invite-copy">当前只展示已发布的积分规则,邀请和资料奖励尚未开放。</text>
</view>
<view class="records">
<view class="record-head" @click="toggleRecords"><text class="panel-title">积分明细</text><view class="record-toggle"><text class="more">{{ recordsExpanded ? '收起' : '展开' }}</text><AppIcon name="chevron-right" :size="26" :class="recordsExpanded ? 'record-arrow expanded-arrow' : 'record-arrow'" muted /></view></view>
<view v-if="recordsExpanded">
<view v-if="records.length == 0" class="empty-records"><text class="empty-record-title">暂无积分记录</text><text class="empty-record-copy">已发布的积分事件会显示在这里。</text></view>
<view class="record" v-for="(item, index) in records" :key="item.ledgerId" :class="index == records.length - 1 ? 'last-record' : ''"><view class="record-copy"><text class="record-name">{{ recordName(item) }}</text><text class="record-date">{{ recordDate(item) }}</text></view><text :class="item.delta >= 0 ? 'record-score' : 'record-score negative-score'">{{ deltaLabel(item) }}</text></view>
<button v-if="hasMore" class="load-more" :disabled="loadingMore" @click="loadMore">{{ loadingMore ? '正在加载…' : '加载更多' }}</button>
</view>
</view>
</template>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { getUserStorage, setUserStorage } from '@/services/storage.uts'
import { ApiRequestError, getSession } from '@/services/api.uts'
import { getUserStorage, setUserStorage, removeUserStorage } from '@/services/storage.uts'
import { usePageLayout } from '@/services/layout.uts'
import { CheckInRequest, PointsLedgerItem, PointsRules, PointsSummary, PointsTask, createCheckInRequest, fetchPointsLedger, fetchPointsRules, fetchPointsSummary, fetchPointsTasks, submitPointsCheckIn } from '@/services/points.uts'
import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue'
import { usePageLayout } from '@/services/layout.uts'
import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue'
const { contentStyle, pageStyle } = usePageLayout()
type PointsTask = { id: string, icon: string, name: string, caption: string }
type PointsRecord = { date: string, score: number }
const tasks: PointsTask[] = [
{ id: 'checkin', icon: 'calendar-check', name: '签到', caption: '每日 +10' },
{ id: 'share', icon: 'share', name: '分享', caption: '复制文案' },
{ id: 'profile', icon: 'edit-file', name: '完善资料', caption: '查看档案' },
{ id: 'invite', icon: 'gift', name: '邀请好友', caption: '复制邀请' }
]
const signDays = ref<string[]>([])
const today = ref('')
const recordsExpanded = ref(true)
const totalPoints = computed((): number => signDays.value.length * 10)
const signedToday = computed((): boolean => signDays.value.includes(today.value))
const records = computed((): PointsRecord[] => signDays.value.map((date: string): PointsRecord => ({ date, score: 10 })))
const localDate = (): string => {
const date = new Date()
const month = date.getMonth() + 1
const day = date.getDate()
return date.getFullYear() + '-' + (month < 10 ? '0' : '') + month + '-' + (day < 10 ? '0' : '') + day
}
const restore = (): void => {
today.value = localDate()
const stored = getUserStorage('finance.points.checkin.days')
if (typeof stored != 'string' || stored == '') { signDays.value = []; return }
const restored = (stored as string).split(',')
const unique: string[] = []
restored.forEach((day: string): void => { if (/^\d{4}-\d{2}-\d{2}$/.test(day) && !unique.includes(day)) unique.push(day) })
signDays.value = unique
}
onShow(() => { restore() })
const checkIn = (): void => {
restore()
if (signedToday.value) { uni.showToast({ title: '今天已签到,明天再来', icon: 'none' }); return }
const next = signDays.value.slice()
next.unshift(today.value)
setUserStorage('finance.points.checkin.days', next.join(','))
signDays.value = next
recordsExpanded.value = true
uni.showToast({ title: '签到完成,体验积分 +10', icon: 'none' })
}
const showRules = (): void => {
uni.showModal({ title: '体验积分规则', content: '当前积分仅供本地体验,不代表真实账户权益。每天可签到一次,每次增加 10 体验积分。分享、邀请和完善资料暂不发放积分;清除应用数据后,本地记录也会清除。', showCancel: false, confirmText: '知道了' })
}
const copyText = (value: string, message: string): void => {
uni.setClipboardData({ data: value, success: () => { uni.showToast({ title: message, icon: 'none' }) }, fail: () => { uni.showToast({ title: '复制失败,请稍后重试', icon: 'none' }) } })
}
const copyCode = (): void => { copyText('AI2680', '体验邀请码已复制') }
const invite = (): void => { copyText('邀请你一起了解 AI融资:学习融资知识,梳理申请资料,提前做好融资准备。体验邀请码:AI2680。', '邀请文案已复制,可自行分享') }
const doTask = (id: string): void => {
if (id == 'checkin') { checkIn(); return }
if (id == 'profile') { uni.navigateTo({ url: '/packages/finance/archive/archive' }); return }
if (id == 'invite') { invite(); return }
copyText('分享一个融资准备方法:先梳理信用、收入与负债,再比较适合自己的产品。可以在 AI融资的融资课堂中学习相关知识。', '分享文案已复制,可自行分享')
}
const { contentStyle, pageStyle } = usePageLayout()
type TaskView = { id: string, icon: string, name: string, status: string, reward: number | null, ruleVersion: string | null }
const taskDefinitions: UTSJSONObject[] = [
{ id: 'checkin', icon: 'calendar-check', name: '签到' },
{ id: 'profile', icon: 'edit-file', name: '完善资料' },
{ id: 'invitation', icon: 'gift', name: '邀请好友' }
]
const pendingKey = 'finance.points.checkin.pending'
const loading = ref(false)
const loadingMore = ref(false)
const checkingIn = ref(false)
const loggedIn = ref(false)
const errorMessage = ref('')
const recordsExpanded = ref(true)
const points = ref<PointsSummary | null>(null)
const rules = ref<PointsRules | null>(null)
const serverTasks = ref<PointsTask[]>([])
const records = ref<PointsLedgerItem[]>([])
const snapshotId = ref('')
const nextBeforeId = ref<string | null>(null)
const pendingRequest = ref<CheckInRequest | null>(null)
const accountRefresh = ref(0)
let visible = false
let generation = 0
let loginPending = false
let viewToken = ''
const balanceLabel = computed((): string => points.value == null ? '--' : points.value.balance.toString())
const pointsNote = computed((): string => {
if (points.value == null) return '正在同步账户数据'
if (points.value.balanceState == 'anomaly') return '账户积分状态异常,请稍后重试'
if (rules.value == null || rules.value.status != 'published') return '积分服务尚未发布,签到暂不可用'
return '服务器同步 · ' + points.value.serverDate
})
const taskViews = computed((): TaskView[] => taskDefinitions.map((definition: UTSJSONObject): TaskView => {
const id = definition.getString('id') ?? ''
const task = serverTasks.value.find((item: PointsTask): boolean => item.task == id)
return { id, icon: definition.getString('icon') ?? 'points', name: definition.getString('name') ?? '', status: task?.status ?? 'unavailable', reward: task?.reward ?? null, ruleVersion: task?.ruleVersion ?? null }
}))
const hasMore = computed((): boolean => { const cursor = nextBeforeId.value; return cursor != null && cursor.length > 0 })
const login = (): void => {
if (!visible || loginPending) return
loginPending = true
uni.navigateTo({ url: '/packages/account/login/login?resumePoints=1', fail: () => { loginPending = false } })
}
const resetData = (): void => { points.value = null; rules.value = null; serverTasks.value = []; records.value = []; snapshotId.value = ''; nextBeforeId.value = null; pendingRequest.value = null; viewToken = '' }
const current = (ticket: number, token: string): boolean => {
if (!visible || generation != ticket) return false
const session = getSession()
if (session?.getString('token') == token) return true
resetData(); loading.value = false; loadingMore.value = false; checkingIn.value = false
generation++
if (session == null) { loggedIn.value = false; login() }
else { accountRefresh.value++ }
return false
}
const validPending = (value: any | null): CheckInRequest | null => {
if (value == null || typeof value != 'object' || Array.isArray(value)) return null
const data = value as UTSJSONObject
const requestId = data.getString('requestId') ?? ''; const expectedRuleVersion = data.getString('expectedRuleVersion') ?? ''; const expectedDate = data.getString('expectedDate') ?? ''
if (!/^pts_[0-9a-f]{32}$/.test(requestId) || !/^[a-z][a-z0-9-]{1,63}$/.test(expectedRuleVersion) || !/^\d{4}-\d{2}-\d{2}$/.test(expectedDate)) return null
return { requestId, expectedRuleVersion, expectedDate }
}
const restorePending = (): void => { pendingRequest.value = validPending(getUserStorage(pendingKey)) }
async function load(): Promise<void> {
if (!visible) return
const session = getSession()
if (session == null) { loggedIn.value = false; resetData(); login(); return }
const token = session.getString('token') ?? ''; const ticket = ++generation
loggedIn.value = true; loading.value = true; loadingMore.value = false; checkingIn.value = false; errorMessage.value = ''; resetData()
viewToken = token; restorePending()
try {
const summary = await fetchPointsSummary(); if (!current(ticket, token)) return
const currentRules = await fetchPointsRules(); if (!current(ticket, token)) return
const taskPage = await fetchPointsTasks(); if (!current(ticket, token)) return
const ledgerPage = await fetchPointsLedger(20); if (!current(ticket, token)) return
const checkInTask = taskPage.items.find((item: PointsTask): boolean => item.task == 'checkin')
if (summary.serverDate != taskPage.serverDate || summary.timezone != taskPage.timezone || summary.timezone != currentRules.timezone
|| summary.ruleVersion != currentRules.ruleVersion || summary.checkInReward != currentRules.checkInReward || checkInTask == null
|| checkInTask.ruleVersion != summary.ruleVersion || checkInTask.reward != summary.checkInReward
|| (checkInTask.status == 'completed') != (summary.ruleVersion != null && summary.signedToday)
|| (checkInTask.status == 'available') != (summary.checkInEnabled && !summary.signedToday)) throw new Error('积分状态已变化,请重新加载')
points.value = summary; rules.value = currentRules; serverTasks.value = taskPage.items
records.value = ledgerPage.items; snapshotId.value = ledgerPage.snapshotId; nextBeforeId.value = ledgerPage.nextBeforeId
} catch (error) {
if (!current(ticket, token)) return
errorMessage.value = error instanceof Error ? error.message : '积分读取失败,请重试'
} finally { if (ticket == generation && visible) loading.value = false }
}
watch(accountRefresh, () => { if (visible) load() })
const sameAccount = (): boolean => {
if (!visible) return false
const token = getSession()?.getString('token') ?? ''
if (token.length > 0 && token == viewToken) return true
load(); return false
}
const toggleRecords = (): void => { if (sameAccount()) recordsExpanded.value = !recordsExpanded.value }
const loadMore = async (): Promise<void> => {
if (loading.value || loadingMore.value || checkingIn.value || !hasMore.value || snapshotId.value.length == 0 || nextBeforeId.value == null) return
if (!sameAccount()) return
const session = getSession(); if (session == null) { login(); return }
const token = session.getString('token') ?? ''; const ticket = generation
const beforeCursor = nextBeforeId.value
if (beforeCursor == null) return
loadingMore.value = true
try {
const page = await fetchPointsLedger(20, snapshotId.value, beforeCursor)
if (!current(ticket, token)) return
records.value = records.value.concat(page.items); nextBeforeId.value = page.nextBeforeId
} catch (error) {
if (current(ticket, token)) errorMessage.value = error instanceof Error ? error.message : '积分明细读取失败,请重试'
}
finally { if (ticket == generation) loadingMore.value = false }
}
const showRules = (): void => {
if (!sameAccount()) return
const currentRules = rules.value
if (currentRules == null) { uni.showModal({ title: '积分规则', content: '积分规则暂时无法读取,请稍后重试。', showCancel: false, confirmText: '知道了' }); return }
if (currentRules.status != 'published' || currentRules.checkInReward == null || currentRules.ruleVersion == null) {
uni.showModal({ title: '积分规则', content: '积分服务尚未发布,签到和奖励暂未开放。', showCancel: false, confirmText: '知道了' }); return
}
uni.showModal({ title: '积分规则', content: '当前规则版本:' + currentRules.ruleVersion + '\n每日签到奖励:' + currentRules.checkInReward.toString() + ' 分\n每日限签到 1 次,不支持补签。', showCancel: false, confirmText: '知道了' })
}
const taskCaption = (item: TaskView): string => {
if (item.id == 'checkin' && checkingIn.value) return '正在确认…'
if (item.id == 'checkin' && pendingRequest.value != null) return '确认上次状态'
if (item.status == 'completed') return '今日已完成'
if (item.status == 'available' && item.reward != null) return '每日 +' + item.reward.toString()
return '暂未开放'
}
const checkIn = async (): Promise<void> => {
if (loading.value || loadingMore.value || checkingIn.value) return
if (!sameAccount()) return
const session = getSession(); if (session == null) { login(); return }
const summary = points.value
let request = pendingRequest.value
try {
if (request == null) {
if (summary == null) { await load(); return }
if (summary.signedToday) { uni.showToast({ title: '今天已签到', icon: 'none' }); return }
request = createCheckInRequest(summary); pendingRequest.value = request; setUserStorage(pendingKey, request)
}
} catch (error) { uni.showToast({ title: error instanceof Error ? error.message : '签到暂不可用', icon: 'none' }); return }
const token = session.getString('token') ?? ''; const ticket = generation; checkingIn.value = true; errorMessage.value = ''
try {
const receipt = await submitPointsCheckIn(request)
if (!current(ticket, token)) return
removeUserStorage(pendingKey); pendingRequest.value = null; uni.showToast({ title: receipt.replayed ? '已确认签到状态' : '签到成功 +' + receipt.delta.toString() + ' 分', icon: 'none' }); await load()
} catch (error) {
if (!current(ticket, token)) return
// 只有服务器明确拒绝且未入账的结果才能弃旧键;未知提交仍用原键确认。
if (error instanceof ApiRequestError && ((error.status == 409 && ['POINT_DATE_CHANGED', 'POINT_RULE_CHANGED', 'POINT_ALREADY_SIGNED', 'POINT_REQUEST_CONFLICT', 'POINT_BALANCE_INVALID'].includes(error.errorCode))
|| (error.status == 422 && error.errorCode == 'POINT_INPUT_INVALID') || (error.status == 503 && error.errorCode == 'POINT_RULE_UNAVAILABLE'))) {
removeUserStorage(pendingKey); pendingRequest.value = null
const message = error.message
await load()
if (visible && getSession()?.getString('token') == token) uni.showToast({ title: message, icon: 'none' })
} else errorMessage.value = error instanceof Error ? error.message : '签到请求未确认,可稍后重试'
} finally { if (ticket == generation) checkingIn.value = false }
}
const doTask = (item: TaskView): void => {
if (!sameAccount()) return
if (item.id == 'checkin') { checkIn(); return }
if (item.id == 'profile') { uni.navigateTo({ url: '/packages/finance/archive/archive' }); return }
uni.showToast({ title: '邀请奖励暂未开放', icon: 'none' })
}
const recordName = (item: PointsLedgerItem): string => item.businessType == 'checkin' ? '每日签到' : '历史积分记录'
const recordDate = (item: PointsLedgerItem): string => item.businessDate ?? item.createdAt ?? '日期待核实'
const deltaLabel = (item: PointsLedgerItem): string => item.delta >= 0 ? '+' + item.delta.toString() : item.delta.toString()
onShow(() => { visible = true; loginPending = false; load() })
onHide(() => { visible = false; generation++; loading.value = false; loadingMore.value = false; checkingIn.value = false; resetData() })
onUnload(() => { visible = false; generation++; loading.value = false; loadingMore.value = false; checkingIn.value = false; resetData() })
</script>
<style>
.points-content { padding: 24rpx 28rpx 48rpx; }
.points-hero { padding: 28rpx; border-radius: 20rpx; background-color: #5148d8; }
.points-hero-top { flex-direction: row; align-items: center; justify-content: space-between; }
.points-label { font-size: 26rpx; color: #f0efff; }
.points-number { font-size: 74rpx; line-height: 94rpx; font-weight: 700; color: #ffffff; margin-top: 12rpx; }
.points-note { color: #e5e1ff; font-size: 22rpx; line-height: 34rpx; margin-top: 4rpx; }
.points-hero-button { width: 144rpx; height: 56rpx; line-height: 56rpx; padding: 0; margin: 0; border-radius: 28rpx; color: #5148d8; background-color: #ffffff; font-size: 23rpx; }
.task-grid { min-height: 186rpx; flex-direction: row; background-color: #ffffff; border-radius: 18rpx; margin-top: 20rpx; }
.task { flex: 1; align-items: center; justify-content: center; padding: 24rpx 0; }
.task-name { margin-top: 14rpx; font-size: 24rpx; color: #172640; font-weight: 600; }
.task-caption { margin-top: 7rpx; font-size: 22rpx; color: #66758a; }
.completed-caption { color: #168454; }
.invite-panel, .records { margin-top: 22rpx; padding: 24rpx; border-radius: 18rpx; background-color: #ffffff; }
.panel-title { color: #172640; font-size: 30rpx; line-height: 42rpx; font-weight: 700; }
.invite-copy { margin-top: 12rpx; color: #66758a; font-size: 25rpx; line-height: 38rpx; }
.invite-code { min-height: 92rpx; padding: 12rpx 18rpx; margin-top: 22rpx; background-color: #f4f7fb; border-radius: 12rpx; flex-direction: row; align-items: center; }
.code-label { color: #66758a; font-size: 23rpx; }
.code { flex: 1; margin-left: 16rpx; color: #172640; font-size: 29rpx; font-weight: 700; }
.copy-action { padding: 12rpx 0 12rpx 16rpx; }
.copy-text { color: #0868f7; font-size: 24rpx; }
.invite-button { margin-top: 22rpx; }
.invite-note { color: #66758a; font-size: 22rpx; line-height: 34rpx; margin-top: 14rpx; }
.record-head { flex-direction: row; justify-content: space-between; align-items: center; min-height: 52rpx; }
.record-toggle { flex-direction: row; align-items: center; padding-left: 20rpx; }
.more { color: #0868f7; font-size: 23rpx; margin-right: 6rpx; }
.record-arrow { transform: rotate(90deg); }
.expanded-arrow { transform: rotate(-90deg); }
.record { min-height: 116rpx; padding: 18rpx 0; flex-direction: row; justify-content: space-between; align-items: center; border-bottom: 1rpx solid #edf1f6; }
.last-record { border-bottom-width: 0; }
.record-copy { flex: 1; }
.record-name { font-size: 27rpx; color: #172640; }
.record-date { color: #66758a; font-size: 22rpx; line-height: 34rpx; margin-top: 8rpx; }
.record-score { color: #168454; font-size: 30rpx; font-weight: 700; margin-left: 16rpx; }
.empty-records { padding: 28rpx 0 12rpx; }
.empty-record-title { color: #66758a; font-size: 26rpx; }
.empty-record-copy { color: #66758a; font-size: 23rpx; line-height: 36rpx; margin-top: 10rpx; }
.points-content { padding: 24rpx 28rpx 48rpx; }
.points-hero { padding: 28rpx; border-radius: 20rpx; background-color: #5148d8; }
.points-hero-top { flex-direction: row; align-items: center; justify-content: space-between; }
.points-label { font-size: 26rpx; color: #f0efff; }
.points-number { font-size: 74rpx; line-height: 94rpx; font-weight: 700; color: #ffffff; margin-top: 12rpx; }
.points-note { color: #e5e1ff; font-size: 22rpx; line-height: 34rpx; margin-top: 4rpx; }
.points-hero-button { width: 144rpx; height: 56rpx; line-height: 56rpx; padding: 0; margin: 0; border-radius: 28rpx; color: #5148d8; background-color: #ffffff; font-size: 23rpx; }
.state-panel { padding: 100rpx 28rpx; align-items: center; background-color: #ffffff; border-radius: 18rpx; }
.state-title { margin-top: 24rpx; color: #172640; font-size: 30rpx; font-weight: 700; }
.state-copy { margin-top: 12rpx; color: #66758a; font-size: 24rpx; line-height: 36rpx; text-align: center; }
.state-button { min-width: 250rpx; height: 76rpx; line-height: 76rpx; margin-top: 28rpx; border-radius: 14rpx; color: #ffffff; background-color: #0868f7; font-size: 26rpx; }
.inline-error { min-height: 76rpx; padding: 14rpx 20rpx; margin-top: 18rpx; border-radius: 12rpx; background-color: #fff4f2; flex-direction: row; align-items: center; }
.inline-error-copy { flex: 1; color: #b0473e; font-size: 23rpx; line-height: 34rpx; }
.inline-error-action { color: #0868f7; font-size: 23rpx; margin-left: 18rpx; }
.task-grid { min-height: 186rpx; flex-direction: row; background-color: #ffffff; border-radius: 18rpx; margin-top: 20rpx; }
.task { flex: 1; align-items: center; justify-content: center; padding: 24rpx 0; }
.task-name { margin-top: 14rpx; font-size: 24rpx; color: #172640; font-weight: 600; }
.task-caption { margin-top: 7rpx; font-size: 22rpx; color: #66758a; }
.completed-caption { color: #168454; }
.invite-panel, .records { margin-top: 22rpx; padding: 24rpx; border-radius: 18rpx; background-color: #ffffff; }
.panel-title { color: #172640; font-size: 30rpx; line-height: 42rpx; font-weight: 700; }
.invite-copy { margin-top: 12rpx; color: #66758a; font-size: 25rpx; line-height: 38rpx; }
.record-head { flex-direction: row; justify-content: space-between; align-items: center; min-height: 52rpx; }
.record-toggle { flex-direction: row; align-items: center; padding-left: 20rpx; }
.more { color: #0868f7; font-size: 23rpx; margin-right: 6rpx; }
.record-arrow { transform: rotate(90deg); }
.expanded-arrow { transform: rotate(-90deg); }
.record { min-height: 116rpx; padding: 18rpx 0; flex-direction: row; justify-content: space-between; align-items: center; border-bottom: 1rpx solid #edf1f6; }
.last-record { border-bottom-width: 0; }
.record-copy { flex: 1; }
.record-name { font-size: 27rpx; color: #172640; }
.record-date { color: #66758a; font-size: 22rpx; line-height: 34rpx; margin-top: 8rpx; }
.record-score { color: #168454; font-size: 30rpx; font-weight: 700; margin-left: 16rpx; }
.negative-score { color: #b0473e; }
.empty-records { padding: 28rpx 0 12rpx; }
.empty-record-title { color: #66758a; font-size: 26rpx; }
.empty-record-copy { color: #66758a; font-size: 23rpx; line-height: 36rpx; margin-top: 10rpx; }
.load-more { height: 72rpx; line-height: 72rpx; margin-top: 18rpx; border-radius: 12rpx; color: #0868f7; background-color: #f2f6fc; font-size: 24rpx; }
</style>
+13 -1
View File
@@ -6,6 +6,16 @@ export { API_BASE_URL, API_DEMO_ENABLED } from './config.uts'
const REQUEST_TIMEOUT = 10000
const SESSION_KEY = 'aiFinanceSession'
export class ApiRequestError extends Error {
status: number
errorCode: string
constructor(message: string, status: number, errorCode: string) {
super(message)
this.status = status
this.errorCode = errorCode
}
}
const wait = (duration: number) => new Promise<void>((resolve) => { setTimeout(() => resolve(), duration) })
export function saveSession(token: string, phone: string, userId: string, expiresAt: string): void {
@@ -74,7 +84,9 @@ const requestJson = (path: string, method: RequestMethod, payload: UTSJSONObject
const message = body['msg'] as string | null
if (response.statusCode < 200 || response.statusCode >= 300 || code != 1) {
if (code == 401 && sentToken.length > 0) clearSession()
reject(new Error(message != null && message.length > 0 ? message : '请求失败,请稍后重试')); return
const details = body['data']
const errorCode = details != null && typeof details == 'object' ? (details as UTSJSONObject).getString('errorCode') ?? '' : ''
reject(new ApiRequestError(message != null && message.length > 0 ? message : '请求失败,请稍后重试', response.statusCode, errorCode)); return
}
const data = body['data']
if (data == null) resolve(new UTSJSONObject())
+3 -1
View File
@@ -2,8 +2,10 @@
export function clearAccountCache(userId: string): void {
if (userId.length == 0) return
const prefix = 'airz:v1:user:' + userId + ':'
const pendingCheckIn = prefix + 'finance.points.checkin.pending'
uni.getStorageInfoSync().keys.forEach((key: string) => {
if (key.startsWith(prefix)) uni.removeStorageSync(key)
// 未确认的签到是恢复用命令上下文,仅保留原键/业务日/规则,重新登录后仍须服务端鉴权。
if (key.startsWith(prefix) && key != pendingCheckIn) uni.removeStorageSync(key)
})
}
+145
View File
@@ -0,0 +1,145 @@
import { getSession, requestJson } from '@/services/api.uts'
export type PointsSummary = {
balance: number, serverDate: string, timezone: string, signedToday: boolean,
checkInEnabled: boolean, ruleVersion: string | null, checkInReward: number | null, balanceState: string
}
export type PointsRules = { status: string, ruleVersion: string | null, checkInReward: number | null, timezone: string, publishedRules: UTSJSONObject | null }
export type PointsTask = { task: string, status: string, reward: number | null, ruleVersion: string | null }
export type PointsLedgerItem = {
ledgerId: string, delta: number, balanceBefore: number, balanceAfter: number,
businessType: string, businessDate: string | null, ruleVersion: string | null, createdAt: string | null
}
export type PointsLedgerPage = { items: PointsLedgerItem[], snapshotId: string, nextBeforeId: string | null }
export type PointsTaskPage = { serverDate: string, timezone: string, items: PointsTask[] }
export type CheckInRequest = { requestId: string, expectedRuleVersion: string, expectedDate: string }
export type CheckInReceipt = { ledgerId: string, delta: number, balanceAfter: number, signedDate: string, ruleVersion: string, replayed: boolean }
const text = (data: UTSJSONObject, key: string): string => data.getString(key) ?? ''
const nullableText = (data: UTSJSONObject, key: string): string | null => data.getString(key)
const integer = (data: UTSJSONObject, key: string): number => {
const value = data.getNumber(key)
if (value == null || !Number.isInteger(value)) throw new Error('积分响应格式无效')
return value
}
const nullableInteger = (data: UTSJSONObject, key: string): number | null => {
const value = data.getNumber(key)
if (value != null && (!Number.isInteger(value) || value < 1 || value > 1000)) throw new Error('积分奖励格式无效')
return value
}
const requiredDate = (value: string): string => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error('积分日期格式无效')
return value
}
const validId = (value: string, allowZero: boolean): boolean => {
if (!(allowZero ? /^(?:0|[1-9][0-9]{0,9})$/.test(value) : /^[1-9][0-9]{0,9}$/.test(value))) return false
return !(value.length == 10 && value > '4294967295')
}
const requiredId = (value: string): string => {
if (!validId(value, false)) throw new Error('积分流水编号无效')
return value
}
const snapshotId = (value: string): string => {
if (!validId(value, true)) throw new Error('积分快照编号无效')
return value
}
const optionalId = (value: string | null): string | null => value == null || value.length == 0 ? null : requiredId(value)
const ensureSession = (): void => { if (getSession() == null) throw new Error('请先登录后查看积分') }
const ensureStatus = (value: string): string => {
if (!['draft', 'published', 'paused'].includes(value)) throw new Error('积分规则状态无效')
return value
}
const ensureTaskStatus = (value: string): string => {
if (!['available', 'completed', 'unavailable'].includes(value)) throw new Error('积分任务状态无效')
return value
}
function summaryFrom(data: UTSJSONObject): PointsSummary {
const serverDate = requiredDate(text(data, 'serverDate'))
const ruleVersion = nullableText(data, 'ruleVersion')
const reward = nullableInteger(data, 'checkInReward')
const balanceState = text(data, 'balanceState')
if (!['normal', 'anomaly'].includes(balanceState)) throw new Error('积分余额状态无效')
const signedToday = data.getBoolean('signedToday')
const checkInEnabled = data.getBoolean('checkInEnabled')
if (signedToday == null || checkInEnabled == null) throw new Error('积分响应格式无效')
return { balance: integer(data, 'balance'), serverDate, timezone: text(data, 'timezone'), signedToday, checkInEnabled, ruleVersion, checkInReward: reward, balanceState }
}
export async function fetchPointsSummary(): Promise<PointsSummary> {
ensureSession()
return summaryFrom(await requestJson('/points', 'GET', {}))
}
export async function fetchPointsRules(): Promise<PointsRules> {
ensureSession()
const data = await requestJson('/points/rules', 'GET', {})
const published = data['publishedRules']
return {
status: ensureStatus(text(data, 'status')), ruleVersion: nullableText(data, 'ruleVersion'),
checkInReward: nullableInteger(data, 'checkInReward'), timezone: text(data, 'timezone'),
publishedRules: published != null && typeof published == 'object' ? published as UTSJSONObject : null
}
}
function ledgerFrom(data: UTSJSONObject): PointsLedgerItem {
const businessDate = nullableText(data, 'businessDate')
const businessType = text(data, 'businessType')
if (businessDate != null) requiredDate(businessDate)
if (!['checkin', 'legacy'].includes(businessType)) throw new Error('积分流水类型无效')
return {
ledgerId: requiredId(text(data, 'ledgerId')), delta: integer(data, 'delta'), balanceBefore: integer(data, 'balanceBefore'), balanceAfter: integer(data, 'balanceAfter'),
businessType, businessDate, ruleVersion: nullableText(data, 'ruleVersion'), createdAt: nullableText(data, 'createdAt')
}
}
export async function fetchPointsLedger(limit: number = 50, snapshot: string = '', before: string = ''): Promise<PointsLedgerPage> {
ensureSession()
if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new Error('积分流水分页参数无效')
const payload: UTSJSONObject = { limit: limit.toString() }
if (snapshot.length > 0) payload['snapshotId'] = snapshotId(snapshot)
if (before.length > 0) payload['beforeId'] = requiredId(before)
const data = await requestJson('/points/ledger', 'GET', payload)
const rows = data['items']
if (rows == null || !Array.isArray(rows)) throw new Error('积分流水格式无效')
return {
items: (rows as UTSJSONObject[]).map((row: UTSJSONObject): PointsLedgerItem => ledgerFrom(row)),
snapshotId: snapshotId(text(data, 'snapshotId')), nextBeforeId: optionalId(data.getString('nextBeforeId'))
}
}
export async function fetchPointsTasks(): Promise<PointsTaskPage> {
ensureSession()
const data = await requestJson('/points/tasks', 'GET', {})
const rows = data['items']
if (rows == null || !Array.isArray(rows)) throw new Error('积分任务格式无效')
return {
serverDate: requiredDate(text(data, 'serverDate')), timezone: text(data, 'timezone'),
items: (rows as UTSJSONObject[]).map((row: UTSJSONObject): PointsTask => ({
task: text(row, 'task'), status: ensureTaskStatus(text(row, 'status')), reward: nullableInteger(row, 'reward'), ruleVersion: nullableText(row, 'ruleVersion')
}))
}
}
export function createCheckInRequest(summary: PointsSummary): CheckInRequest {
if (!summary.checkInEnabled || summary.signedToday || summary.ruleVersion == null || summary.checkInReward == null) throw new Error('签到活动暂不可用')
let value = ''
for (let index = 0; index < 32; index++) value += Math.floor(Math.random() * 16).toString(16)
return { requestId: 'pts_' + value, expectedRuleVersion: summary.ruleVersion, expectedDate: summary.serverDate }
}
export async function submitPointsCheckIn(input: CheckInRequest): Promise<CheckInReceipt> {
ensureSession()
if (!/^pts_[0-9a-f]{32}$/.test(input.requestId) || !/^[a-z][a-z0-9-]{1,63}$/.test(input.expectedRuleVersion)) throw new Error('签到请求参数无效')
requiredDate(input.expectedDate)
const data = await requestJson('/points/check-in', 'POST', { requestId: input.requestId, expectedRuleVersion: input.expectedRuleVersion, expectedDate: input.expectedDate })
const replayed = data.getBoolean('replayed')
if (replayed == null) throw new Error('签到回执格式无效')
const receipt: CheckInReceipt = {
ledgerId: requiredId(text(data, 'ledgerId')), delta: integer(data, 'delta'), balanceAfter: integer(data, 'balanceAfter'),
signedDate: requiredDate(text(data, 'signedDate')), ruleVersion: text(data, 'ruleVersion'), replayed
}
if (receipt.delta < 1 || receipt.delta > 1000 || receipt.balanceAfter < 0 || receipt.balanceAfter > 2147483647) throw new Error('签到回执格式无效')
if (receipt.signedDate != input.expectedDate || receipt.ruleVersion != input.expectedRuleVersion) throw new Error('签到回执与请求不一致')
return receipt
}