Commit Graph

946 Commits

Author SHA1 Message Date
hailin fda79304c6 Revert "fix(ledger): 分享收益筛选支持多类型(REWARD_TO_SETTLEABLE + REWARD_PENDING)"
This reverts commit d223671db7.
2026-03-01 11:07:19 -08:00
hailin d223671db7 fix(ledger): 分享收益筛选支持多类型(REWARD_TO_SETTLEABLE + REWARD_PENDING)
后端 entryType 筛选支持逗号分隔多值,前端"分享收益"同时查询两种类型,
解决未认种时收到的 REWARD_PENDING 分享权益在筛选中丢失的问题。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:02:08 -08:00
hailin e4a2a0e37a feat(ledger): 流水明细显示来源用户ID + 统计兼容历史批量转换数据
- wallet_service.dart: LedgerEntry 新增 sourceAccountFromMemo 从 memo 提取来源用户
- ledger_detail_page.dart: 流水列表项显示"来自 Dxxx"金色文字
- ledger_detail_page.dart: 权益详情弹窗添加来源用户行和备注行
- wallet-application.service.ts: 统计/趋势保留 memo 兼容历史批量转换记录

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:41:11 -08:00
hailin d876dd1591 fix(wallet): 区分直接入账和批量转换的 REWARD_TO_SETTLEABLE
新增 REWARD_PENDING_CONVERTED 类型用于批量转换(待领取→可结算),
REWARD_TO_SETTLEABLE 保留给直接入账(hasPlanted=true时的新收入)。

统计排除:REWARD_PENDING_CONVERTED + REWARD_SETTLED(状态转换)
统计计入:REWARD_PENDING + REWARD_TO_SETTLEABLE(首次入账)

已迁移7条历史数据的 entry_type。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:30:47 -08:00
hailin 31e6f9e15a fix(mobile): 预种可结算列表以wallet实际金额为准,避免已结算条目残留
planting-service 的分配记录不跟踪 wallet 端的结算状态,
原来的反向排除法(排除PENDING+EXPIRED)无法处理:
1. 已结算到余额(SETTLED→余额)的条目
2. hasPlanted=true 后直接进可结算、不经 pending_rewards 的条目

改为以 walletInfo.rewards.settleableUsdt 为权威来源:
- settleableUsdt=0 时直接跳过(已全部结算到余额)
- settleableUsdt>0 时按金额截断,确保展示总额不超过实际可结算

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:19:31 -08:00
hailin 8e52535dd9 feat(mobile): 预种明细显示来源用户ID + 修复空待领取倒计时
1. 预种待领取/可结算明细的 memo 中追加来源用户(如"来自D26022600016的预种")
2. 修复 pendingUsdt=0 时倒计时仍然显示的问题(pending_expire_at 未清除时兜底)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:48:34 -08:00
hailin b13d873f64 feat(mobile): 流水明细中 REWARD_PENDING 标注"已转可结算"
用户购买预种后 hasPlanted=true,所有 PENDING 奖励转为 SETTLED,
此时流水中历史的 REWARD_PENDING 条目追加"(已转可结算)"标注,
避免用户误以为还有未领取的奖励。仅在 pendingUsdt=0 时显示。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:15:18 -08:00
hailin e9b9896317 fix(mobile): 修复预种可结算列表与金额不一致
问题:pre-planting/my-rewards 返回所有分配记录不区分状态,
导致 PENDING 状态的预种奖励也被错误地显示在可结算列表中,
而可结算金额(从 wallet-service 取值)正确为 0,造成列表和金额不一致。

修复:在合并预种可结算列表时,排除正在 PENDING 和 EXPIRED 状态的条目。
通过 wallet/pending-rewards 和 wallet/expired-rewards 获取实际状态,
用 sourceOrderId 交叉比对,只保留真正可结算的预种条目。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 08:24:03 -08:00
hailin 4996c1d110 feat(mobile): profile页待领取/可结算/已过期列表统一显示预种数据
变更概要:
- wallet_service.dart: 新增 WalletPendingRewardItem 模型和 getWalletPendingRewards() 方法
  调用 GET /wallet/pending-rewards 获取 wallet-service 的待领取奖励列表
- profile_page.dart: 合并预种待领取奖励到列表中
  从 wallet-service 待领取列表中筛选 PPL 前缀的预种条目,转换为 PendingRewardItem
  与 reward-service 的正常认种待领取统一展示
- profile_page.dart: 已过期列表标记预种条目
  wallet-service GET /wallet/expired-rewards 已包含预种过期记录,
  渲染时通过 sourceOrderId.startsWith('PPL') 动态添加 [预种] 前缀
- profile_page.dart: 所有汇总金额统一从 wallet-service 取值
  _pendingUsdt / _expiredUsdt / _remainingSeconds 改为从 walletInfo.rewards 读取,
  wallet_accounts 包含正常认种 + 预种,是唯一的 source of truth

技术说明:
- 后端零改动,仅前端变更(零风险)
- 预种条目通过订单号 PPL 前缀与正常认种区分,避免重复显示
- 所有预种条目在卡片上显示 [预种] 前缀,方便用户区分来源

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:52:02 -08:00
hailin 27cd72fe01 feat(pre-planting): 预种可结算收益结算 + 前端可结算金额修正
背景:
  预种奖励通过 planting-service → wallet-service allocateFunds 链路
  直接写入 wallet_accounts.settleable_usdt,不经过 reward-service。
  因此 reward-service 的一键结算(settleToBalance)无法覆盖预种部分,
  且 reward-service 的 summary.settleableUsdt 不包含预种金额。

改动:
1. wallet-service 新增 POST /wallet/settle-pre-planting 端点
   - 将 wallet 中剩余的 settleable_usdt 转入 available 余额
   - settleable_usdt=0 时幂等跳过,不创建空流水
   - 流水备注标注 [预种],payloadJson.source='pre-planting'

2. mobile-app 兑换页(trading_page):
   - 可结算金额改为从 wallet-service 的 wallet.rewards.settleableUsdt 取值
     (包含正常认种 + 预种的可结算部分,是唯一的 source of truth)
   - 一键结算流程改为两步串行:
     先调 reward-service settleToBalance(正常认种,不动现有逻辑),
     再调 wallet-service settle-pre-planting(预种部分,纯增量)

3. mobile-app 我的页(profile_page):
   - 并行加载新增 walletService.getMyWallet() 调用
   - _settleableUsdt 改为从 wallet.rewards.settleableUsdt 取值

不涉及的系统:
  - reward-service:零改动
  - planting-service:零改动
  - wallet-service 现有结算逻辑:零改动
  - admin-web:零改动

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:33:53 -08:00
hailin 05e590ef04 fix(pre-planting): 修复可结算收益重复计算
pre-planting getMyRewards API 错误地将所有分配记录金额算作
settleableUsdt(包括 PENDING 状态的待领取奖励)。
预种奖励的 PENDING/SETTLEABLE 状态由 wallet-service 管理,
reward-service 的 getMyRewardSummary 已包含预种可结算部分,
不应重复累加。

修复:
- 后端 getMyRewards 返回 settleableUsdt: 0
- 前端"我"页面和"兑换"页面不再额外加预种 settleableUsdt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:34:22 -08:00
hailin d1be7173be fix(admin-web): 引荐树节点点击导航到用户详情页
setTreeRootUser 只更新树不更新页面顶部信息,导致数据不同步。
改为 router.push 导航,页面整体刷新保持一致。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:13:42 -08:00
hailin cf07712a8c fix(mobile): 兑换页可结算收益聚合正常认种+预种金额
之前只显示正常认种的 settleableUsdt,未包含预种收益,
导致有预种收益但显示"暂无可结算收益"。
现在并行获取两者并累加,与"我"页面保持一致。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:11:13 -08:00
hailin 5392c47e47 fix(admin-web): 引荐关系树节点点击切换为该节点为根显示
点击节点后以该节点为根重新加载树,显示其祖先链和一级下线。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:00:45 -08:00
hailin e715fd2504 fix(admin-web): 引荐关系树节点点击跳转到用户详情页
之前点击只切换树根节点,不导航。改为 router.push 跳转。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 02:55:56 -08:00
hailin 292c6518ba fix(admin-web): 隐藏钱包流水表格的类型和关联订单列
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 02:49:59 -08:00
hailin 0ac131a3b7 fix(admin-web): 钱包流水表格备注列显示不全
各列均为 flex:1 等宽导致备注内容被 ellipsis 截断,
调整各列 flex 比例并允许备注列换行显示完整文本。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 02:41:31 -08:00
hailin 3be7b47678 feat(pre-planting+mobile): 预种奖励在"我"页面展示
## 问题
预种奖励直接走 planting-service → wallet-service,绕过 reward-service,
导致前端"我"页面读 reward-service 的 summary/settleable 数据时看不到预种奖励。

## 方案
前端同时读 reward-service(正常认种)和 planting-service(预种),合并展示。

## 后端(planting-service)
- PrePlantingRewardEntryRepository: 新增 findByRecipientAccountSequence() 方法,
  按收款方账户查询预种奖励记录(注入 PrismaService 替代事务 client)
- PrePlantingController: 新增 GET /pre-planting/my-rewards 端点,
  返回当前用户作为收款方收到的预种奖励汇总+明细列表
  格式与 reward-service 的 settleable 对齐(id, rightType, usdtAmount, sourceOrderNo 等)

## 前端(Flutter mobile-app)
- PrePlantingService: 新增 getMyRewards() 方法 + PrePlantingMyRewards/PrePlantingRewardItem 数据类
- profile_page.dart: 并行调用 prePlantingService.getMyRewards(),
  将预种奖励转为 SettleableRewardItem 合并到可结算列表,
  summary.settleableUsdt 也加上预种金额

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:03:53 -08:00
hailin 6a659ca718 feat(admin-web): 省/市区域账户添加点击查看明细功能
RegionAccountsSection 新增:
- 每行添加"查看明细"按钮,点击展开该账户分类账流水
- 明细表包含 时间/类型/金额/余额/来源账户/来源备注/备注 7列
- 复用 getAllLedger API 的 provinceAccountsLedger/cityAccountsLedger 数据
- 行点击和按钮点击均可展开/收起
- 新增 clickableRow/selectedRow CSS 样式

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:37:12 -08:00
hailin bf50810830 feat(wallet+admin-web): 系统账户流水增加来源用户账户和来源备注列
问题:系统账户(S0000000001等)、省/市区域/团队账户的流水明细
只显示 allocationType 英文标识,无法追溯是哪个用户的认种产生的。

解决方案:从 wallet_ledger_entries.payload_json.metadata 中提取
sourceAccountSequence 和 memo 字段,通过 API 返回给前端展示。

后端 wallet-service 改动:
- LedgerEntryDTO 新增 sourceAccountSequence / sourceMemo 两个可选字段
- 新增 extractPayloadInfo() 辅助函数统一从 payloadJson 提取信息
- 替换所有 5 处 LedgerEntryDTO 映射,使用 extractPayloadInfo()
- 向后兼容:旧记录无 metadata 时返回 null,不影响已有功能

前端 admin-web 改动:
- LedgerEntryDTO 类型新增 sourceAccountSequence / sourceMemo 字段
- 固定账户明细表格和分类账明细表格增加"来源账户"和"来源备注"列
- 新增 .sourceAccount 样式(等宽字体显示账户序列号)

数据来源说明:
- 正常认种:reward-service 传入 metadata 含完整中文 memo 和 sourceAccountSequence
- 预种:planting-service 传入 metadata 含 sourceAccountSequence 和中文 memo
- 历史记录(2026-01-04前):metadata 可能为空,显示为"-"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:22:40 -08:00
hailin 05aacc0d5b fix(admin-web): 系统账户流水备注列显示完整内容
移除 .memo 的 max-width/overflow/ellipsis/nowrap 截断样式,
改为 word-break: break-word 自动换行,确保备注全文可见。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:55:45 -08:00
hailin 62bbbca609 Revert "fix(ledger): REWARD_EXPIRED条目显示权益类型+已过期标签"
This reverts commit 4bd40970d0.
2026-02-28 12:04:07 -08:00
hailin 4bd40970d0 fix(ledger): REWARD_EXPIRED条目显示权益类型+已过期标签
- 奖励过期条目显示具体权益名(分享权益/省团队权益等)+ 红色"已过期"标签
- 图标改为灰色 timer_off,金额文字改为灰色,背景微灰
- 与正常权益收入条目有明显视觉区分

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 12:01:36 -08:00
hailin 724fb08be4 fix(contribution): 发布过期份额同步事件 + 管理后台/挖矿app状态显示
- contribution-service: swapContributionForMerge 作废旧份额记录后,
  立即发布 ContributionRecordSynced outbox 事件(isExpired=true),
  mining-admin-service 收到后 upsert syncedContributionRecord
- mining-admin-web: 算力记录状态列改为绿色"有效"/红色"无效"
- mining-app: 贡献值记录卡片始终显示有效/无效状态标签

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 11:35:55 -08:00
hailin a904c8bd42 feat(ledger): 预种份额在流水明细中显示合并合同下载按钮
- 移除硬编码"预种无合同"逻辑
- PPL 份额点击详情时,查找是否有对应的已签署合并记录
- 有签署合同则显示查看/下载按钮,调用预种合并合同 PDF 接口
- 同时新增 _viewMergeContractPdf / _downloadMergeContractPdf 方法

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 11:19:27 -08:00
hailin cd73b2dec4 fix(pre-planting): 签署合同前检查实名认证 + 修正合同金额
- getMergeContractPdf: KYC 为 null 时返回 400,不允许查看合同
- getMergeContractPdf: 从源订单汇总实际绿积分金额,CNY = 绿积分 × 1.1
- Flutter: KYC 错误时显示专用提示 + "去完成实名认证" 按钮

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 10:59:26 -08:00
hailin b1e5e6b29f feat(pre-planting): 合并合同走完整签署流程(PDF展示+手写签名)
- planting-service: 新增 GET /merges/:mergeNo/contract-pdf 接口,复用现有 PDF 模板
- planting-service: PrePlantingApplicationService 注入 PdfGeneratorService/IdentityServiceClient
- pre_planting_service.dart: 新增 downloadMergeContractPdf,signMergeContract 简化返回值
- 新建 PrePlantingMergeSigningPage:PDF展示→滚动到底→确认法律效力→手写签名→提交
- pending_contracts_page: 合并卡片点击跳签名页(prePlantingMergeSigning)
- pre_planting_merge_detail_page: 签署按钮跳签名页,移除直接调用逻辑
- 新增路由 /pre-planting/merge-signing/:mergeNo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 10:35:22 -08:00
hailin 2ad1936126 fix(pre-planting): 合并详情页 USDT 改为绿积分
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 10:17:12 -08:00
hailin 7bad0a8935 fix(pre-planting): 修复编译错误(getMerges→getMyMerges、RoutePaths 缺失导入、Future.wait 类型)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 10:00:17 -08:00
hailin b9b23c36d7 feat(pre-planting): 合并后走正常签合同流程,购买第5份直接跳合并详情页
- pre_planting_service: CreatePrePlantingOrderResponse 增加 merged/mergeNo 字段
- pre_planting_purchase_page: 购买成功若触发合并,直接跳转合并详情签合同
- contract_check_service: 注入 PrePlantingService,checkAll 增加预种待签合并检查
- pending_contracts_page: 同时展示普通合同和预种合并待签卡片,复用现有签合同弹窗流程
- injection_container: contractCheckServiceProvider 注入 prePlantingService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 09:51:21 -08:00
hailin 26dcd1d2de fix(pre-planting): 修复购买省市名称存储及多项购买失败问题
== 问题修复 ==

1. 购买失败:NestJS 返回数组 message 导致 Flutter 类型转换错误
   - 症状:List<dynamic> is not a subtype of String
   - 原因:ValidationPipe 校验失败时 message 字段为 List<String>(每条字段错误一条),
     Flutter _handleDioError 直接用 data['message'] 作为 String 参数导致运行时崩溃
   - 修复:api_client.dart 中对 rawMsg 判断是否 List,若是则 join(', ')

2. 续购省市为空导致 400 校验失败
   - 症状:续购时后端返回 "provinceCode should not be empty"
   - 原因:购买页面续购分支未传入省市,导致 provinceCode/cityCode 为 null
   - 修复:pre_planting_purchase_page.dart 中续购时使用 _position?.provinceCode

3. 购买请求携带 provinceName/cityName 被后端 forbidNonWhitelisted 拒绝
   - 症状:400 "property provinceName should not exist"
   - 原因:前端发送名称字段,但 PurchasePrePlantingDto 未声明这些字段
   - 修复:在 DTO 中添加 @IsOptional() 的 provinceName / cityName 字段

== 功能新增 ==

4. 预种持仓表新增省市名称存储(参照正式认种的处理方式)
   - 迁移:20260228000000_add_province_city_name_to_position
   - Prisma schema:PrePlantingPosition 新增 provinceName / cityName 可空字段
   - 聚合根:addPortions() 接受可选 provinceName/cityName,首购时写入,续购忽略
   - Repository:save/toDomain 同步处理名称字段
   - Application Service:purchasePortion 透传名称,getPosition 返回名称
   - Controller:purchase 端点透传 dto.provinceName / dto.cityName

5. 预种合并时算力精确回滚(contribution-service)
   - 新增 9a-team 步骤:事务内查询即将作废的 TEAM_LEVEL/TEAM_BONUS 算力记录
   - 新增 9c-team 步骤:按账户聚合后精确 decrement 上游推荐人的各档位 pending 和 effective
   - 目的:确保旧份额算力精确回滚,避免新树算力 9d 叠加后造成双倍计入

== UI 优化 ==
   - 购买页面将 "USDT" 改为 "绿积分"(单价、总价、成功提示)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 08:02:14 -08:00
hailin eea38b2b86 fix(pre-planting): 购买页面和弹窗中 USDT 改为绿积分
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 07:20:51 -08:00
hailin 9b6effe63d debug(pre-planting): 添加购买流程详细日志以排查 List cast 错误
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 07:19:35 -08:00
hailin 606d3c0b22 fix(pre-planting): 修复购买失败时 List<dynamic> 类型转换错误和续购省市缺失
1. api_client.dart: NestJS validation error 返回 message 为数组时,
   用 join(', ') 转为字符串,避免直接传给 ApiException(String) 崩溃
2. pre_planting_purchase_page.dart: 续购时传 _position 中已保存的
   provinceCode/cityCode,满足后端 DTO 必填校验

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 06:56:49 -08:00
hailin 2d7b02aa96 fix(pre-planting): 修复预种页面 5 个 UI 问题(纯前端,零后端改动)
=== 问题 1:流水明细合同按钮 500 错误 ===
文件: ledger_detail_page.dart
原因: 预种订单(PPL 前缀)无合同,但流水详情弹窗显示「查看合同/下载合同」按钮
修复: _showTransactionDetail 检测 refOrderId.startsWith('PPL'),
      预种订单传 showContractButtons: false,弹窗不渲染合同按钮区

=== 问题 2:流水备注显示英文 ===
文件: ledger_detail_page.dart
原因: 备注字段存储的是 'Plant payment (from frozen)'(后端写入,不改后端)
修复: _TransactionDetailSheet 展示备注时,若订单号以 PPL 开头则显示「预种」

=== 问题 3:预种明细订单金额单位错误 ===
文件: pre_planting_position_page.dart
修复: '${order.totalAmount.toInt()} USDT' → '${order.totalAmount.toInt()} 绿积分'

=== 问题 4:省市显示数字代码(如 44 · 4401)===
文件: pre_planting_position_page.dart / pre_planting_purchase_page.dart
原因: provinceName/cityName 为 null 时回退显示 provinceCode/cityCode
修复:
  - position 页:条件改为 provinceName != null && cityName != null,无中文名则不显示省市行
  - purchase 页:加载时不再 fallback 到代码;锁定显示无名称时显示「已锁定」;
    购买确认弹窗省市行无名称时显示「-」

=== 问题 5:「合并进度」改为「合成进度」===
文件: pre_planting_position_page.dart / pre_planting_purchase_page.dart
修复: 两处 Text('合并进度') → Text('合成进度')

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 06:25:38 -08:00
hailin 97f8b7339f fix(auth): LOGIN 能力禁用后强制下线已登录用户
## 问题
管理员在后台禁用用户的 LOGIN 能力后,该用户仍然可以正常使用 mining-app。
原因是 LOGIN 检查只在登录/刷新 token 时执行,已持有有效 JWT(7天有效期)
的用户不会被影响,直到 token 过期才会被拦截。

## 修复

### 后端 - JwtAuthGuard (auth-service)
- 在 JWT 验证通过后,增加 LOGIN 能力实时检查
- 调用 CapabilityService.isCapabilityEnabled() 查询 Redis 缓存
- LOGIN 被禁用时返回 403 ForbiddenException("您的账户已被限制登录")
- 采用 fail-open 策略:Redis/DB 查询失败时放行,不影响正常用户
- 每次认证请求多一次 Redis GET(<1ms),对当前用户规模无性能影响

### 前端 - mining-app API Client
- 新增 onLoginDisabled 全局回调(类似现有的 onUnauthorized)
- Dio 拦截器检测 403 响应中包含"限制登录"关键词时触发回调
- 回调执行:清除用户状态 + 跳转登录页(与 401 处理一致)

## 影响范围
- 所有使用 @UseGuards(JwtAuthGuard) 的端点都会实时检查 LOGIN 能力
- 管理员禁用 LOGIN 后,用户下一次 API 请求即被拦截并强制下线
- 不影响公开端点(登录、注册等不经过 JwtAuthGuard 的接口)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 05:01:37 -08:00
hailin fe9a30df85 feat(mining-app): 接入 capabilitiesProvider 实现 UI 层能力适配
Phase 5 补充:在 mining-app Flutter 前端关键页面接入 capability 检查,
被禁用的功能按钮置灰并显示 SnackBar 提示,后端 403 拦截作为兜底。

修改的页面及对应能力:
- send_shares_page: P2P_SEND → "确认发送"按钮
- receive_shares_page: P2P_RECEIVE → 顶部限制提示横幅
- c2c_market_page: C2C → "发布"按钮 + "接单"弹窗入口
- c2c_publish_page: C2C → "发布买入/卖出"按钮
- trading_page: TRADING → "确认交易"按钮
- edit_profile_page: PROFILE_EDIT → "保存"操作
- team_page: VIEW_TEAM → 页面数据加载前检查

设计原则:
- 不阻断页面浏览,只阻断操作
- fail-open: 能力获取失败时默认全部开启
- 禁用时点击按钮弹出 SnackBar 提示"您的XX功能已被限制"
- 沿用现有 disabled 按钮样式(.withOpacity(0.4))

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 01:36:24 -08:00
hailin 55cfc96464 feat(capability): 实现用户能力权限控制系统(Capability-based Permission)
借鉴 Stripe Capability 模型,实现 13 项细粒度用户功能权限控制:
LOGIN, TRADING, C2C, TRANSFER_IN/OUT, P2P_SEND/RECEIVE,
MINING_CLAIM, KYC, PROFILE_EDIT, VIEW_ASSET/TEAM/RECORDS

## 架构设计
- auth-service 为能力数据唯一写入点(DB + Redis DB14 缓存)
- 下游服务通过独立 ioredis 客户端直连 Redis DB14 检查能力(~1ms)
- 默认全部开启(fail-open):无缓存/Redis 故障 = 允许通行
- Guard 执行顺序:JwtAuthGuard → CapabilityGuard

## Phase 1: auth-service 核心
- Prisma Schema: UserCapability + CapabilityLog 两张表
- Domain: Capability 枚举, CapabilityMap 类型, Repository 接口
- Infrastructure: PrismaCapabilityRepository(含 $transaction 原子操作)
- Application: CapabilityService(Redis 缓存优先 → DB fallback → 写回 Redis TTL 1h)
- Scheduler: 每 60 秒扫描到期限制自动恢复(Redis 分布式锁防重复)
- API: GET /auth/user/capabilities (JWT), Internal CRUD API (服务间)
- 登录/refreshToken 均增加 LOGIN 能力检查

## Phase 2: 下游 CapabilityGuard
- trading-service: 14 个端点标注(TRADING/C2C/TRANSFER/P2P_SEND/VIEW_ASSET)
- contribution-service: 3 个端点标注(VIEW_RECORDS/VIEW_TEAM)
- mining-service: Guard 注册 + JwtAuthGuard accountSequence 兼容修复
- auth-service: KYC 端点标注(controller 级别 UseGuards)

## Phase 3: mining-admin-service
- CapabilityAdminService: 代理 auth-service internal API + 本地 AuditLog
- CapabilityController: Admin CRUD + 批量设置 + 变更日志查询

## Phase 4: mining-admin-web
- capability-management.tsx: 分组 Switch 开关 + 禁用 Dialog(原因+到期时间)+ 变更日志分页
- React Query hooks: useCapabilities/useSetCapability/useCapabilityLogs
- 用户详情页新增"权限管理"Tab

## Phase 5: mining-app (Flutter)
- CapabilityMap 数据模型 + ForbiddenException 异常类
- api_client.dart: 403 响应适配 ExceptionFilter 包装格式
- capabilitiesProvider: 登录后获取能力列表,fail-open 降级

## 审计修复
- CRITICAL: users.api.ts capability 方法移入 usersApi 对象内部
- P0: Flutter 403 解析路径适配 {error:{code,message}} 实际格式
- P0: 批量接口 operatorId 提升到 body 顶层匹配 auth-service 契约
- P1: mining-service JwtAuthGuard accountSequence fallback payload.sub
- P1: refreshCache 加 try/catch 防止 Redis 故障导致 500
- P1: processExpiredRestrictions 改用 upsertWithLog 事务方法
- P1: C2C upload-proof 补加 @RequireCapability('C2C')
- HIGH: internal.controller.ts 新增 capability 枚举校验
- HIGH: admin capability.controller.ts adminId fallback + query params 类型修复
- MEDIUM: setCapability 改用 $transaction 保证 upsert+log 原子性

## 部署注意
- 需运行: cd auth-service && npx prisma migrate dev --name add_user_capabilities
- 需配置: mining-admin-service .env AUTH_SERVICE_URL=http://auth-service:3010

## 待后续处理(P2)
- P2P_RECEIVE 需在业务逻辑层检查(收款方无主动请求)
- MINING_CLAIM/PROFILE_EDIT 待对应端点实现后标注
- getCapabilities 返回 Map 转 Array 丢失 reason/expiresAt 详细字段

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:19:56 -08:00
hailin 1d1c60e2a2 feat(notification): 新增强制阅读弹窗功能(管理员可配置 requiresForceRead)
## 功能概述

在不影响任何现有业务的前提下,新增"强制阅读弹窗"功能:
- 管理员创建通知时可勾选「需要强制弹窗阅读」
- App 冷启动进入主页 或 从后台切回前台时自动触发检查
- 存在未读且标记 requiresForceRead=true 的通知时,依次逐条弹窗
- 用户无法通过点击背景或返回键关闭弹窗(强制阅读)
- 最后一条通知弹窗底部显示 checkbox「我已经阅读并知晓」
  - 未勾选时"确定"按钮置灰禁用
  - 勾选后"确定"变为金色可点击,点击后所有弹窗消失
- 全部看完后仅对已展示的强制阅读通知按 ID 逐一标记已读
  (不影响普通未读通知的 badge 计数)

## 涉及改动

### 后端 admin-service

- `prisma/schema.prisma`
  - Notification 模型新增字段 `requiresForceRead Boolean @default(false)`

- `prisma/migrations/20260227100000_add_requires_force_read_to_notifications/migration.sql`
  - 手动创建 SQL migration(本地无 DATABASE_URL 环境)
  - 部署时需在服务器执行 `npx prisma migrate deploy`

- `src/domain/entities/notification.entity.ts`
  - 实体类构造器新增 `requiresForceRead`
  - create() / update() 方法均支持该字段,默认值 false

- `src/infrastructure/persistence/mappers/notification.mapper.ts`
  - toDomain() 从 Prisma 记录读取 requiresForceRead
  - toPersistence() 写入 requiresForceRead

- `src/api/dto/request/notification.dto.ts`
  - CreateNotificationDto / UpdateNotificationDto 各新增可选字段 requiresForceRead

- `src/api/dto/response/notification.dto.ts`
  - NotificationResponseDto(管理端)新增 requiresForceRead
  - UserNotificationResponseDto(移动端)新增 requiresForceRead

- `src/api/controllers/notification.controller.ts`
  - create() / update() 透传 requiresForceRead 到 entity

### 前端 admin-web

- `src/services/notificationService.ts`
  - NotificationItem / CreateNotificationRequest / UpdateNotificationRequest 新增 requiresForceRead

- `src/app/(dashboard)/notifications/page.tsx`
  - 通知列表:requiresForceRead=true 时显示红色「强制阅读」标签
  - 创建/编辑表单:新增 checkbox「需要强制弹窗阅读」及说明文字
  - form state / submit payload / edit 初始化均包含 requiresForceRead

### 移动端 mobile-app

- `lib/core/services/notification_service.dart`
  - NotificationItem 新增字段 requiresForceRead(默认 false,fromJson 安全读取)

- `lib/features/notification/presentation/pages/notification_inbox_page.dart`
  - markAsRead / markAllAsRead 重建 NotificationItem 时保留 requiresForceRead

- `lib/features/notification/presentation/widgets/force_read_notification_dialog.dart`(新建)
  - 单条强制阅读弹窗组件
  - 顶部显示通知类型图标 + 进度「1/3」
  - 可滚动内容区展示完整通知
  - 非最后条:「下一条 ▶」按钮(始终可点)
  - 最后一条:checkbox + 「确定」(勾选后才可点)
  - barrierDismissible: false + PopScope(canPop: false),无法逃出

- `lib/features/home/presentation/pages/home_shell_page.dart`
  - 新增状态:_isShowingForceReadDialog(实例,防重入)
                _lastForceReadDialogShownAt(静态,60秒冷却)
  - 新增方法 _checkAndShowForceReadDialog():
      Guard 1: 防重入锁
      Guard 2: 60秒冷却(防回前台闪弹)
      Guard 3: 检查用户已登录
      Guard 4: 检查无其他弹窗在显示
    弹窗期间同时设置 _isShowingDialog=true,阻止后台合同/KYC检查并发
    全部看完后仅标记 forceReadList 中的通知为已读,再 refresh() 刷新 badge
  - initState addPostFrameCallback 中新增调用
  - didChangeAppLifecycleState resumed 分支中新增调用
  - resetContractCheckState() 中重置 _lastForceReadDialogShownAt(账号切换隔离)

## 安全与兼容性

- API 调用失败时静默返回,不阻断用户进入 App
- 仅对 requiresForceRead=true 的通知弹窗,普通通知完全不受影响
- 与现有合同弹窗、KYC弹窗、维护弹窗、更新弹窗无冲突
- 静态冷却变量在账号切换时重置,避免新账号被旧账号冷却影响
- badge 准确:仅标记已展示的强制通知,不动其他未读通知计数

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 19:33:51 -08:00
hailin ef68b7b9c0 fix(pre-planting): 预种开关关闭时隐藏"我的"页面预种按钮
- profile_page: 加载预种配置,根据 isActive 控制预种按钮显隐
- 开关关闭时不显示「预种购买」「预种明细」按钮
- 默认不显示(_isPrePlantingActive = false),加载成功后更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:11:53 -08:00
hailin e328c75fc1 fix(pre-planting): 修复前后端 API 路径不匹配导致预种页面打不开
后端 PrePlantingController 缺少 eligibility 端点,前端请求 404 导致
Future.wait 整体失败,页面显示"加载数据失败"。

修复:
1. 后端: 在 PrePlantingController 添加 GET eligibility 端点
2. 前端: createOrder 路径从 /orders 改为 /purchase(匹配后端)
3. 前端: signMergeContract 路径从 /merges/:no/sign 改为 /sign-contract

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:07:47 -08:00
hailin c0ac63d40a feat(pre-planting): 重命名预种持仓→预种明细 + 购买协议弹窗
- mobile-app: "预种持仓"按钮和页面标题改为"预种明细"
- admin-service: 新增预种协议文本 API (GET/PUT agreement),存储于 system_configs 表
- admin-service: 公开 config API 响应增加 agreementText 字段
- planting-service: 新建 PrePlantingPublicController (无需 JWT),暴露 GET /pre-planting/config
- admin-web: 预种管理页面新增协议文本编辑器(textarea + 保存按钮)
- mobile-app: 购买流程增加协议弹窗,用户需勾选同意后才能继续
- mobile-app: 协议文本优先使用后台配置,未配置时使用默认文本

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:14:24 -08:00
hailin 8bafb0a8d4 fix(mobile-app): 增加切换账号全局防护,彻底解决切换期间自动退出登录
根因:切换账号时 saveCurrentAccountData() 耗时 ~7 秒,期间定时器仍在发 API 请求,
clear 阶段 token 被删除后 in-flight 请求收到 401 → 触发 tokenExpired →
logoutCurrentAccount() 把刚恢复的新账号数据全部擦除。

修复(两层防护):
1. 全局锁 isSwitchingAccount:MultiAccountService 在 switchToAccount 整个过程中
   设为 true,app.dart _handleTokenExpired 检测到该标志直接 return,不执行 logout
2. 定时器提前停止:将定时器停止从 onBeforeRestore(save 之后)移到 switchToAccount
   调用之前,确保 save 期间无新 API 请求
3. try/finally 保证标志位必定清除,异常情况不会锁死后续 tokenExpired 事件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:41:45 -08:00
hailin a5cc3fdc5b fix(mobile-app): 修复切换账号时 token 过期导致自动退出登录
根因:switchToAccount() 流程中,定时器在 _clearCurrentAccountData()
之后才停止。clear 阶段会删除 token,但此时定时器仍在运行,
in-flight 的 API 请求收到 401 → 触发 _handleTokenExpired()
→ 调用 logoutCurrentAccount() 把正在恢复的新账号数据全部清掉
→ 用户被自动踢到登录页面。

修复:将 onBeforeRestore 回调(停止定时器)移到 _clearCurrentAccountData()
之前执行,确保所有 API 请求停止后再清除 token。

修改前: save → clear(删token) → 停定时器 → restore
修改后: save → 停定时器 → clear(删token) → restore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:21:57 -08:00
hailin cfc03fe523 fix(pricing): 手动调价支持负数降价对冲
之前手动调价只允许非负整数,无法用负数对冲降价。

前端 (admin-web settings/page.tsx):
- 移除 input min="0" 限制,允许输入负数
- 验证改为:只校验 isNaN 和总价不低于 0(15831 + amount >= 0)
- 文案:"加价金额" → "调价金额",placeholder 改为"正数涨价,负数降价"
- 实时预览条件从 amount >= 0 改为总价 >= 0
- 提示文案更新为"正数涨价,负数降价对冲"

后端 (admin-service tree-pricing.service.ts):
- 移除 newSupplement < 0 的硬性拒绝
- 改为校验 BASE_PRICE(15831) + newSupplement >= 0,防止总价为负

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:58:11 -08:00
hailin 12004d1c2e fix(mobile-app): 修复账号切换后自动退出登录的问题
根因:ref.invalidate(authProvider) 销毁旧 AuthNotifier 后,新实例的构造函数
仅设置 AuthState(status: AuthStatus.initial),从不自动调用 checkAuthStatus()
从 SecureStorage 重新加载认证数据。导致 auth 状态停留在 initial(未认证),
依赖 auth 状态的组件误判为"未登录",触发页面跳转到登录页。

修复:
- account_switch_page: invalidate 后立即调用 loadAuthState() 从 storage
  读取新账号数据,确保 auth 状态为 authenticated 后再导航
- account_switch_page: 切换后重置 ApiClient 的 tokenExpired 标记,防止
  旧会话的 401 状态阻塞新账号的请求
- app.dart: _handleTokenExpired() 增加醒目日志和调用栈打印,便于排查
  切换期间是否有 token 过期事件被误触发

切换流程更新为 6 步:
[1/6] switchToAccount() - 保存旧账号、清空、恢复新账号 storage
[2/6] onBeforeRestore - 停止所有定时器
[3/6] invalidate Provider - 销毁旧 Provider 实例
[4/6] loadAuthState() - 从 storage 加载新账号 auth 状态 ← 新增关键步骤
[5/6] 恢复遥测上传
[6/6] 导航到 ranking 页面

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:39:31 -08:00
hailin 3a307b5db7 fix(planting): 修复认种页面动态定价不生效 + 添加涨价倒计时
- 修复 admin-service PublicTreePricingController 路由双重前缀问题
  (@Controller('api/v1/tree-pricing') → @Controller('tree-pricing'))
- Kong 网关新增 /api/v1/tree-pricing 路由到 admin-service
- mobile-app 认种页面添加涨价倒计时功能:
  显示"距下次涨价还有 X天 X小时 X分钟"及涨价后价格

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 10:22:27 -08:00
hailin 19f350b8e3 fix(mining-app): 全网兑换销毁量改为保留8位小数
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 09:54:53 -08:00
hailin c293309bdf fix(mining-app): 全网兑换销毁量保留4位小数
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 09:53:47 -08:00
hailin a39af93063 fix(mining-app): 全网兑换销毁量改用circulationPool+保留小数格式
根据业务需求,"全网兑换销毁量"应显示流通池总数量(全网卖出量),
而非SELL_BURN销毁总量。同时将formatIntWithCommas改为formatWithCommas
以保留小数位,避免小数值被截断显示为0。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 09:53:14 -08:00