Commit Graph

928 Commits

Author SHA1 Message Date
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
hailin edc81cc55d fix(trading+mining-app): 修复"全网兑换销毁量"显示为0的问题
根因:前端"全网兑换销毁量"读取的是circulationPool(流通池积分股),
但实际应该显示burn_records中source_type=SELL_BURN的销毁总量。
这是两个不同的概念:circulationPool是卖出交易进入流通的积分股,
而兑换销毁量是卖出时被销毁(进入黑洞)的积分股。

修复:
- 后端: BlackHoleRepository添加getTotalSellBurned()聚合查询
- 后端: asset.service.ts市场概览API新增totalSellBurned字段
- 前端: MarketOverview实体/Model新增totalSellBurned字段
- 前端: trading_page销毁明细弹窗改用totalSellBurned显示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 09:52:10 -08:00
hailin acf55b26a7 feat(pricing): 预种每份价格从 3171 调整为 3566 绿积分
分配规则:按 reward-service RIGHT_AMOUNTS(15831 整棵树)各项 /5 取整,
余额全归总部社区(HQ_BASE_FEE)。5 份合成一棵树 = 17830。

10 类分配金额变更:
- COST_FEE:       576 (不变, floor(2880/5))
- OPERATION_FEE:  420 (不变, floor(2100/5))
- HQ_BASE_FEE:    29.4 → 427 (3566 - 3139, 吸收全部余额)
- RWAD_POOL:      1152 (不变, floor(5760/5))
- SHARE_RIGHT:    720 (不变, floor(3600/5))
- PROVINCE_AREA:  21.6 → 21 (floor(108/5))
- PROVINCE_TEAM:  28.8 → 28 (floor(144/5))
- CITY_AREA:      50.4 → 50 (floor(252/5))
- CITY_TEAM:      57.6 → 57 (floor(288/5))
- COMMUNITY:      115.2 → 115 (floor(576/5))
- 合计: 3171 → 3566 ✓

涉及服务:planting-service, admin-service, contribution-service
涉及前端:admin-web, mobile-app (Flutter)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 08:02:17 -08:00
hailin ed6b48562a feat(pricing): 认种树动态定价涨价系统(总部运营成本压力涨价)
基础价 15831 USDT/棵不变,新增 HQ_PRICE_SUPPLEMENT 加价项全额归总部(S0000000001)。
支持手动调价+自动周期涨价,所有变更可审计,移动端动态展示价格及涨价预告。

- admin-service: TreePricingConfig/ChangeLog 表 + Service + Controller + 定时任务
- planting-service: 正式认种和预种订单快照 priceSupplement,动态价格校验
- reward-service: HQ_PRICE_SUPPLEMENT 分配类型,涨价金额直接入总部账户
- admin-web: Settings 页面新增定价配置区间(手动调价/自动涨价/变更历史)
- mobile-app: TreePricingService + 动态价格加载 + 涨价预告展示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 03:02:56 -08:00
hailin 577979bc83 chore: 同步线上构建版本号,避免服务器编译时版本号回退
- mining-app: 1.0.0+1 → 1.0.0+58(与线上已发布版本对齐)
- mobile-app: 2.0.0+1 → 2.0.0+357(与线上已发布版本对齐)

构建脚本会在此基础上自动递增,下次构建将从 59/358 开始。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 00:29:29 -08:00
hailin a68fe5e999 feat(mining-app): 兑换页面"流通池"改名及"已销毁量"点击查看销毁明细
1. 将市场数据卡片中的"流通池"标签改名为"已分配积分股"
   - 展示值不变,仍为 totalMined(全网已挖矿产出总量)

2. "已销毁量"新增点击交互,点击弹出"销毁明细"对话框:
   - 系统总销毁量(blackHoleAmount):当前页面已展示的黑洞销毁总量
   - 全网兑换销毁量(circulationPool):卖出交易实际产生的流通池金额
   - 标签旁显示 info_outline 图标提示可点击

3. 重构 _buildMarketDataItem,拆分出 _buildMarketDataItemContent:
   - _buildMarketDataItem:包含 Expanded 包裹,供普通数据项使用
   - _buildMarketDataItemContent:纯内容组件,支持 showTapHint 参数
   - 避免 Row > GestureDetector > Expanded 的布局冲突

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:55:37 -08:00
hailin eda39b982d fix(mobile-app): 修复切换窗口期 NotificationBadge 混账号请求问题
问题(通过日志发现):
账号切换时存在一个 storage 空窗期(旧数据已清除、新数据尚未恢复完成)。
在此期间,NotificationBadgeNotifier 的 30s 定时器恰好触发,导致:
- _loadUnreadCount() 从 authProvider 内存读到旧账号 userSerialNum
- HTTP interceptor 从 storage 读到已恢复的新账号 accessToken
- 发出混账号请求:?userSerialNum=旧账号 + Authorization: Bearer 新账号token
日志证据:
  _restoreAccountData() 执行期间出现
  GET /notifications/unread-count?userSerialNum=D26022600000
  Authorization: Bearer [D26022600001的token]

修复:

1. notification_badge_provider.dart
   新增 stopAutoRefresh() 公开方法,取消 30s 定时器而不 dispose,
   Provider invalidate 重建后会自动重启定时器。

2. account_switch_page.dart - _switchToAccount
   在 onBeforeRestore 中补加:
     ref.read(notificationBadgeProvider.notifier).stopAutoRefresh()
   确保切换空窗期内 notificationBadge 定时器不触发。

   同时移除 UI 层冗余的 saveCurrentAccountData() 调用——
   switchToAccount() 内部已有此步骤,无需重复。

   日志步骤从 [1/6]...[6/6] 更新为 [1/5]...[5/5],
   并在 onBeforeRestore 注释中说明停止各定时器的原因。

切换空窗期现在所有定时器均已停止:
  ✓ walletStatusProvider (60s)
  ✓ pendingActionPollingService (4s)
  ✓ notificationBadgeProvider (30s)  ← 本次新增
  ✓ TelemetryUploader (30s)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:09:37 -08:00
hailin 825c8a32e4 chore(mobile-app): 补全多账号切换流程的关键日志,便于验证与排查
切换流程中的每一步现在都有清晰的日志输出,方便通过 adb logcat
或 flutter logs 验证切换行为是否符合预期。

account_switch_page.dart - 三条路径全部覆盖:

_switchToAccount(切换账号):
  [1/6] 保存当前账号数据
  [2/6] 调用 switchToAccount()
  [3/6] onBeforeRestore - 停止 walletStatus/pendingAction 轮询、暂停遥测
  [4/6] invalidate authProvider / walletStatusProvider / notificationBadgeProvider
  [5/6] 恢复遥测上传
  [6/6] 导航到 ranking 页面

_addNewAccount(添加新账号):
  [1/5] 保存当前账号数据
  [2/5] 停止定时器
  [3/5] 调用 logoutCurrentAccount()
  [4/5] invalidate 三个 Provider
  [5/5] 导航到 guide 页面

_deleteAccount(删除账号):
  删除前打印目标账号,删除后打印剩余账号数

profile_page.dart - _performLogout(退出登录):
  [1/4] 停止 walletStatus/pendingAction 轮询
  [2/4] 调用 logoutCurrentAccount()
  [3/4] invalidate 三个 Provider
  [4/4] 导航到 guide 页面

每条关键操作完成后打印 ✓ 确认符号,便于逐步验证。
每条路径用 ========== 分隔符标识开始/完成,日志易于 grep 过滤。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:04:14 -08:00
hailin e02bcf418c fix(mobile-app): 升级弹窗禁止点击外部或返回键关闭,必须通过按钮操作
问题:
检测到新版本弹出升级对话框时,用户点击弹窗外部区域或按系统返回键
即可直接关闭弹窗,绕过升级提示,导致用户可能永远不会注意到更新。

修复:
对所有 3 个升级弹窗统一加两层防护:
- barrierDismissible: false — 禁止点击弹窗外部区域关闭
- PopScope(canPop: false) — 禁止系统返回键关闭

涉及弹窗:
1. self_hosted_updater.dart - _showSelfHostedUpdateDialog(自建APK更新)
2. self_hosted_updater.dart - _showMarketUpdateDialog(应用市场引导更新)
3. update_service.dart - _checkGooglePlayUpdate(Google Play 更新)

用户必须通过弹窗内按钮操作:
- 非强制更新:点击「稍后」/「暂时不更新」关闭,或点击「立即更新」开始更新
- 强制更新:只有「立即更新」按钮,无法跳过

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 08:01:52 -08:00
hailin 8f8a9230d0 fix(mobile-app): 修复多账号切换数据串号问题,完善存储隔离与状态重置
问题:
多账号切换时,前一个账号的推荐码、种植省市、缓存数据等会串到下一个账号,
同时定时器(钱包轮询、通知刷新、遥测上传)未正确停止/重启,
导致旧账号的 API 请求混入新账号上下文。

修复内容:

1. StorageKeys 补充种植省市常量(storage_keys.dart)
   - 新增 plantingProvinceName/Code、plantingCityName/Code 4个常量
   - 将硬编码的 key 统一收口,确保隔离列表引用一致

2. MultiAccountService 补全隔离列表(multi_account_service.dart)
   - _accountSecureKeys 新增 inviterReferralCode(修复邀请码串号)
   - _accountLocalKeys 新增 4个种植省市key + cachedAppAssets +
     cachedCustomerServiceContacts(修复种植/缓存数据串号)
   - switchToAccount() 新增 onBeforeRestore 回调参数,用于在
     storage清空后、恢复新数据前 停止定时器

3. AccountSwitchPage 三层状态重置(account_switch_page.dart)
   - _switchToAccount: onBeforeRestore 内停止 walletStatus 轮询、
     pendingAction 轮询、telemetry 上传;返回后 invalidate
     authProvider/walletStatusProvider/notificationBadgeProvider,
     最后 resumeAfterLogin 恢复遥测
   - _addNewAccount: 退出前停止定时器,退出后 invalidate Provider

4. ProfilePage 退出登录补全清理(profile_page.dart)
   - _performLogout: 退出前停止 walletStatus/pendingAction 轮询,
     退出后 invalidate 三个 Provider

5. 页面 key 统一引用 StorageKeys 常量
   - planting_location_page.dart 和 authorization_apply_page.dart
     将硬编码 key 替换为 StorageKeys.plantingXxx 常量

关键时序(switchToAccount 内部):
  save → clear → onBeforeRestore(停timer) → restore → 返回
  → invalidate Provider(此时storage已恢复新数据)→ resume telemetry → navigate

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 06:13:05 -08:00
hailin a5a69645b4 fix(trading): 流通池改为显示已挖矿产出总量,解决显示为0的问题
前端"流通池"原来读取 circulationPool(交易流通池),因系统尚无卖出交易故为 0。
现改为读取 totalMined(全网已挖矿产出的积分股总量 = 用户已挖 + 系统已挖)。

后端新增 getTotalMinedFromMiningService() 方法,调用 mining-service 的
GET /mining/progress 接口获取 totalDistributed。

注意:价格公式中的 circulationPool 保持不变,仍用交易流通池参与计算。
新增的 totalMined 字段仅用于前端展示。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:54:26 -08:00
hailin 18675f083c fix(admin-web): 滚动区域改到 nav 内部,避免裁掉 toggle 按钮
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 06:55:32 -08:00
hailin 31d3eabcf8 fix(admin-web): 侧边栏菜单项过多时支持滚动,底部不再被截断
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 06:46:15 -08:00
hailin e32ef9b9ff fix(frontend): snapshot.types 补充 MPC_POSTGRES 类型和标签
后端新增了 MPC_POSTGRES 备份目标,前端 BackupTarget 类型和
BACKUP_TARGET_LABELS 缺少对应项导致复选框无法渲染。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 04:19:57 -08:00
hailin 26e55a649f feat(snapshot): 进度显示增加文件大小信息,完成项显示 "完成 (29.4 MB)"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:59:07 -08:00
hailin ff28615fc3 fix(admin-web): Dockerfile 构建时传入 SNAPSHOT_SERVICE_URL build arg
Next.js rewrites 在构建阶段烘焙,运行时环境变量不生效

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:53:03 -08:00
hailin de361e24f6 fix(infra): 网关 nginx 添加 snapshot-api 代理 + admin-web 配置 SNAPSHOT_SERVICE_URL
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:44:46 -08:00
hailin 30f1355bb4 fix(admin-web): snapshots 页面补上 PageContainer 包裹,恢复侧边栏
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:27:43 -08:00
hailin 8855491637 feat(snapshot): 进度精度升级 — Float百分比 + MB消息存DB
- schema: progress Int→Float,新增 progressMsg 字段
- PG handler: 百分比保留2位小数(toFixed(2)),不再 Math.floor
- orchestrator: 每2秒写DB时同时写 progressMsg (含MB信息)
- 前端: 百分比显示 toFixed(1),message 优先读 progressMsg

效果: 113GB库每次轮询进度条和MB数都有变化,不再卡在整数百分比

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:57:47 -08:00