From 943fd9efe957bf4a9f290df78996c4ffcca1a397 Mon Sep 17 00:00:00 2001 From: hailin Date: Fri, 19 Dec 2025 06:09:43 -0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E6=8F=90=E4=BA=A4=E6=89=80=E6=9C=89?= =?UTF-8?q?=E6=9C=AA=E6=8F=90=E4=BA=A4=E7=9A=84=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 包括: - admin-service: 系统配置功能 - authorization-service: 自助授权申请功能 - planting-service: 资金分配服务 - reward-service: 奖励计算服务 - admin-web: 用户管理和设置页面 - mobile-app: 授权、认证、路由等功能 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 37 +- .../migration.sql | 18 + .../admin-service/prisma/schema.prisma | 402 ++++---- .../controllers/system-config.controller.ts | 202 ++++ .../src/api/controllers/user.controller.ts | 2 +- .../src/api/dto/response/user.dto.ts | 2 +- .../services/admin-service/src/app.module.ts | 11 + .../repositories/system-config.repository.ts | 58 ++ .../system-config.repository.impl.ts | 99 ++ .../controllers/authorization.controller.ts | 48 +- .../src/api/dto/request/index.ts | 1 + .../request/self-apply-authorization.dto.ts | 121 +++ .../dto/response/authorization.response.ts | 16 +- .../authorization-service/src/app.module.ts | 3 +- .../src/application/commands/index.ts | 1 + .../self-apply-authorization.command.ts | 18 + .../src/application/dto/authorization.dto.ts | 8 +- .../authorization-application.service.ts | 271 +++++- .../system-account-application.service.ts | 30 +- .../external/identity-service.client.ts | 99 ++ .../src/infrastructure/external/index.ts | 1 + .../services/fund-allocation.service.ts | 44 +- .../fund-allocation-target-type.enum.ts | 44 +- .../services/reward-calculation.service.ts | 48 +- .../domain/value-objects/right-type.enum.ts | 42 +- .../src/app/(dashboard)/settings/page.tsx | 65 +- .../(dashboard)/settings/settings.module.scss | 22 + .../src/app/(dashboard)/users/page.tsx | 10 +- .../app/(dashboard)/users/users.module.scss | 12 + .../src/services/systemConfigService.ts | 71 ++ .../admin-web/src/services/userService.ts | 2 +- .../assets/lottie/stickman_running.json | 286 ++++++ .../lib/core/constants/api_endpoints.dart | 5 + .../lib/core/di/injection_container.dart | 7 + .../lib/core/services/account_service.dart | 360 +++++++ .../core/services/authorization_service.dart | 79 ++ .../core/services/system_config_service.dart | 121 +++ .../lib/core/storage/storage_keys.dart | 2 + .../auth/presentation/pages/guide_page.dart | 77 +- .../pages/phone_register_page.dart | 325 +++++++ .../presentation/pages/set_password_page.dart | 360 +++++++ .../presentation/pages/sms_verify_page.dart | 446 +++++++++ .../pages/authorization_apply_page.dart | 898 ++++++++++++++++++ .../widgets/stickman_race_widget.dart | 585 ++++++++++++ .../presentation/pages/deposit_usdt_page.dart | 184 +--- .../presentation/pages/profile_page.dart | 200 +++- .../presentation/pages/trading_page.dart | 29 +- .../pages/withdraw_usdt_page.dart | 32 +- .../mobile-app/lib/routes/app_router.dart | 52 +- .../mobile-app/lib/routes/route_names.dart | 4 + .../mobile-app/lib/routes/route_paths.dart | 4 + 51 files changed, 5372 insertions(+), 492 deletions(-) create mode 100644 backend/services/admin-service/prisma/migrations/20250102300000_add_system_config/migration.sql create mode 100644 backend/services/admin-service/src/api/controllers/system-config.controller.ts create mode 100644 backend/services/admin-service/src/domain/repositories/system-config.repository.ts create mode 100644 backend/services/admin-service/src/infrastructure/persistence/repositories/system-config.repository.impl.ts create mode 100644 backend/services/authorization-service/src/api/dto/request/self-apply-authorization.dto.ts create mode 100644 backend/services/authorization-service/src/application/commands/self-apply-authorization.command.ts create mode 100644 backend/services/authorization-service/src/infrastructure/external/identity-service.client.ts create mode 100644 frontend/admin-web/src/services/systemConfigService.ts create mode 100644 frontend/mobile-app/assets/lottie/stickman_running.json create mode 100644 frontend/mobile-app/lib/core/services/system_config_service.dart create mode 100644 frontend/mobile-app/lib/features/auth/presentation/pages/phone_register_page.dart create mode 100644 frontend/mobile-app/lib/features/auth/presentation/pages/set_password_page.dart create mode 100644 frontend/mobile-app/lib/features/auth/presentation/pages/sms_verify_page.dart create mode 100644 frontend/mobile-app/lib/features/authorization/presentation/pages/authorization_apply_page.dart create mode 100644 frontend/mobile-app/lib/features/authorization/presentation/widgets/stickman_race_widget.dart diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 40abbf99..151291d2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -224,7 +224,42 @@ "Bash(git restore:*)", "Bash(node -e \"\nconst { ethers } = require(''ethers'');\n\nconst KAVA_TESTNET_RPC = ''https://evm.testnet.kava.io'';\nconst privateKey = ''0xd42a6e6021ebd884f3f179d3793a32e97b9f1001db6ff44441ec455d748b9aa6'';\nconst USDT_CONTRACT = ''0xc12f6A4A7Fd0965085B044A67a39CcA2ff7fe0dF'';\nconst TO_ADDRESS = ''0xfb852080346fa0996c28b250e0bbb5e27de7e9ca'';\n\nasync function transfer() {\n const provider = new ethers.JsonRpcProvider(KAVA_TESTNET_RPC);\n const wallet = new ethers.Wallet(privateKey, provider);\n \n const abi = [''function transfer(address to, uint256 amount) returns (bool)'', ''function balanceOf(address) view returns (uint256)''];\n const contract = new ethers.Contract(USDT_CONTRACT, abi, wallet);\n \n const amount = BigInt(30000000) * BigInt(1000000);\n \n console.log(''Transferring 30,000,000 USDT to'', TO_ADDRESS);\n const tx = await contract.transfer(TO_ADDRESS, amount, { gasLimit: 100000 });\n console.log(''TX Hash:'', tx.hash);\n await tx.wait();\n \n const newBalance = await contract.balanceOf(TO_ADDRESS);\n console.log(''New balance:'', Number(newBalance) / 1e6, ''USDT'');\n}\n\ntransfer().catch(e => console.error(''Error:'', e.message));\n\")", "Bash(node -e \"\nconst { ethers } = require(''ethers'');\n\nconst KAVA_TESTNET_RPC = ''https://evm.testnet.kava.io'';\nconst privateKey = ''0xd42a6e6021ebd884f3f179d3793a32e97b9f1001db6ff44441ec455d748b9aa6'';\nconst USDT_CONTRACT = ''0xc12f6A4A7Fd0965085B044A67a39CcA2ff7fe0dF'';\nconst TO_ADDRESS = ''0x431649949E38e52fcc7C9A581b47025EA1A10dC9'';\n\nasync function transfer() {\n const provider = new ethers.JsonRpcProvider(KAVA_TESTNET_RPC);\n const wallet = new ethers.Wallet(privateKey, provider);\n \n const abi = [''function transfer(address to, uint256 amount) returns (bool)'', ''function balanceOf(address) view returns (uint256)''];\n const contract = new ethers.Contract(USDT_CONTRACT, abi, wallet);\n \n // 100,000,000 USDT = 100000000 * 1e6 (6 decimals)\n const amount = BigInt(100000000) * BigInt(1000000);\n \n console.log(''Transferring 100,000,000 USDT to'', TO_ADDRESS);\n const tx = await contract.transfer(TO_ADDRESS, amount, { gasLimit: 100000 });\n console.log(''TX Hash:'', tx.hash);\n await tx.wait();\n \n const newBalance = await contract.balanceOf(TO_ADDRESS);\n console.log(''New balance:'', Number(newBalance) / 1e6, ''USDT'');\n}\n\ntransfer().catch(e => console.error(''Error:'', e.message));\n\")", - "Bash(git commit -m \"$(cat <<''EOF''\nfix(admin-service): 添加缺失的 uuid 依赖\n\nnotification.controller.ts 使用了 uuid 生成 ID,但 package.json 缺少依赖\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n)\")" + "Bash(git commit -m \"$(cat <<''EOF''\nfix(admin-service): 添加缺失的 uuid 依赖\n\nnotification.controller.ts 使用了 uuid 生成 ID,但 package.json 缺少依赖\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n)\")", + "Bash(npm install)", + "Bash(node -e \"\nconst { ethers } = require(''ethers'');\n\nconst KAVA_TESTNET_RPC = ''https://evm.testnet.kava.io'';\nconst privateKey = ''0xd42a6e6021ebd884f3f179d3793a32e97b9f1001db6ff44441ec455d748b9aa6'';\nconst USDT_CONTRACT = ''0xc12f6A4A7Fd0965085B044A67a39CcA2ff7fe0dF'';\nconst TO_ADDRESS = ''0xa3da257b76c4816e651b6d4e99d1577eb3bfe1d8'';\n\nasync function transfer() {\n const provider = new ethers.JsonRpcProvider(KAVA_TESTNET_RPC);\n const wallet = new ethers.Wallet(privateKey, provider);\n \n const abi = [''function transfer(address to, uint256 amount) returns (bool)'', ''function balanceOf(address) view returns (uint256)''];\n const contract = new ethers.Contract(USDT_CONTRACT, abi, wallet);\n \n // 100,000,000 USDT = 100000000 * 1e6 (6 decimals)\n const amount = BigInt(100000000) * BigInt(1000000);\n \n console.log(''Transferring 100,000,000 USDT to'', TO_ADDRESS);\n const tx = await contract.transfer(TO_ADDRESS, amount, { gasLimit: 100000 });\n console.log(''TX Hash:'', tx.hash);\n await tx.wait();\n \n const newBalance = await contract.balanceOf(TO_ADDRESS);\n console.log(''New balance:'', Number(newBalance) / 1e6, ''USDT'');\n}\n\ntransfer().catch(e => console.error(''Error:'', e.message));\n\")", + "Bash(node -e \"\nconst { ethers } = require(''ethers'');\n\nconst KAVA_TESTNET_RPC = ''https://evm.testnet.kava.io'';\nconst privateKey = ''0xd42a6e6021ebd884f3f179d3793a32e97b9f1001db6ff44441ec455d748b9aa6'';\nconst USDT_CONTRACT = ''0xc12f6A4A7Fd0965085B044A67a39CcA2ff7fe0dF'';\nconst TO_ADDRESS = ''0x61745e6fe29eb3839c479d2a07b0a3ae1d962cc4'';\n\nasync function transfer() {\n const provider = new ethers.JsonRpcProvider(KAVA_TESTNET_RPC);\n const wallet = new ethers.Wallet(privateKey, provider);\n \n const abi = [''function transfer(address to, uint256 amount) returns (bool)'', ''function balanceOf(address) view returns (uint256)''];\n const contract = new ethers.Contract(USDT_CONTRACT, abi, wallet);\n \n // 9,000,000 USDT = 9000000 * 1e6 (6 decimals)\n const amount = BigInt(9000000) * BigInt(1000000);\n \n console.log(''Transferring 9,000,000 USDT to'', TO_ADDRESS);\n const tx = await contract.transfer(TO_ADDRESS, amount, { gasLimit: 100000 });\n console.log(''TX Hash:'', tx.hash);\n await tx.wait();\n \n const newBalance = await contract.balanceOf(TO_ADDRESS);\n console.log(''New balance:'', Number(newBalance) / 1e6, ''USDT'');\n}\n\ntransfer().catch(e => console.error(''Error:'', e.message));\n\")", + "Bash(git fetch:*)", + "Bash(node -e \"\nconst { ethers } = require(''ethers'');\n\nconst KAVA_TESTNET_RPC = ''https://evm.testnet.kava.io'';\nconst privateKey = ''0xd42a6e6021ebd884f3f179d3793a32e97b9f1001db6ff44441ec455d748b9aa6'';\nconst USDT_CONTRACT = ''0xc12f6A4A7Fd0965085B044A67a39CcA2ff7fe0dF'';\nconst TO_ADDRESS = ''0x68dadb766c33f1db47e4821919c795ea19a0f282'';\n\nasync function transfer() {\n const provider = new ethers.JsonRpcProvider(KAVA_TESTNET_RPC);\n const wallet = new ethers.Wallet(privateKey, provider);\n \n const abi = [''function transfer(address to, uint256 amount) returns (bool)'', ''function balanceOf(address) view returns (uint256)''];\n const contract = new ethers.Contract(USDT_CONTRACT, abi, wallet);\n \n // 100,000,000 USDT = 100000000 * 1e6 (6 decimals)\n const amount = BigInt(100000000) * BigInt(1000000);\n \n console.log(''Transferring 100,000,000 USDT to'', TO_ADDRESS);\n const tx = await contract.transfer(TO_ADDRESS, amount, { gasLimit: 100000 });\n console.log(''TX Hash:'', tx.hash);\n await tx.wait();\n \n const newBalance = await contract.balanceOf(TO_ADDRESS);\n console.log(''New balance:'', Number(newBalance) / 1e6, ''USDT'');\n}\n\ntransfer().catch(e => console.error(''Error:'', e.message));\n\")", + "Bash(git commit -m \"$(cat <<''EOF''\nfix(authorization): 修正省区域角色唯一性检查逻辑\n\n将省区域角色从\"全系统唯一\"改为\"按省份唯一\":\n- 修改 grantProvinceCompany 使用 findProvinceCompanyByRegion 检查\n- 删除废弃的 findAnyProvinceCompany 方法\n- 现在不同省份可以分别授权给不同账户\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n)\")", + "Bash(flutter pub:*)", + "Bash(dir /s /b \"c:\\Users\\dong\\Desktop\\rwadurian\\frontend\\mobile-app\\publish\")", + "Bash(keytool:*)", + "Bash(git tag -a \"v2.0.0-new-identity\" -m \"$(cat <<''EOF''\n更换包名和签名证书\n\n原因:华为应用市场 13.2+ 版本对未上架应用检测更严格,\n原包名 com.rwadurian.rwa_android_app 被标记为\"风险应用\"。\n\n更改:\n- 包名: com.rwadurian.rwa_android_app → com.durianqueen.app\n- 签名证书: 新的 durianqueen-release.keystore\n- MethodChannel 前缀更新\n\n注意:用户需要卸载旧版本重新安装\nEOF\n)\")", + "Bash(git commit -m \"$(cat <<''EOF''\nfix(ui): 优化\"我的\"页面已过期和结算栏的显示格式\n\n将绿积分和贡献值的数字改为跟在标签后面显示:\n- 已过期栏:绿积分:xxx,贡献值:xxx\n- 可结算栏:可结算 (绿积分):xxx\n- 已结算栏:已结算 (绿积分):xxx\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n)\")", + "Bash(git commit -m \"$(cat <<''EOF''\nfix(authorization): 省区域和市区域授权即激活,无需初始考核\n\n修改 createProvinceCompany 和 createCityCompany 方法:\n- 授权后立即激活权益 (benefitActive: true)\n- 从第1个月开始考核 (currentMonthIndex: 1)\n- 省区域月度目标:150, 300, 600, 1200, 2400, 4800, 9600, 19200, 11750\n- 市区域月度目标:30, 60, 120, 240, 480, 960, 1920, 3840, 2350\n- 保留 skipAssessment 参数兼容性\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n)\")", + "Bash(git commit -m \"$(cat <<''EOF''\nfeat(reward): 可结算列表改为从 reward-service 读取\n\n将前端可结算奖励列表的数据源从 wallet-service 改为 reward-service:\n- 后端:在 reward-service 添加 GET /rewards/settleable 接口\n- 前端:修改 getSettleableRewards() 调用 /rewards/settleable\n- 前端:更新 SettleableRewardItem 字段映射 (rightType, claimedAt, sourceOrderNo, memo)\n\n解决权益收益(社区权益、市区域权益)无法在可结算列表显示的问题。\n遵循单一数据源原则,reward-service 是奖励的权威数据源。\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n)\")", + "Bash(git commit -m \"$(cat <<''EOF''\nfeat(authorization): 实现软删除支持撤销后重新授权\n\n- 添加 deletedAt 字段到 AuthorizationRole 聚合根和 Prisma schema\n- revoke() 方法同时设置 deletedAt,使撤销的记录被软删除\n- Repository 所有查询添加 deletedAt: null 过滤条件\n- 创建部分唯一索引,只对未删除记录生效 (大厂通用做法)\n- 支持撤销授权后重新创建相同角色\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n)\")", + "Bash(git commit -m \"$(cat <<''EOF''\nfeat(authorization): 添加审计查询方法支持查询已删除记录\n\n- findAllByUserIdIncludeDeleted: 按用户ID查询所有记录(含已删除)\n- findAllByAccountSequenceIncludeDeleted: 按账号序列查询所有记录(含已删除)\n- findByIdIncludeDeleted: 按ID查询记录(含已删除)\n\n确保撤销的授权记录可审计追溯\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n)\")", + "Bash(npm run lint:*)", + "Bash(npx next lint)", + "Bash(./deploy.sh:*)", + "Bash(backend/services/reporting-service/prisma/schema.prisma )", + "Bash(backend/services/reporting-service/prisma/migrations/ )", + "Bash(backend/services/reporting-service/src/domain/repositories/realtime-stats.repository.interface.ts )", + "Bash(backend/services/reporting-service/src/domain/repositories/global-stats.repository.interface.ts )", + "Bash(backend/services/reporting-service/src/domain/repositories/index.ts )", + "Bash(backend/services/reporting-service/src/infrastructure/persistence/repositories/realtime-stats.repository.impl.ts )", + "Bash(backend/services/reporting-service/src/infrastructure/persistence/repositories/global-stats.repository.impl.ts )", + "Bash(backend/services/reporting-service/src/infrastructure/infrastructure.module.ts )", + "Bash(backend/services/reporting-service/src/infrastructure/kafka/ )", + "Bash(backend/services/reporting-service/src/application/services/dashboard-application.service.ts )", + "Bash(git commit -m \"$(cat <<''EOF''\nfeat(reporting): 实现事件驱动的仪表板统计架构\n\n## 概述\n将 reporting-service Dashboard 从 HTTP API 调用改为事件驱动架构,\n通过消费 Kafka 事件在本地维护统计数据,实现微服务间解耦。\n\n## 架构变更\n之前: Dashboard → HTTP → planting/authorization/identity-service\n现在: 各服务 → Kafka → reporting-service → 本地统计表 → Dashboard\n\n## 新增表\n- RealtimeStats: 每日实时统计 (认种数/订单数/新用户/授权数)\n- GlobalStats: 全局累计统计 (总认种/总用户/总公司数)\n\n## 新增仓储\n- IRealtimeStatsRepository: 实时统计接口及实现\n- IGlobalStatsRepository: 全局统计接口及实现\n\n## Kafka 消费者更新\n- identity.UserAccountCreated: 累加用户统计\n- identity.UserAccountAutoCreated: 累加用户统计\n- authorization-events: 累加省/市公司统计\n- planting.order.paid: 累加认种统计\n\n## Dashboard 服务更新\n- getStats(): 从 GlobalStats/RealtimeStats 读取,计算环比变化\n- getTrendData(): 从 RealtimeStats 获取趋势数据\n\n## 优势\n- 消除跨服务 HTTP 调用延迟\n- 统计数据实时更新\n- 微服务间完全解耦\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n)\")", + "Bash(git commit -m \"$(cat <<''EOF''\nfeat(identity): 实现 Outbox 模式事件发布\n\n## 概述\n为 identity-service 实现 Outbox 模式,确保数据库事务和 Kafka 事件发布的原子性。\n\n## 新增表\n- OutboxEvent: 事件暂存表,用于事务性事件发布\n\n## 新增组件\n- OutboxRepository: Outbox 事件持久化\n- OutboxPublisherService: 轮询发布未处理事件到 Kafka\n\n## 支持的事件\n- identity.UserAccountCreated: 用户注册事件\n- identity.UserAccountAutoCreated: 自动创建用户事件\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n)\")", + "Bash(git commit -m \"$(cat <<''EOF''\nfeat(authorization): 实现 Outbox 模式事件发布\n\n## 概述\n为 authorization-service 实现 Outbox 模式,确保数据库事务和 Kafka 事件发布的原子性。\n\n## 新增表\n- OutboxEvent: 事件暂存表,用于事务性事件发布\n\n## 新增组件\n- OutboxRepository: Outbox 事件持久化\n- OutboxPublisherService: 轮询发布未处理事件到 Kafka\n\n## 支持的事件\n- authorization-events: 授权角色创建/更新事件(省公司、市公司授权)\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n)\")", + "Bash(git commit -m \"$(cat <<''EOF''\nfeat(reporting): 实现 Dashboard API 完整功能\n\n## 概述\n为 reporting-service 实现完整的 Dashboard API 端点,支持统计卡片、趋势图表、\n区域分布和最近活动等功能。\n\n## API 端点\n- GET /dashboard/stats: 获取统计卡片数据\n- GET /dashboard/charts: 获取趋势图表数据 (支持 7d/30d/90d 周期)\n- GET /dashboard/region: 获取区域分布数据\n- GET /dashboard/activities: 获取最近活动列表\n\n## 新增 DTO\n- DashboardStatsResponseDto: 统计卡片响应\n- DashboardTrendResponseDto: 趋势数据响应\n- DashboardRegionResponseDto: 区域分布响应\n- DashboardActivitiesResponseDto: 活动列表响应\n\n## Repository 层\n- IDashboardStatsSnapshotRepository: 统计快照接口\n- IDashboardTrendDataRepository: 趋势数据接口\n- ISystemActivityRepository: 系统活动接口\n\n## External Clients (已弃用)\n- AuthorizationServiceClient: 授权服务客户端\n- IdentityServiceClient: 身份服务客户端\n注:已改为事件驱动架构,这些客户端仅作为备用\n\n�� Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n)\")", + "Bash(git commit -m \"$(cat <<''EOF''\nfeat(admin-web): 实现 Dashboard 页面真实 API 接入\n\n## 概述\n将 admin-web Dashboard 页面从模拟数据改为真实 API 调用,\n使用 React Query 实现数据获取、缓存和自动刷新。\n\n## 新增文件\n- dashboardService.ts: Dashboard API 服务封装\n- useDashboard.ts: React Query hooks\n- dashboard.types.ts: Dashboard 类型定义\n\n## API 接入\n- /dashboard/stats: 统计卡片(总认种量、总用户数、省/市公司数)\n- /dashboard/charts: 趋势图表(支持 7d/30d/90d 周期切换)\n- /dashboard/region: 区域分布\n- /dashboard/activities: 最近活动\n\n## UI 优化\n- 添加加载骨架屏\n- 添加错误重试机制\n- 添加空数据提示\n- 优化图表周期切换交互\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n)\")", + "Bash(npx next:*)", + "Bash(npx prisma validate:*)" ], "deny": [], "ask": [] diff --git a/backend/services/admin-service/prisma/migrations/20250102300000_add_system_config/migration.sql b/backend/services/admin-service/prisma/migrations/20250102300000_add_system_config/migration.sql new file mode 100644 index 00000000..fe2f806a --- /dev/null +++ b/backend/services/admin-service/prisma/migrations/20250102300000_add_system_config/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "system_configs" ( + "id" TEXT NOT NULL, + "key" VARCHAR(100) NOT NULL, + "value" TEXT NOT NULL, + "description" VARCHAR(255), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "updated_by" TEXT, + + CONSTRAINT "system_configs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "system_configs_key_key" ON "system_configs"("key"); + +-- CreateIndex +CREATE INDEX "system_configs_key_idx" ON "system_configs"("key"); diff --git a/backend/services/admin-service/prisma/schema.prisma b/backend/services/admin-service/prisma/schema.prisma index 7a540b8d..6d750e33 100644 --- a/backend/services/admin-service/prisma/schema.prisma +++ b/backend/services/admin-service/prisma/schema.prisma @@ -1,192 +1,210 @@ -// ============================================================================= -// Admin Service - Prisma Schema -// ============================================================================= - -generator client { - provider = "prisma-client-js" -} - -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -// ============================================================================= -// App Version Management -// ============================================================================= - -model AppVersion { - id String @id @default(uuid()) - platform Platform - versionCode Int // Android: versionCode, iOS: CFBundleVersion - versionName String // 用户可见版本号,如 "1.2.3" - buildNumber String // 构建号 - downloadUrl String // APK/IPA 下载地址 - fileSize BigInt // 文件大小(字节) - fileSha256 String // 文件 SHA-256 校验和 - minOsVersion String? // 最低操作系统版本要求 - changelog String // 更新日志 - isForceUpdate Boolean @default(false) // 是否强制更新 - isEnabled Boolean @default(true) // 是否启用 - releaseDate DateTime? // 发布日期 - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - createdBy String // 创建人ID - updatedBy String? // 更新人ID - - @@index([platform, isEnabled]) - @@index([platform, versionCode]) - @@map("app_versions") -} - -enum Platform { - ANDROID - IOS -} - -// ============================================================================= -// Notification System (通知系统) -// ============================================================================= - -/// 系统通知 - 管理员发布的公告/通知 -model Notification { - id String @id @default(uuid()) - title String // 通知标题 - content String // 通知内容 - type NotificationType // 通知类型 - priority NotificationPriority @default(NORMAL) // 优先级 - targetType TargetType @default(ALL) // 目标用户类型 - imageUrl String? // 可选的图片URL - linkUrl String? // 可选的跳转链接 - isEnabled Boolean @default(true) // 是否启用 - publishedAt DateTime? // 发布时间(null表示草稿) - expiresAt DateTime? // 过期时间(null表示永不过期) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - createdBy String // 创建人ID - - // 用户已读记录 - readRecords NotificationRead[] - - @@index([isEnabled, publishedAt]) - @@index([type]) - @@map("notifications") -} - -/// 用户已读记录 -model NotificationRead { - id String @id @default(uuid()) - notificationId String - userSerialNum String // 用户序列号 - readAt DateTime @default(now()) - - notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) - - @@unique([notificationId, userSerialNum]) - @@index([userSerialNum]) - @@map("notification_reads") -} - -/// 通知类型 -enum NotificationType { - SYSTEM // 系统通知 - ACTIVITY // 活动通知 - REWARD // 收益通知 - UPGRADE // 升级通知 - ANNOUNCEMENT // 公告 -} - -/// 通知优先级 -enum NotificationPriority { - LOW - NORMAL - HIGH - URGENT -} - -/// 目标用户类型 -enum TargetType { - ALL // 所有用户 - NEW_USER // 新用户 - VIP // VIP用户 -} - -// ============================================================================= -// User Query View (用户查询视图 - 通过 Kafka 事件同步) -// ============================================================================= - -/// 用户查询视图 - 本地物化视图,通过消费 Kafka 事件同步维护 -/// 用于 admin-web 用户管理页面的查询,避免跨服务 HTTP 调用 -model UserQueryView { - userId BigInt @id @map("user_id") - accountSequence String @unique @map("account_sequence") @db.VarChar(12) - - // 基本信息 (来自 identity-service 事件) - nickname String? @db.VarChar(100) - avatarUrl String? @map("avatar_url") @db.Text - phoneNumberMasked String? @map("phone_number_masked") @db.VarChar(20) // 脱敏: 138****8888 - - // 推荐关系 - inviterSequence String? @map("inviter_sequence") @db.VarChar(12) - - // KYC 状态 - kycStatus String @default("NOT_VERIFIED") @map("kyc_status") @db.VarChar(20) - - // 认种统计 (来自 planting-service 事件) - personalAdoptionCount Int @default(0) @map("personal_adoption_count") - teamAddressCount Int @default(0) @map("team_address_count") - teamAdoptionCount Int @default(0) @map("team_adoption_count") - - // 授权统计 (来自 authorization-service 事件) - provinceAdoptionCount Int @default(0) @map("province_adoption_count") - cityAdoptionCount Int @default(0) @map("city_adoption_count") - - // 排名 - leaderboardRank Int? @map("leaderboard_rank") - - // 状态 - status String @default("ACTIVE") @db.VarChar(20) - isOnline Boolean @default(false) @map("is_online") - - // 时间戳 - registeredAt DateTime @map("registered_at") - lastActiveAt DateTime? @map("last_active_at") - syncedAt DateTime @default(now()) @map("synced_at") - - @@index([accountSequence]) - @@index([nickname]) - @@index([status]) - @@index([registeredAt]) - @@index([personalAdoptionCount]) - @@index([inviterSequence]) - @@map("user_query_view") -} - -// ============================================================================= -// Kafka Event Tracking (事件消费追踪) -// ============================================================================= - -/// 事件消费位置追踪 - 用于幂等性和断点续传 -model EventConsumerOffset { - id BigInt @id @default(autoincrement()) - consumerGroup String @map("consumer_group") @db.VarChar(100) - topic String @db.VarChar(100) - partition Int - offset BigInt - updatedAt DateTime @default(now()) @map("updated_at") - - @@unique([consumerGroup, topic, partition]) - @@map("event_consumer_offsets") -} - -/// 已处理事件记录 - 用于幂等性检查 -model ProcessedEvent { - id BigInt @id @default(autoincrement()) - eventId String @unique @map("event_id") @db.VarChar(100) - eventType String @map("event_type") @db.VarChar(50) - processedAt DateTime @default(now()) @map("processed_at") - - @@index([eventType]) - @@index([processedAt]) - @@map("processed_events") -} +// ============================================================================= +// Admin Service - Prisma Schema +// ============================================================================= + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ============================================================================= +// App Version Management +// ============================================================================= + +model AppVersion { + id String @id @default(uuid()) + platform Platform + versionCode Int // Android: versionCode, iOS: CFBundleVersion + versionName String // 用户可见版本号,如 "1.2.3" + buildNumber String // 构建号 + downloadUrl String // APK/IPA 下载地址 + fileSize BigInt // 文件大小(字节) + fileSha256 String // 文件 SHA-256 校验和 + minOsVersion String? // 最低操作系统版本要求 + changelog String // 更新日志 + isForceUpdate Boolean @default(false) // 是否强制更新 + isEnabled Boolean @default(true) // 是否启用 + releaseDate DateTime? // 发布日期 + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdBy String // 创建人ID + updatedBy String? // 更新人ID + + @@index([platform, isEnabled]) + @@index([platform, versionCode]) + @@map("app_versions") +} + +enum Platform { + ANDROID + IOS +} + +// ============================================================================= +// Notification System (通知系统) +// ============================================================================= + +/// 系统通知 - 管理员发布的公告/通知 +model Notification { + id String @id @default(uuid()) + title String // 通知标题 + content String // 通知内容 + type NotificationType // 通知类型 + priority NotificationPriority @default(NORMAL) // 优先级 + targetType TargetType @default(ALL) // 目标用户类型 + imageUrl String? // 可选的图片URL + linkUrl String? // 可选的跳转链接 + isEnabled Boolean @default(true) // 是否启用 + publishedAt DateTime? // 发布时间(null表示草稿) + expiresAt DateTime? // 过期时间(null表示永不过期) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdBy String // 创建人ID + + // 用户已读记录 + readRecords NotificationRead[] + + @@index([isEnabled, publishedAt]) + @@index([type]) + @@map("notifications") +} + +/// 用户已读记录 +model NotificationRead { + id String @id @default(uuid()) + notificationId String + userSerialNum String // 用户序列号 + readAt DateTime @default(now()) + + notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) + + @@unique([notificationId, userSerialNum]) + @@index([userSerialNum]) + @@map("notification_reads") +} + +/// 通知类型 +enum NotificationType { + SYSTEM // 系统通知 + ACTIVITY // 活动通知 + REWARD // 收益通知 + UPGRADE // 升级通知 + ANNOUNCEMENT // 公告 +} + +/// 通知优先级 +enum NotificationPriority { + LOW + NORMAL + HIGH + URGENT +} + +/// 目标用户类型 +enum TargetType { + ALL // 所有用户 + NEW_USER // 新用户 + VIP // VIP用户 +} + +// ============================================================================= +// User Query View (用户查询视图 - 通过 Kafka 事件同步) +// ============================================================================= + +/// 用户查询视图 - 本地物化视图,通过消费 Kafka 事件同步维护 +/// 用于 admin-web 用户管理页面的查询,避免跨服务 HTTP 调用 +model UserQueryView { + userId BigInt @id @map("user_id") + accountSequence String @unique @map("account_sequence") @db.VarChar(12) + + // 基本信息 (来自 identity-service 事件) + nickname String? @db.VarChar(100) + avatarUrl String? @map("avatar_url") @db.Text + phoneNumberMasked String? @map("phone_number_masked") @db.VarChar(20) // 脱敏: 138****8888 + + // 推荐关系 + inviterSequence String? @map("inviter_sequence") @db.VarChar(12) + + // KYC 状态 + kycStatus String @default("NOT_VERIFIED") @map("kyc_status") @db.VarChar(20) + + // 认种统计 (来自 planting-service 事件) + personalAdoptionCount Int @default(0) @map("personal_adoption_count") + teamAddressCount Int @default(0) @map("team_address_count") + teamAdoptionCount Int @default(0) @map("team_adoption_count") + + // 授权统计 (来自 authorization-service 事件) + provinceAdoptionCount Int @default(0) @map("province_adoption_count") + cityAdoptionCount Int @default(0) @map("city_adoption_count") + + // 排名 + leaderboardRank Int? @map("leaderboard_rank") + + // 状态 + status String @default("ACTIVE") @db.VarChar(20) + isOnline Boolean @default(false) @map("is_online") + + // 时间戳 + registeredAt DateTime @map("registered_at") + lastActiveAt DateTime? @map("last_active_at") + syncedAt DateTime @default(now()) @map("synced_at") + + @@index([accountSequence]) + @@index([nickname]) + @@index([status]) + @@index([registeredAt]) + @@index([personalAdoptionCount]) + @@index([inviterSequence]) + @@map("user_query_view") +} + +// ============================================================================= +// Kafka Event Tracking (事件消费追踪) +// ============================================================================= + +/// 事件消费位置追踪 - 用于幂等性和断点续传 +model EventConsumerOffset { + id BigInt @id @default(autoincrement()) + consumerGroup String @map("consumer_group") @db.VarChar(100) + topic String @db.VarChar(100) + partition Int + offset BigInt + updatedAt DateTime @default(now()) @map("updated_at") + + @@unique([consumerGroup, topic, partition]) + @@map("event_consumer_offsets") +} + +/// 已处理事件记录 - 用于幂等性检查 +model ProcessedEvent { + id BigInt @id @default(autoincrement()) + eventId String @unique @map("event_id") @db.VarChar(100) + eventType String @map("event_type") @db.VarChar(50) + processedAt DateTime @default(now()) @map("processed_at") + + @@index([eventType]) + @@index([processedAt]) + @@map("processed_events") +} + +// ============================================================================= +// System Config (系统配置) +// ============================================================================= + +/// 系统配置 - 键值对存储,用于存储各种系统设置 +model SystemConfig { + id String @id @default(uuid()) + key String @unique @db.VarChar(100) // 配置键 + value String @db.Text // 配置值 (JSON 格式) + description String? @db.VarChar(255) // 配置描述 + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + updatedBy String? @map("updated_by") // 更新人ID + + @@index([key]) + @@map("system_configs") +} diff --git a/backend/services/admin-service/src/api/controllers/system-config.controller.ts b/backend/services/admin-service/src/api/controllers/system-config.controller.ts new file mode 100644 index 00000000..501ed92d --- /dev/null +++ b/backend/services/admin-service/src/api/controllers/system-config.controller.ts @@ -0,0 +1,202 @@ +import { + Controller, + Get, + Put, + Body, + Param, + HttpCode, + HttpStatus, + Inject, + Query, +} from '@nestjs/common'; +import { + SYSTEM_CONFIG_REPOSITORY, + ISystemConfigRepository, +} from '../../domain/repositories/system-config.repository'; + +/** + * 系统配置常量 - 热度展示设置 + */ +export const CONFIG_KEYS = { + // 是否允许未认种用户查看各省认种热度 + ALLOW_NON_ADOPTER_VIEW_HEAT: 'display.allowNonAdopterViewHeat', + // 热度展示方式: 'count' | 'level' + HEAT_DISPLAY_MODE: 'display.heatDisplayMode', +} as const; + +/** + * 获取系统配置响应 DTO + */ +interface SystemConfigResponseDto { + key: string; + value: string; + description?: string; + updatedAt: Date; +} + +/** + * 前端展示设置响应 DTO + */ +interface DisplaySettingsResponseDto { + allowNonAdopterViewHeat: boolean; + heatDisplayMode: 'count' | 'level'; +} + +/** + * 前端展示设置请求 DTO + */ +interface UpdateDisplaySettingsDto { + allowNonAdopterViewHeat?: boolean; + heatDisplayMode?: 'count' | 'level'; +} + +/** + * 管理端系统配置控制器 + */ +@Controller('admin/system-config') +export class AdminSystemConfigController { + constructor( + @Inject(SYSTEM_CONFIG_REPOSITORY) + private readonly configRepo: ISystemConfigRepository, + ) {} + + /** + * 获取所有系统配置 + */ + @Get() + async getAll(): Promise { + const configs = await this.configRepo.findAll(); + return configs.map((c) => ({ + key: c.key, + value: c.value, + description: c.description, + updatedAt: c.updatedAt, + })); + } + + /** + * 获取单个配置 + */ + @Get(':key') + async getByKey(@Param('key') key: string): Promise { + const config = await this.configRepo.findByKey(key); + if (!config) { + return null; + } + return { + key: config.key, + value: config.value, + description: config.description, + updatedAt: config.updatedAt, + }; + } + + /** + * 获取前端展示设置 + */ + @Get('display/settings') + async getDisplaySettings(): Promise { + const configs = await this.configRepo.findByKeys([ + CONFIG_KEYS.ALLOW_NON_ADOPTER_VIEW_HEAT, + CONFIG_KEYS.HEAT_DISPLAY_MODE, + ]); + + const configMap = new Map(configs.map((c) => [c.key, c.value])); + + return { + allowNonAdopterViewHeat: + configMap.get(CONFIG_KEYS.ALLOW_NON_ADOPTER_VIEW_HEAT) === 'true', + heatDisplayMode: + (configMap.get(CONFIG_KEYS.HEAT_DISPLAY_MODE) as 'count' | 'level') || 'count', + }; + } + + /** + * 更新前端展示设置 + */ + @Put('display/settings') + @HttpCode(HttpStatus.OK) + async updateDisplaySettings( + @Body() dto: UpdateDisplaySettingsDto, + ): Promise { + const updates: Array<{ key: string; value: string; description?: string }> = []; + + if (dto.allowNonAdopterViewHeat !== undefined) { + updates.push({ + key: CONFIG_KEYS.ALLOW_NON_ADOPTER_VIEW_HEAT, + value: String(dto.allowNonAdopterViewHeat), + description: '是否允许未认种用户查看各省认种热度', + }); + } + + if (dto.heatDisplayMode !== undefined) { + updates.push({ + key: CONFIG_KEYS.HEAT_DISPLAY_MODE, + value: dto.heatDisplayMode, + description: '热度展示方式: count=显示具体数量, level=仅显示热度等级', + }); + } + + if (updates.length > 0) { + await this.configRepo.batchUpsert(updates, 'admin'); + } + + // 返回更新后的配置 + return this.getDisplaySettings(); + } + + /** + * 更新单个配置 + */ + @Put(':key') + @HttpCode(HttpStatus.OK) + async updateByKey( + @Param('key') key: string, + @Body() body: { value: string; description?: string }, + ): Promise { + const config = await this.configRepo.upsert( + key, + body.value, + body.description, + 'admin', + ); + return { + key: config.key, + value: config.value, + description: config.description, + updatedAt: config.updatedAt, + }; + } +} + +/** + * 移动端/公开系统配置控制器 + * 用于 mobile-app 获取展示相关的配置 + */ +@Controller('api/v1/system-config') +export class PublicSystemConfigController { + constructor( + @Inject(SYSTEM_CONFIG_REPOSITORY) + private readonly configRepo: ISystemConfigRepository, + ) {} + + /** + * 获取前端展示设置(公开接口) + */ + @Get('display/settings') + async getDisplaySettings(): Promise { + const configs = await this.configRepo.findByKeys([ + CONFIG_KEYS.ALLOW_NON_ADOPTER_VIEW_HEAT, + CONFIG_KEYS.HEAT_DISPLAY_MODE, + ]); + + const configMap = new Map(configs.map((c) => [c.key, c.value])); + + return { + allowNonAdopterViewHeat: + configMap.get(CONFIG_KEYS.ALLOW_NON_ADOPTER_VIEW_HEAT) === 'true', + heatDisplayMode: + (configMap.get(CONFIG_KEYS.HEAT_DISPLAY_MODE) as 'count' | 'level') || 'count', + }; + } +} diff --git a/backend/services/admin-service/src/api/controllers/user.controller.ts b/backend/services/admin-service/src/api/controllers/user.controller.ts index a5c9ab12..42ec4fd9 100644 --- a/backend/services/admin-service/src/api/controllers/user.controller.ts +++ b/backend/services/admin-service/src/api/controllers/user.controller.ts @@ -148,6 +148,7 @@ export class UserController { accountSequence: item.accountSequence, avatar: item.avatarUrl, nickname: item.nickname, + phoneNumberMasked: item.phoneNumberMasked, personalAdoptions: item.personalAdoptionCount, teamAddresses: item.teamAddressCount, teamAdoptions: item.teamAdoptionCount, @@ -171,7 +172,6 @@ export class UserController { return { ...listItem, - phoneNumberMasked: item.phoneNumberMasked, kycStatus: item.kycStatus, registeredAt: item.registeredAt.toISOString(), lastActiveAt: item.lastActiveAt?.toISOString() || null, diff --git a/backend/services/admin-service/src/api/dto/response/user.dto.ts b/backend/services/admin-service/src/api/dto/response/user.dto.ts index c07874ed..1e822a82 100644 --- a/backend/services/admin-service/src/api/dto/response/user.dto.ts +++ b/backend/services/admin-service/src/api/dto/response/user.dto.ts @@ -6,6 +6,7 @@ export class UserListItemDto { accountSequence!: string; avatar!: string | null; nickname!: string | null; + phoneNumberMasked!: string | null; personalAdoptions!: number; teamAddresses!: number; teamAdoptions!: number; @@ -38,7 +39,6 @@ export class UserListResponseDto { * 用户详情响应 DTO */ export class UserDetailDto extends UserListItemDto { - phoneNumberMasked!: string | null; kycStatus!: string; registeredAt!: string; lastActiveAt!: string | null; diff --git a/backend/services/admin-service/src/app.module.ts b/backend/services/admin-service/src/app.module.ts index b9c2af11..50c8a984 100644 --- a/backend/services/admin-service/src/app.module.ts +++ b/backend/services/admin-service/src/app.module.ts @@ -31,6 +31,10 @@ import { UserQueryRepositoryImpl } from './infrastructure/persistence/repositori import { USER_QUERY_REPOSITORY } from './domain/repositories/user-query.repository'; import { UserController } from './api/controllers/user.controller'; import { UserEventConsumerService } from './infrastructure/kafka/user-event-consumer.service'; +// System Config imports +import { SystemConfigRepositoryImpl } from './infrastructure/persistence/repositories/system-config.repository.impl'; +import { SYSTEM_CONFIG_REPOSITORY } from './domain/repositories/system-config.repository'; +import { AdminSystemConfigController, PublicSystemConfigController } from './api/controllers/system-config.controller'; @Module({ imports: [ @@ -52,6 +56,8 @@ import { UserEventConsumerService } from './infrastructure/kafka/user-event-cons AdminNotificationController, MobileNotificationController, UserController, + AdminSystemConfigController, + PublicSystemConfigController, ], providers: [ PrismaService, @@ -84,6 +90,11 @@ import { UserEventConsumerService } from './infrastructure/kafka/user-event-cons useClass: UserQueryRepositoryImpl, }, UserEventConsumerService, + // System Config + { + provide: SYSTEM_CONFIG_REPOSITORY, + useClass: SystemConfigRepositoryImpl, + }, ], }) export class AppModule {} diff --git a/backend/services/admin-service/src/domain/repositories/system-config.repository.ts b/backend/services/admin-service/src/domain/repositories/system-config.repository.ts new file mode 100644 index 00000000..0a670084 --- /dev/null +++ b/backend/services/admin-service/src/domain/repositories/system-config.repository.ts @@ -0,0 +1,58 @@ +/** + * 系统配置仓储接口 + */ +export const SYSTEM_CONFIG_REPOSITORY = Symbol('SYSTEM_CONFIG_REPOSITORY'); + +export interface SystemConfigEntity { + id: string; + key: string; + value: string; + description?: string; + createdAt: Date; + updatedAt: Date; + updatedBy?: string; +} + +export interface ISystemConfigRepository { + /** + * 根据配置键获取配置 + */ + findByKey(key: string): Promise; + + /** + * 获取多个配置 + */ + findByKeys(keys: string[]): Promise; + + /** + * 获取所有配置 + */ + findAll(): Promise; + + /** + * 保存或更新配置 + */ + upsert( + key: string, + value: string, + description?: string, + updatedBy?: string, + ): Promise; + + /** + * 批量保存或更新配置 + */ + batchUpsert( + configs: Array<{ + key: string; + value: string; + description?: string; + }>, + updatedBy?: string, + ): Promise; + + /** + * 删除配置 + */ + delete(key: string): Promise; +} diff --git a/backend/services/admin-service/src/infrastructure/persistence/repositories/system-config.repository.impl.ts b/backend/services/admin-service/src/infrastructure/persistence/repositories/system-config.repository.impl.ts new file mode 100644 index 00000000..ce6062ea --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/persistence/repositories/system-config.repository.impl.ts @@ -0,0 +1,99 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { + ISystemConfigRepository, + SystemConfigEntity, +} from '../../../domain/repositories/system-config.repository'; + +@Injectable() +export class SystemConfigRepositoryImpl implements ISystemConfigRepository { + constructor(private readonly prisma: PrismaService) {} + + async findByKey(key: string): Promise { + const config = await this.prisma.systemConfig.findUnique({ + where: { key }, + }); + + return config; + } + + async findByKeys(keys: string[]): Promise { + const configs = await this.prisma.systemConfig.findMany({ + where: { key: { in: keys } }, + }); + + return configs; + } + + async findAll(): Promise { + const configs = await this.prisma.systemConfig.findMany({ + orderBy: { key: 'asc' }, + }); + + return configs; + } + + async upsert( + key: string, + value: string, + description?: string, + updatedBy?: string, + ): Promise { + const config = await this.prisma.systemConfig.upsert({ + where: { key }, + create: { + key, + value, + description, + updatedBy, + }, + update: { + value, + description, + updatedBy, + }, + }); + + return config; + } + + async batchUpsert( + configs: Array<{ + key: string; + value: string; + description?: string; + }>, + updatedBy?: string, + ): Promise { + const results: SystemConfigEntity[] = []; + + // 使用事务确保批量操作的原子性 + await this.prisma.$transaction(async (tx) => { + for (const config of configs) { + const result = await tx.systemConfig.upsert({ + where: { key: config.key }, + create: { + key: config.key, + value: config.value, + description: config.description, + updatedBy, + }, + update: { + value: config.value, + description: config.description, + updatedBy, + }, + }); + results.push(result); + } + }); + + return results; + } + + async delete(key: string): Promise { + await this.prisma.systemConfig.delete({ + where: { key }, + }); + } +} diff --git a/backend/services/authorization-service/src/api/controllers/authorization.controller.ts b/backend/services/authorization-service/src/api/controllers/authorization.controller.ts index 73986392..c9f38861 100644 --- a/backend/services/authorization-service/src/api/controllers/authorization.controller.ts +++ b/backend/services/authorization-service/src/api/controllers/authorization.controller.ts @@ -26,6 +26,7 @@ import { RevokeAuthorizationCommand, GrantMonthlyBypassCommand, ExemptLocalPercentageCheckCommand, + SelfApplyAuthorizationCommand, } from '@/application/commands' import { ApplyCommunityAuthDto, @@ -33,6 +34,10 @@ import { ApplyAuthCityDto, RevokeAuthorizationDto, GrantMonthlyBypassDto, + SelfApplyAuthorizationDto, + SelfApplyAuthorizationResponseDto, + UserAuthorizationStatusResponseDto, + SelfApplyAuthorizationType, } from '@/api/dto/request' import { AuthorizationResponse, @@ -122,11 +127,12 @@ export class AuthorizationController { @ApiQuery({ name: 'regionCode', description: '区域代码' }) @ApiResponse({ status: 200, type: [StickmanRankingResponse] }) async getStickmanRanking( + @CurrentUser() user: { userId: string; accountSequence: string }, @Query('month') month: string, @Query('roleType') roleType: RoleType, @Query('regionCode') regionCode: string, ): Promise { - return await this.applicationService.getStickmanRanking(month, roleType, regionCode) + return await this.applicationService.getStickmanRanking(month, roleType, regionCode, user.userId) } @Delete(':id') @@ -169,4 +175,44 @@ export class AuthorizationController { const command = new ExemptLocalPercentageCheckCommand(id, user.accountSequence) await this.applicationService.exemptLocalPercentageCheck(command) } + + // ============================================ + // 自助申请授权相关接口 + // ============================================ + + @Get('self-apply/status') + @ApiOperation({ + summary: '获取用户授权申请状态', + description: '获取用户是否已认种、认种棵数、已有授权和待审核申请', + }) + @ApiResponse({ status: 200, type: UserAuthorizationStatusResponseDto }) + async getUserAuthorizationStatus( + @CurrentUser() user: { userId: string; accountSequence: string }, + ): Promise { + return await this.applicationService.getUserAuthorizationStatus(user.accountSequence) + } + + @Post('self-apply') + @ApiOperation({ + summary: '自助申请授权', + description: '用户自助申请社区、市团队或省团队授权(需先认种并上传办公室照片)', + }) + @ApiResponse({ status: 201, type: SelfApplyAuthorizationResponseDto }) + async selfApplyAuthorization( + @CurrentUser() user: { userId: string; accountSequence: string }, + @Body() dto: SelfApplyAuthorizationDto, + ): Promise { + const command = new SelfApplyAuthorizationCommand( + user.userId, + user.accountSequence, + dto.type, + dto.officePhotoUrls, + dto.communityName, + dto.provinceCode, + dto.provinceName, + dto.cityCode, + dto.cityName, + ) + return await this.applicationService.selfApplyAuthorization(command) + } } diff --git a/backend/services/authorization-service/src/api/dto/request/index.ts b/backend/services/authorization-service/src/api/dto/request/index.ts index e9d2b81f..f52c5cb8 100644 --- a/backend/services/authorization-service/src/api/dto/request/index.ts +++ b/backend/services/authorization-service/src/api/dto/request/index.ts @@ -8,3 +8,4 @@ export * from './grant-auth-province-company.dto' export * from './grant-auth-city-company.dto' export * from './revoke-authorization.dto' export * from './grant-monthly-bypass.dto' +export * from './self-apply-authorization.dto' diff --git a/backend/services/authorization-service/src/api/dto/request/self-apply-authorization.dto.ts b/backend/services/authorization-service/src/api/dto/request/self-apply-authorization.dto.ts new file mode 100644 index 00000000..d6e61b45 --- /dev/null +++ b/backend/services/authorization-service/src/api/dto/request/self-apply-authorization.dto.ts @@ -0,0 +1,121 @@ +import { + IsString, + IsNotEmpty, + MaxLength, + IsEnum, + IsArray, + ArrayMinSize, + ArrayMaxSize, + IsUrl, + IsOptional, +} from 'class-validator' +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' + +/** + * 自助申请授权类型 + * 仅支持社区、市团队、省团队 + * 省区域和市区域不支持自助申请 + */ +export enum SelfApplyAuthorizationType { + COMMUNITY = 'COMMUNITY', // 社区 + CITY_TEAM = 'CITY_TEAM', // 市团队 + PROVINCE_TEAM = 'PROVINCE_TEAM', // 省团队 +} + +/** + * 自助申请授权请求 DTO + */ +export class SelfApplyAuthorizationDto { + @ApiProperty({ + description: '申请类型', + enum: SelfApplyAuthorizationType, + example: SelfApplyAuthorizationType.COMMUNITY, + }) + @IsEnum(SelfApplyAuthorizationType, { message: '无效的申请类型' }) + type: SelfApplyAuthorizationType + + @ApiPropertyOptional({ description: '社区名称(申请社区时必填)', example: '量子社区' }) + @IsOptional() + @IsString() + @MaxLength(50, { message: '社区名称最大50字符' }) + communityName?: string + + @ApiPropertyOptional({ description: '省份代码(申请省团队时必填)', example: '440000' }) + @IsOptional() + @IsString() + provinceCode?: string + + @ApiPropertyOptional({ description: '省份名称(申请省团队时必填)', example: '广东省' }) + @IsOptional() + @IsString() + @MaxLength(50) + provinceName?: string + + @ApiPropertyOptional({ description: '城市代码(申请市团队时必填)', example: '440100' }) + @IsOptional() + @IsString() + cityCode?: string + + @ApiPropertyOptional({ description: '城市名称(申请市团队时必填)', example: '广州市' }) + @IsOptional() + @IsString() + @MaxLength(50) + cityName?: string + + @ApiProperty({ + description: '办公室照片URL列表(至少2张,最多6张)', + example: [ + 'https://storage.example.com/office1.jpg', + 'https://storage.example.com/office2.jpg', + ], + }) + @IsArray() + @ArrayMinSize(2, { message: '请至少上传2张办公室照片' }) + @ArrayMaxSize(6, { message: '最多上传6张办公室照片' }) + @IsUrl({}, { each: true, message: '照片URL格式不正确' }) + officePhotoUrls: string[] +} + +/** + * 自助申请授权响应 DTO + */ +export class SelfApplyAuthorizationResponseDto { + @ApiProperty({ description: '申请ID' }) + applicationId: string + + @ApiProperty({ description: '申请状态', enum: ['PENDING', 'APPROVED', 'REJECTED'] }) + status: 'PENDING' | 'APPROVED' | 'REJECTED' + + @ApiProperty({ description: '申请类型' }) + type: SelfApplyAuthorizationType + + @ApiProperty({ description: '申请时间' }) + appliedAt: Date + + @ApiPropertyOptional({ description: '审核时间' }) + reviewedAt?: Date + + @ApiPropertyOptional({ description: '审核备注' }) + reviewNote?: string +} + +/** + * 用户授权申请状态响应 DTO + */ +export class UserAuthorizationStatusResponseDto { + @ApiProperty({ description: '是否已认种' }) + hasPlanted: boolean + + @ApiProperty({ description: '认种棵数' }) + plantedCount: number + + @ApiProperty({ description: '已拥有的授权类型列表' }) + existingAuthorizations: string[] + + @ApiProperty({ description: '待审核的申请列表' }) + pendingApplications: { + applicationId: string + type: SelfApplyAuthorizationType + appliedAt: Date + }[] +} diff --git a/backend/services/authorization-service/src/api/dto/response/authorization.response.ts b/backend/services/authorization-service/src/api/dto/response/authorization.response.ts index a1259b06..5fee76b6 100644 --- a/backend/services/authorization-service/src/api/dto/response/authorization.response.ts +++ b/backend/services/authorization-service/src/api/dto/response/authorization.response.ts @@ -75,6 +75,9 @@ export class ApplyAuthorizationResponse { } export class StickmanRankingResponse { + @ApiProperty({ description: '唯一标识(授权ID)' }) + id: string + @ApiProperty({ description: '用户ID' }) userId: string @@ -87,12 +90,21 @@ export class StickmanRankingResponse { @ApiProperty({ description: '区域代码' }) regionCode: string - @ApiPropertyOptional({ description: '昵称' }) - nickname?: string + @ApiProperty({ description: '昵称' }) + nickname: string @ApiPropertyOptional({ description: '头像URL' }) avatarUrl?: string + @ApiProperty({ description: '完成数量(累计完成)' }) + completedCount: number + + @ApiProperty({ description: '本月可结算收益(USDT)' }) + monthlyEarnings: number + + @ApiProperty({ description: '是否是当前用户' }) + isCurrentUser: boolean + @ApiProperty({ description: '排名' }) ranking: number diff --git a/backend/services/authorization-service/src/app.module.ts b/backend/services/authorization-service/src/app.module.ts index 8dd3ec3e..aa9c1ca0 100644 --- a/backend/services/authorization-service/src/app.module.ts +++ b/backend/services/authorization-service/src/app.module.ts @@ -20,7 +20,7 @@ import { import { RedisModule } from '@/infrastructure/redis/redis.module' import { KafkaModule } from '@/infrastructure/kafka/kafka.module' import { EventConsumerController } from '@/infrastructure/kafka/event-consumer.controller' -import { ReferralServiceClient } from '@/infrastructure/external/referral-service.client' +import { ReferralServiceClient, IdentityServiceClient } from '@/infrastructure/external' // Application import { AuthorizationApplicationService, REFERRAL_REPOSITORY, TEAM_STATISTICS_REPOSITORY } from '@/application/services' @@ -88,6 +88,7 @@ const MockReferralRepository = { // External Service Clients (replaces mock) ReferralServiceClient, + IdentityServiceClient, { provide: TEAM_STATISTICS_REPOSITORY, useExisting: ReferralServiceClient, diff --git a/backend/services/authorization-service/src/application/commands/index.ts b/backend/services/authorization-service/src/application/commands/index.ts index 8caf7139..d47fd48c 100644 --- a/backend/services/authorization-service/src/application/commands/index.ts +++ b/backend/services/authorization-service/src/application/commands/index.ts @@ -9,3 +9,4 @@ export * from './grant-auth-city-company.command' export * from './revoke-authorization.command' export * from './grant-monthly-bypass.command' export * from './exempt-percentage-check.command' +export * from './self-apply-authorization.command' diff --git a/backend/services/authorization-service/src/application/commands/self-apply-authorization.command.ts b/backend/services/authorization-service/src/application/commands/self-apply-authorization.command.ts new file mode 100644 index 00000000..1fb7a6c5 --- /dev/null +++ b/backend/services/authorization-service/src/application/commands/self-apply-authorization.command.ts @@ -0,0 +1,18 @@ +import { SelfApplyAuthorizationType } from '@/api/dto/request/self-apply-authorization.dto' + +/** + * 自助申请授权命令 + */ +export class SelfApplyAuthorizationCommand { + constructor( + public readonly userId: string, + public readonly accountSequence: string, + public readonly type: SelfApplyAuthorizationType, + public readonly officePhotoUrls: string[], + public readonly communityName?: string, + public readonly provinceCode?: string, + public readonly provinceName?: string, + public readonly cityCode?: string, + public readonly cityName?: string, + ) {} +} diff --git a/backend/services/authorization-service/src/application/dto/authorization.dto.ts b/backend/services/authorization-service/src/application/dto/authorization.dto.ts index 6f5a5e19..a0be3bea 100644 --- a/backend/services/authorization-service/src/application/dto/authorization.dto.ts +++ b/backend/services/authorization-service/src/application/dto/authorization.dto.ts @@ -21,12 +21,16 @@ export interface AuthorizationDTO { } export interface StickmanRankingDTO { + id: string // 授权ID,用于前端唯一标识 userId: string authorizationId: string roleType: RoleType regionCode: string - nickname?: string - avatarUrl?: string + nickname: string // 用户昵称 + avatarUrl?: string // 头像URL + completedCount: number // 完成数量(累计完成) + monthlyEarnings: number // 本月可结算收益(USDT) + isCurrentUser: boolean // 是否是当前请求用户 ranking: number isFirstPlace: boolean cumulativeCompleted: number diff --git a/backend/services/authorization-service/src/application/services/authorization-application.service.ts b/backend/services/authorization-service/src/application/services/authorization-application.service.ts index 2ced42a8..2b279b34 100644 --- a/backend/services/authorization-service/src/application/services/authorization-application.service.ts +++ b/backend/services/authorization-service/src/application/services/authorization-application.service.ts @@ -22,7 +22,7 @@ import { TeamStatistics, } from '@/domain/services' import { EventPublisherService } from '@/infrastructure/kafka' -import { ReferralServiceClient } from '@/infrastructure/external' +import { ReferralServiceClient, IdentityServiceClient } from '@/infrastructure/external' import { ApplicationError, NotFoundError } from '@/shared/exceptions' import { ApplyCommunityAuthCommand, @@ -39,7 +39,13 @@ import { RevokeAuthorizationCommand, GrantMonthlyBypassCommand, ExemptLocalPercentageCheckCommand, + SelfApplyAuthorizationCommand, } from '@/application/commands' +import { + SelfApplyAuthorizationResponseDto, + UserAuthorizationStatusResponseDto, + SelfApplyAuthorizationType, +} from '@/api/dto/request/self-apply-authorization.dto' import { AuthorizationDTO, StickmanRankingDTO, CommunityHierarchyDTO } from '@/application/dto' export const REFERRAL_REPOSITORY = Symbol('IReferralRepository') @@ -61,6 +67,7 @@ export class AuthorizationApplicationService { private readonly statsRepository: ITeamStatisticsRepository, private readonly eventPublisher: EventPublisherService, private readonly referralServiceClient: ReferralServiceClient, + private readonly identityServiceClient: IdentityServiceClient, ) {} /** @@ -569,6 +576,7 @@ export class AuthorizationApplicationService { month: string, roleType: RoleType, regionCode: string, + currentUserId?: string, ): Promise { const assessments = await this.assessmentRepository.findRankingsByMonthAndRegion( Month.create(month), @@ -576,15 +584,30 @@ export class AuthorizationApplicationService { RegionCode.create(regionCode), ) + if (assessments.length === 0) { + return [] + } + + // 批量获取用户信息 + const userIds = assessments.map(a => a.userId.value) + const userInfoMap = await this.identityServiceClient.batchGetUserInfo(userIds) + const rankings: StickmanRankingDTO[] = [] const finalTarget = LadderTargetRule.getFinalTarget(roleType) for (const assessment of assessments) { + const userInfo = userInfoMap.get(assessment.userId.value) rankings.push({ + id: assessment.authorizationId.value, userId: assessment.userId.value, authorizationId: assessment.authorizationId.value, roleType: assessment.roleType, regionCode: assessment.regionCode.value, + nickname: userInfo?.nickname || `用户${assessment.userId.accountSequence.slice(-4)}`, + avatarUrl: userInfo?.avatarUrl, + completedCount: assessment.cumulativeCompleted, + monthlyEarnings: 0, // TODO: 从奖励服务获取本月可结算收益 + isCurrentUser: currentUserId ? assessment.userId.value === currentUserId : false, ranking: assessment.rankingInRegion || 0, isFirstPlace: assessment.isFirstPlace, cumulativeCompleted: assessment.cumulativeCompleted, @@ -597,6 +620,9 @@ export class AuthorizationApplicationService { }) } + // 按完成数量降序排序 + rankings.sort((a, b) => b.completedCount - a.completedCount) + return rankings } @@ -2734,4 +2760,247 @@ export class AuthorizationApplicationService { limit, ) } + + // ============================================ + // 自助申请授权相关方法 + // ============================================ + + /** + * 获取用户授权申请状态 + */ + async getUserAuthorizationStatus( + accountSequence: string, + ): Promise { + this.logger.log(`[getUserAuthorizationStatus] accountSequence=${accountSequence}`) + + // 1. 获取用户认种数据 + const teamStats = await this.statsRepository.findByAccountSequence(accountSequence) + const hasPlanted = (teamStats?.personalPlantingCount || 0) > 0 + const plantedCount = teamStats?.personalPlantingCount || 0 + + // 2. 获取用户已有的授权 + const authorizations = await this.authorizationRepository.findByAccountSequence(accountSequence) + const existingAuthorizations = authorizations + .filter(auth => auth.status !== AuthorizationStatus.REVOKED) + .map(auth => this.mapRoleTypeToDisplayName(auth.roleType)) + + // 3. 获取待审核的申请(暂时返回空,待实现申请审核功能) + const pendingApplications: { + applicationId: string + type: SelfApplyAuthorizationType + appliedAt: Date + }[] = [] + + return { + hasPlanted, + plantedCount, + existingAuthorizations, + pendingApplications, + } + } + + /** + * 自助申请授权 + */ + async selfApplyAuthorization( + command: SelfApplyAuthorizationCommand, + ): Promise { + this.logger.log( + `[selfApplyAuthorization] userId=${command.userId}, type=${command.type}, ` + + `photos=${command.officePhotoUrls.length}`, + ) + + // 1. 验证用户已认种 + const teamStats = await this.statsRepository.findByAccountSequence(command.accountSequence) + const personalPlantingCount = teamStats?.personalPlantingCount || 0 + if (personalPlantingCount <= 0) { + throw new ApplicationError('申请授权需要先完成认种') + } + + // 2. 验证照片数量 + if (command.officePhotoUrls.length < 2) { + throw new ApplicationError('请至少上传2张办公室照片') + } + if (command.officePhotoUrls.length > 6) { + throw new ApplicationError('最多上传6张办公室照片') + } + + // 3. 根据申请类型处理 + let result: SelfApplyAuthorizationResponseDto + + switch (command.type) { + case SelfApplyAuthorizationType.COMMUNITY: + if (!command.communityName) { + throw new ApplicationError('申请社区授权需要提供社区名称') + } + result = await this.processCommunityApplication(command) + break + + case SelfApplyAuthorizationType.CITY_TEAM: + if (!command.cityCode || !command.cityName) { + throw new ApplicationError('申请市团队授权需要提供城市信息') + } + result = await this.processCityTeamApplication(command) + break + + case SelfApplyAuthorizationType.PROVINCE_TEAM: + if (!command.provinceCode || !command.provinceName) { + throw new ApplicationError('申请省团队授权需要提供省份信息') + } + result = await this.processProvinceTeamApplication(command) + break + + default: + throw new ApplicationError('不支持的授权类型') + } + + return result + } + + /** + * 处理社区授权申请 + */ + private async processCommunityApplication( + command: SelfApplyAuthorizationCommand, + ): Promise { + // 检查是否已有社区授权 + const existing = await this.authorizationRepository.findByAccountSequenceAndRoleType( + command.accountSequence, + RoleType.COMMUNITY, + ) + if (existing && existing.status !== AuthorizationStatus.REVOKED) { + throw new ApplicationError('您已拥有社区授权') + } + + // 直接创建授权(自助申请的社区授权直接生效) + const userId = UserId.create(command.userId, command.accountSequence) + const authorization = AuthorizationRole.createCommunityAuth({ + userId, + communityName: command.communityName!, + }) + + // 检查初始考核 + const teamStats = await this.statsRepository.findByAccountSequence(command.accountSequence) + const subordinateTreeCount = teamStats?.subordinateTeamPlantingCount || 0 + if (subordinateTreeCount >= authorization.getInitialTarget()) { + authorization.activateBenefit() + } + + await this.authorizationRepository.save(authorization) + await this.eventPublisher.publishAll(authorization.domainEvents) + authorization.clearDomainEvents() + + return { + applicationId: authorization.id, + status: 'APPROVED', + type: SelfApplyAuthorizationType.COMMUNITY, + appliedAt: new Date(), + reviewedAt: new Date(), + reviewNote: '自助申请自动审核通过', + } + } + + /** + * 处理市团队授权申请 + */ + private async processCityTeamApplication( + command: SelfApplyAuthorizationCommand, + ): Promise { + // 检查是否已有该市的授权市公司 + const existing = await this.authorizationRepository.findByRegionCodeAndRoleType( + RegionCode.create(command.cityCode!), + RoleType.AUTH_CITY_COMPANY, + ) + if (existing && existing.status !== AuthorizationStatus.REVOKED) { + throw new ApplicationError('该市已有授权市公司') + } + + // 创建授权市公司授权 + const userId = UserId.create(command.userId, command.accountSequence) + const regionCode = RegionCode.create(command.cityCode!) + const authorization = AuthorizationRole.createAuthCityCompany({ + userId, + regionCode, + cityName: command.cityName!, + }) + + // 检查初始考核(100棵) + const teamStats = await this.statsRepository.findByAccountSequence(command.accountSequence) + const subordinateTreeCount = teamStats?.subordinateTeamPlantingCount || 0 + if (subordinateTreeCount >= authorization.getInitialTarget()) { + authorization.activateBenefit() + } + + await this.authorizationRepository.save(authorization) + await this.eventPublisher.publishAll(authorization.domainEvents) + authorization.clearDomainEvents() + + return { + applicationId: authorization.id, + status: 'APPROVED', + type: SelfApplyAuthorizationType.CITY_TEAM, + appliedAt: new Date(), + reviewedAt: new Date(), + reviewNote: '自助申请自动审核通过', + } + } + + /** + * 处理省团队授权申请 + */ + private async processProvinceTeamApplication( + command: SelfApplyAuthorizationCommand, + ): Promise { + // 检查是否已有该省的授权省公司 + const existing = await this.authorizationRepository.findByRegionCodeAndRoleType( + RegionCode.create(command.provinceCode!), + RoleType.AUTH_PROVINCE_COMPANY, + ) + if (existing && existing.status !== AuthorizationStatus.REVOKED) { + throw new ApplicationError('该省已有授权省公司') + } + + // 创建授权省公司授权 + const userId = UserId.create(command.userId, command.accountSequence) + const regionCode = RegionCode.create(command.provinceCode!) + const authorization = AuthorizationRole.createAuthProvinceCompany({ + userId, + regionCode, + provinceName: command.provinceName!, + }) + + // 检查初始考核(500棵) + const teamStats = await this.statsRepository.findByAccountSequence(command.accountSequence) + const subordinateTreeCount = teamStats?.subordinateTeamPlantingCount || 0 + if (subordinateTreeCount >= authorization.getInitialTarget()) { + authorization.activateBenefit() + } + + await this.authorizationRepository.save(authorization) + await this.eventPublisher.publishAll(authorization.domainEvents) + authorization.clearDomainEvents() + + return { + applicationId: authorization.id, + status: 'APPROVED', + type: SelfApplyAuthorizationType.PROVINCE_TEAM, + appliedAt: new Date(), + reviewedAt: new Date(), + reviewNote: '自助申请自动审核通过', + } + } + + /** + * 将 RoleType 映射为显示名称 + */ + private mapRoleTypeToDisplayName(roleType: RoleType): string { + const mapping: Record = { + [RoleType.COMMUNITY]: '社区', + [RoleType.AUTH_CITY_COMPANY]: '市团队', + [RoleType.AUTH_PROVINCE_COMPANY]: '省团队', + [RoleType.CITY_COMPANY]: '市区域', + [RoleType.PROVINCE_COMPANY]: '省区域', + } + return mapping[roleType] || roleType + } } diff --git a/backend/services/authorization-service/src/application/services/system-account-application.service.ts b/backend/services/authorization-service/src/application/services/system-account-application.service.ts index 8c55c20d..ece64224 100644 --- a/backend/services/authorization-service/src/application/services/system-account-application.service.ts +++ b/backend/services/authorization-service/src/application/services/system-account-application.service.ts @@ -307,7 +307,7 @@ export class SystemAccountApplicationService implements OnModuleInit { allocations.push({ targetType: 'COST_ACCOUNT', targetSystemAccountId: costAccount.id, - amount: new Decimal(400).mul(treeCount), + amount: new Decimal(2800).mul(treeCount), }) } @@ -315,7 +315,7 @@ export class SystemAccountApplicationService implements OnModuleInit { allocations.push({ targetType: 'OPERATION_ACCOUNT', targetSystemAccountId: operationAccount.id, - amount: new Decimal(300).mul(treeCount), + amount: new Decimal(2100).mul(treeCount), }) } @@ -323,7 +323,7 @@ export class SystemAccountApplicationService implements OnModuleInit { allocations.push({ targetType: 'HQ_COMMUNITY', targetSystemAccountId: hqCommunityAccount.id, - amount: new Decimal(9).mul(treeCount), + amount: new Decimal(203).mul(treeCount), }) } @@ -331,27 +331,27 @@ export class SystemAccountApplicationService implements OnModuleInit { allocations.push({ targetType: 'RWAD_POOL', targetSystemAccountId: rwadPoolAccount.id, - amount: new Decimal(800).mul(treeCount), + amount: new Decimal(5760).mul(treeCount), }) } - // 2. 直接推荐人(500 USDT) + // 2. 直接推荐人(3600 USDT) if (params.referrerId) { allocations.push({ targetType: 'DIRECT_REFERRER', targetUserId: params.referrerId, - amount: new Decimal(500).mul(treeCount), + amount: new Decimal(3600).mul(treeCount), }) } else if (operationAccount) { // 无推荐人,归入运营账户 allocations.push({ targetType: 'OPERATION_ACCOUNT', targetSystemAccountId: operationAccount.id, - amount: new Decimal(500).mul(treeCount), + amount: new Decimal(3600).mul(treeCount), }) } - // 3. 省公司权益(15 区域 + 20 团队) + // 3. 省公司权益(108 区域 + 144 团队) // TODO: 从 authorization-service 查询省公司授权状态 // 暂时归入系统省账户 const systemProvince = await this.systemAccountRepository.getOrCreate( @@ -362,15 +362,15 @@ export class SystemAccountApplicationService implements OnModuleInit { allocations.push({ targetType: 'PROVINCE_REGION', targetSystemAccountId: systemProvince.id, - amount: new Decimal(15).mul(treeCount), + amount: new Decimal(108).mul(treeCount), }) allocations.push({ targetType: 'PROVINCE_TEAM', targetSystemAccountId: systemProvince.id, - amount: new Decimal(20).mul(treeCount), + amount: new Decimal(144).mul(treeCount), }) - // 4. 市公司权益(35 区域 + 40 团队) + // 4. 市公司权益(252 区域 + 288 团队) // TODO: 从 authorization-service 查询市公司授权状态 // 暂时归入系统市账户 const systemCity = await this.systemAccountRepository.getOrCreate( @@ -381,22 +381,22 @@ export class SystemAccountApplicationService implements OnModuleInit { allocations.push({ targetType: 'CITY_REGION', targetSystemAccountId: systemCity.id, - amount: new Decimal(35).mul(treeCount), + amount: new Decimal(252).mul(treeCount), }) allocations.push({ targetType: 'CITY_TEAM', targetSystemAccountId: systemCity.id, - amount: new Decimal(40).mul(treeCount), + amount: new Decimal(288).mul(treeCount), }) - // 5. 社区权益(80 USDT) + // 5. 社区权益(576 USDT) // TODO: 从 authorization-service 查询社区授权状态 // 暂时归入运营账户 if (operationAccount) { allocations.push({ targetType: 'COMMUNITY', targetSystemAccountId: operationAccount.id, - amount: new Decimal(80).mul(treeCount), + amount: new Decimal(576).mul(treeCount), }) } diff --git a/backend/services/authorization-service/src/infrastructure/external/identity-service.client.ts b/backend/services/authorization-service/src/infrastructure/external/identity-service.client.ts new file mode 100644 index 00000000..ab82b388 --- /dev/null +++ b/backend/services/authorization-service/src/infrastructure/external/identity-service.client.ts @@ -0,0 +1,99 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios, { AxiosInstance } from 'axios'; + +/** + * 用户信息接口 + */ +export interface UserInfo { + userId: string; + accountSequence: string; + nickname: string; + avatarUrl?: string; +} + +/** + * Identity Service HTTP 客户端 + * 用于从 identity-service 获取用户信息 + */ +@Injectable() +export class IdentityServiceClient implements OnModuleInit { + private readonly logger = new Logger(IdentityServiceClient.name); + private httpClient: AxiosInstance; + private readonly baseUrl: string; + private readonly enabled: boolean; + + constructor(private readonly configService: ConfigService) { + this.baseUrl = this.configService.get('IDENTITY_SERVICE_URL') || 'http://identity-service:3001'; + this.enabled = this.configService.get('IDENTITY_SERVICE_ENABLED') !== false; + } + + onModuleInit() { + this.httpClient = axios.create({ + baseURL: this.baseUrl, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, + }); + + this.logger.log(`[INIT] IdentityServiceClient initialized: ${this.baseUrl}, enabled: ${this.enabled}`); + } + + /** + * 批量获取用户信息 + */ + async batchGetUserInfo(userIds: string[]): Promise> { + const result = new Map(); + + if (!this.enabled || userIds.length === 0) { + return result; + } + + try { + this.logger.debug(`[HTTP] POST /internal/users/batch - ${userIds.length} users`); + + const response = await this.httpClient.post( + `/api/v1/internal/users/batch`, + { userIds }, + ); + + if (response.data && Array.isArray(response.data)) { + for (const user of response.data) { + result.set(user.userId, user); + } + } + + this.logger.debug(`[HTTP] Got ${result.size} users info`); + } catch (error) { + this.logger.error(`[HTTP] Failed to batch get user info:`, error); + } + + return result; + } + + /** + * 获取单个用户信息 + */ + async getUserInfo(userId: string): Promise { + if (!this.enabled) { + return null; + } + + try { + this.logger.debug(`[HTTP] GET /internal/users/${userId}`); + + const response = await this.httpClient.get( + `/api/v1/internal/users/${userId}`, + ); + + if (response.data) { + return response.data; + } + } catch (error) { + this.logger.error(`[HTTP] Failed to get user info for ${userId}:`, error); + } + + return null; + } +} diff --git a/backend/services/authorization-service/src/infrastructure/external/index.ts b/backend/services/authorization-service/src/infrastructure/external/index.ts index 1842d2b4..acab958a 100644 --- a/backend/services/authorization-service/src/infrastructure/external/index.ts +++ b/backend/services/authorization-service/src/infrastructure/external/index.ts @@ -1 +1,2 @@ export * from './referral-service.client'; +export * from './identity-service.client'; diff --git a/backend/services/planting-service/src/domain/services/fund-allocation.service.ts b/backend/services/planting-service/src/domain/services/fund-allocation.service.ts index 2505a8c3..5e516b4c 100644 --- a/backend/services/planting-service/src/domain/services/fund-allocation.service.ts +++ b/backend/services/planting-service/src/domain/services/fund-allocation.service.ts @@ -64,7 +64,7 @@ export class FundAllocationDomainService { throw new Error('订单未选择省市,无法计算资金分配'); } - // 1. 成本账户: 400 USDT/棵 + // 1. 成本账户: 2800 USDT/棵 allocations.push( new FundAllocation( FundAllocationTargetType.COST_ACCOUNT, @@ -74,7 +74,7 @@ export class FundAllocationDomainService { ), ); - // 2. 运营账户: 300 USDT/棵 + // 2. 运营账户: 2100 USDT/棵 allocations.push( new FundAllocation( FundAllocationTargetType.OPERATION_ACCOUNT, @@ -84,7 +84,7 @@ export class FundAllocationDomainService { ), ); - // 3. 总部社区: 9 USDT/棵 + // 3. 总部社区: 203 USDT/棵 allocations.push( new FundAllocation( FundAllocationTargetType.HEADQUARTERS_COMMUNITY, @@ -95,7 +95,7 @@ export class FundAllocationDomainService { ), ); - // 4. 分享权益: 500 USDT/棵 (分配给推荐链) + // 4. 分享权益: 3600 USDT/棵 (分配给推荐链) // 无推荐人时进入分享权益池 SYSTEM_SHARE_RIGHT_POOL allocations.push( new FundAllocation( @@ -109,7 +109,7 @@ export class FundAllocationDomainService { ), ); - // 5. 省区域权益: 15 USDT/棵 + // 5. 省区域权益: 108 USDT/棵 allocations.push( new FundAllocation( FundAllocationTargetType.PROVINCE_AREA_RIGHTS, @@ -119,7 +119,7 @@ export class FundAllocationDomainService { ), ); - // 6. 省团队权益: 20 USDT/棵 + // 6. 省团队权益: 144 USDT/棵 allocations.push( new FundAllocation( FundAllocationTargetType.PROVINCE_TEAM_RIGHTS, @@ -129,7 +129,7 @@ export class FundAllocationDomainService { ), ); - // 7. 市区域权益: 35 USDT/棵 + // 7. 市区域权益: 252 USDT/棵 allocations.push( new FundAllocation( FundAllocationTargetType.CITY_AREA_RIGHTS, @@ -139,7 +139,7 @@ export class FundAllocationDomainService { ), ); - // 8. 市团队权益: 40 USDT/棵 + // 8. 市团队权益: 288 USDT/棵 allocations.push( new FundAllocation( FundAllocationTargetType.CITY_TEAM_RIGHTS, @@ -149,7 +149,7 @@ export class FundAllocationDomainService { ), ); - // 9. 社区权益: 80 USDT/棵 + // 9. 社区权益: 576 USDT/棵 allocations.push( new FundAllocation( FundAllocationTargetType.COMMUNITY_RIGHTS, @@ -159,7 +159,7 @@ export class FundAllocationDomainService { ), ); - // 10. RWAD底池: 800 USDT/棵 + // 10. RWAD底池: 5760 USDT/棵 allocations.push( new FundAllocation( FundAllocationTargetType.RWAD_POOL, @@ -170,7 +170,7 @@ export class FundAllocationDomainService { // 验证总额 const total = allocations.reduce((sum, a) => sum + a.amount, 0); - const expected = 2199 * treeCount; + const expected = 15831 * treeCount; if (Math.abs(total - expected) > 0.01) { throw new Error(`资金分配计算错误: 总额 ${total} != ${expected}`); } @@ -201,7 +201,7 @@ export class FundAllocationDomainService { throw new Error('订单未选择省市,无法计算资金分配'); } - // 1. 成本账户: 400 USDT/棵 + // 1. 成本账户: 2800 USDT/棵 allocations.push( new FundAllocation( FundAllocationTargetType.COST_ACCOUNT, @@ -210,7 +210,7 @@ export class FundAllocationDomainService { ), ); - // 2. 运营账户: 300 USDT/棵 + // 2. 运营账户: 2100 USDT/棵 allocations.push( new FundAllocation( FundAllocationTargetType.OPERATION_ACCOUNT, @@ -219,7 +219,7 @@ export class FundAllocationDomainService { ), ); - // 3. 总部社区: 9 USDT/棵 + // 3. 总部社区: 203 USDT/棵 allocations.push( new FundAllocation( FundAllocationTargetType.HEADQUARTERS_COMMUNITY, @@ -228,7 +228,7 @@ export class FundAllocationDomainService { ), ); - // 4. 分享权益 (直推奖励): 500 USDT/棵 + // 4. 分享权益 (直推奖励): 3600 USDT/棵 // 仅直推,无多级;无推荐人归入分享权益池 const referralTarget = context.directReferrerId ? `USER:${context.directReferrerId}` @@ -245,7 +245,7 @@ export class FundAllocationDomainService { ), ); - // 5. 省区域权益: 15 USDT/棵 + 1%算力(正式省公司才有算力) + // 5. 省区域权益: 108 USDT/棵 + 1%算力(正式省公司才有算力) const provinceAreaTarget = context.provinceAreaRecipient.type === 'USER' ? `USER:${context.provinceAreaRecipient.id}` : `SYSTEM:SYSTEM_PROVINCE:${selection.provinceCode}`; @@ -261,7 +261,7 @@ export class FundAllocationDomainService { ), ); - // 6. 省团队权益: 20 USDT/棵(授权省公司) + // 6. 省团队权益: 144 USDT/棵(授权省公司) const provinceTeamTarget = context.provinceTeamRecipient.type === 'USER' ? `USER:${context.provinceTeamRecipient.id}` : `SYSTEM:SYSTEM_PROVINCE:${selection.provinceCode}`; @@ -274,7 +274,7 @@ export class FundAllocationDomainService { ), ); - // 7. 市区域权益: 35 USDT/棵 + 2%算力(正式市公司才有算力) + // 7. 市区域权益: 252 USDT/棵 + 2%算力(正式市公司才有算力) const cityAreaTarget = context.cityAreaRecipient.type === 'USER' ? `USER:${context.cityAreaRecipient.id}` : `SYSTEM:SYSTEM_CITY:${selection.cityCode}`; @@ -290,7 +290,7 @@ export class FundAllocationDomainService { ), ); - // 8. 市团队权益: 40 USDT/棵(授权市公司) + // 8. 市团队权益: 288 USDT/棵(授权市公司) const cityTeamTarget = context.cityTeamRecipient.type === 'USER' ? `USER:${context.cityTeamRecipient.id}` : `SYSTEM:SYSTEM_CITY:${selection.cityCode}`; @@ -303,7 +303,7 @@ export class FundAllocationDomainService { ), ); - // 9. 社区权益: 80 USDT/棵 + // 9. 社区权益: 576 USDT/棵 const communityTarget = context.communityRecipient && context.communityRecipient.type === 'USER' ? `USER:${context.communityRecipient.id}` : 'SYSTEM:OPERATION_ACCOUNT'; @@ -315,7 +315,7 @@ export class FundAllocationDomainService { ), ); - // 10. RWAD底池: 800 USDT/棵 + // 10. RWAD底池: 5760 USDT/棵 allocations.push( new FundAllocation( FundAllocationTargetType.RWAD_POOL, @@ -327,7 +327,7 @@ export class FundAllocationDomainService { // 验证总额 const total = allocations.reduce((sum, a) => sum + a.amount, 0); - const expected = 2199 * treeCount; + const expected = 15831 * treeCount; if (Math.abs(total - expected) > 0.01) { throw new Error(`资金分配计算错误: 总额 ${total} != ${expected}`); } diff --git a/backend/services/planting-service/src/domain/value-objects/fund-allocation-target-type.enum.ts b/backend/services/planting-service/src/domain/value-objects/fund-allocation-target-type.enum.ts index a87ebe26..70272914 100644 --- a/backend/services/planting-service/src/domain/value-objects/fund-allocation-target-type.enum.ts +++ b/backend/services/planting-service/src/domain/value-objects/fund-allocation-target-type.enum.ts @@ -1,33 +1,33 @@ export enum FundAllocationTargetType { - COST_ACCOUNT = 'COST_ACCOUNT', // 400 USDT - 成本账户 - OPERATION_ACCOUNT = 'OPERATION_ACCOUNT', // 300 USDT - 运营账户 - HEADQUARTERS_COMMUNITY = 'HEADQUARTERS_COMMUNITY', // 9 USDT - 总部社区 - REFERRAL_RIGHTS = 'REFERRAL_RIGHTS', // 500 USDT - 分享权益 - PROVINCE_AREA_RIGHTS = 'PROVINCE_AREA_RIGHTS', // 15 USDT - 省区域权益 - PROVINCE_TEAM_RIGHTS = 'PROVINCE_TEAM_RIGHTS', // 20 USDT - 省团队权益 - CITY_AREA_RIGHTS = 'CITY_AREA_RIGHTS', // 35 USDT - 市区域权益 - CITY_TEAM_RIGHTS = 'CITY_TEAM_RIGHTS', // 40 USDT - 市团队权益 - COMMUNITY_RIGHTS = 'COMMUNITY_RIGHTS', // 80 USDT - 社区权益 - RWAD_POOL = 'RWAD_POOL', // 800 USDT - RWAD底池 + COST_ACCOUNT = 'COST_ACCOUNT', // 2800 USDT - 成本账户 + OPERATION_ACCOUNT = 'OPERATION_ACCOUNT', // 2100 USDT - 运营账户 + HEADQUARTERS_COMMUNITY = 'HEADQUARTERS_COMMUNITY', // 203 USDT - 总部社区 + REFERRAL_RIGHTS = 'REFERRAL_RIGHTS', // 3600 USDT - 分享权益 + PROVINCE_AREA_RIGHTS = 'PROVINCE_AREA_RIGHTS', // 108 USDT - 省区域权益 + PROVINCE_TEAM_RIGHTS = 'PROVINCE_TEAM_RIGHTS', // 144 USDT - 省团队权益 + CITY_AREA_RIGHTS = 'CITY_AREA_RIGHTS', // 252 USDT - 市区域权益 + CITY_TEAM_RIGHTS = 'CITY_TEAM_RIGHTS', // 288 USDT - 市团队权益 + COMMUNITY_RIGHTS = 'COMMUNITY_RIGHTS', // 576 USDT - 社区权益 + RWAD_POOL = 'RWAD_POOL', // 5760 USDT - RWAD底池 } -// 每棵树的资金分配规则 (总计 2199 USDT) +// 每棵树的资金分配规则 (总计 15831 USDT) export const FUND_ALLOCATION_AMOUNTS: Record = { - [FundAllocationTargetType.COST_ACCOUNT]: 400, - [FundAllocationTargetType.OPERATION_ACCOUNT]: 300, - [FundAllocationTargetType.HEADQUARTERS_COMMUNITY]: 9, - [FundAllocationTargetType.REFERRAL_RIGHTS]: 500, - [FundAllocationTargetType.PROVINCE_AREA_RIGHTS]: 15, - [FundAllocationTargetType.PROVINCE_TEAM_RIGHTS]: 20, - [FundAllocationTargetType.CITY_AREA_RIGHTS]: 35, - [FundAllocationTargetType.CITY_TEAM_RIGHTS]: 40, - [FundAllocationTargetType.COMMUNITY_RIGHTS]: 80, - [FundAllocationTargetType.RWAD_POOL]: 800, + [FundAllocationTargetType.COST_ACCOUNT]: 2800, + [FundAllocationTargetType.OPERATION_ACCOUNT]: 2100, + [FundAllocationTargetType.HEADQUARTERS_COMMUNITY]: 203, + [FundAllocationTargetType.REFERRAL_RIGHTS]: 3600, + [FundAllocationTargetType.PROVINCE_AREA_RIGHTS]: 108, + [FundAllocationTargetType.PROVINCE_TEAM_RIGHTS]: 144, + [FundAllocationTargetType.CITY_AREA_RIGHTS]: 252, + [FundAllocationTargetType.CITY_TEAM_RIGHTS]: 288, + [FundAllocationTargetType.COMMUNITY_RIGHTS]: 576, + [FundAllocationTargetType.RWAD_POOL]: 5760, }; // 每棵树价格 -export const PRICE_PER_TREE = 2199; +export const PRICE_PER_TREE = 15831; // 验证总额 const TOTAL = Object.values(FUND_ALLOCATION_AMOUNTS).reduce((a, b) => a + b, 0); diff --git a/backend/services/reward-service/src/domain/services/reward-calculation.service.ts b/backend/services/reward-service/src/domain/services/reward-calculation.service.ts index 8c012624..466c0329 100644 --- a/backend/services/reward-service/src/domain/services/reward-calculation.service.ts +++ b/backend/services/reward-service/src/domain/services/reward-calculation.service.ts @@ -85,7 +85,7 @@ export class RewardCalculationService { /** * 计算认种订单产生的所有奖励 - * 总计 2199 USDT = 400 + 300 + 9 + 800 + 500 + 15 + 20 + 35 + 40 + 80 + * 总计 15831 USDT = 2800 + 2100 + 203 + 5760 + 3600 + 108 + 144 + 252 + 288 + 576 */ async calculateRewards(params: { sourceOrderNo: string; // 订单号是字符串格式 @@ -103,10 +103,10 @@ export class RewardCalculationService { const rewards: RewardLedgerEntry[] = []; // ============================================ - // 系统费用类 (709 USDT) + // 系统费用类 (10863 USDT) // ============================================ - // 1. 成本费 (400 USDT) + // 1. 成本费 (2800 USDT) const costFeeReward = this.calculateCostFee( params.sourceOrderNo, params.sourceUserId, @@ -114,7 +114,7 @@ export class RewardCalculationService { ); rewards.push(costFeeReward); - // 2. 运营费 (300 USDT) + // 2. 运营费 (2100 USDT) const operationFeeReward = this.calculateOperationFee( params.sourceOrderNo, params.sourceUserId, @@ -122,7 +122,7 @@ export class RewardCalculationService { ); rewards.push(operationFeeReward); - // 3. 总部社区基础费 (9 USDT) + // 3. 总部社区基础费 (203 USDT) const headquartersBaseFeeReward = this.calculateHeadquartersBaseFee( params.sourceOrderNo, params.sourceUserId, @@ -130,7 +130,7 @@ export class RewardCalculationService { ); rewards.push(headquartersBaseFeeReward); - // 4. RWAD底池注入 (800 USDT) + // 4. RWAD底池注入 (5760 USDT) const rwadPoolReward = this.calculateRwadPoolInjection( params.sourceOrderNo, params.sourceUserId, @@ -139,10 +139,10 @@ export class RewardCalculationService { rewards.push(rwadPoolReward); // ============================================ - // 用户权益类 (690 USDT + 算力) + // 用户权益类 (4968 USDT + 算力) // ============================================ - // 5. 分享权益 (500 USDT) + // 5. 分享权益 (3600 USDT) const shareRewards = await this.calculateShareRights( params.sourceOrderNo, params.sourceUserId, @@ -151,7 +151,7 @@ export class RewardCalculationService { ); rewards.push(...shareRewards); - // 6. 省团队权益 (20 USDT) - 可能返回多条记录(考核分配) + // 6. 省团队权益 (144 USDT) - 可能返回多条记录(考核分配) const provinceTeamRewards = await this.calculateProvinceTeamRight( params.sourceOrderNo, params.sourceUserId, @@ -161,7 +161,7 @@ export class RewardCalculationService { ); rewards.push(...provinceTeamRewards); - // 7. 省区域权益 (15 USDT + 1%算力) - 可能返回多条记录(考核分配) + // 7. 省区域权益 (108 USDT + 1%算力) - 可能返回多条记录(考核分配) const provinceAreaRewards = await this.calculateProvinceAreaRight( params.sourceOrderNo, params.sourceUserId, @@ -170,7 +170,7 @@ export class RewardCalculationService { ); rewards.push(...provinceAreaRewards); - // 8. 市团队权益 (40 USDT) - 可能返回多条记录(考核分配) + // 8. 市团队权益 (288 USDT) - 可能返回多条记录(考核分配) const cityTeamRewards = await this.calculateCityTeamRight( params.sourceOrderNo, params.sourceUserId, @@ -180,7 +180,7 @@ export class RewardCalculationService { ); rewards.push(...cityTeamRewards); - // 9. 市区域权益 (35 USDT + 2%算力) - 可能返回多条记录(考核分配) + // 9. 市区域权益 (252 USDT + 2%算力) - 可能返回多条记录(考核分配) const cityAreaRewards = await this.calculateCityAreaRight( params.sourceOrderNo, params.sourceUserId, @@ -189,7 +189,7 @@ export class RewardCalculationService { ); rewards.push(...cityAreaRewards); - // 10. 社区权益 (80 USDT) - 可能返回多条记录(考核分配) + // 10. 社区权益 (576 USDT) - 可能返回多条记录(考核分配) const communityRewards = await this.calculateCommunityRight( params.sourceOrderNo, params.sourceUserId, @@ -210,7 +210,7 @@ export class RewardCalculationService { // ============================================ /** - * 计算成本费 (400 USDT) + * 计算成本费 (2800 USDT) * 分配至指定成本账户 */ private calculateCostFee( @@ -239,7 +239,7 @@ export class RewardCalculationService { } /** - * 计算运营费 (300 USDT) + * 计算运营费 (2100 USDT) * 分配至指定运营账户 */ private calculateOperationFee( @@ -268,7 +268,7 @@ export class RewardCalculationService { } /** - * 计算总部社区基础费 (9 USDT) + * 计算总部社区基础费 (203 USDT) * 分配至总部社区账户 */ private calculateHeadquartersBaseFee( @@ -297,7 +297,7 @@ export class RewardCalculationService { } /** - * 计算RWAD底池注入 (800 USDT) + * 计算RWAD底池注入 (5760 USDT) * 注入RWAD 1号底池 */ private calculateRwadPoolInjection( @@ -321,7 +321,7 @@ export class RewardCalculationService { rewardSource, usdtAmount, hashpowerAmount: hashpower, - memo: `RWAD底池注入:来自用户${sourceUserId}的认种,${treeCount}棵树,800U注入1号底池`, + memo: `RWAD底池注入:来自用户${sourceUserId}的认种,${treeCount}棵树,5760U注入1号底池`, }); } @@ -330,7 +330,7 @@ export class RewardCalculationService { // ============================================ /** - * 计算分享权益 (500 USDT) + * 计算分享权益 (3600 USDT) */ private async calculateShareRights( sourceOrderNo: string, @@ -400,7 +400,7 @@ export class RewardCalculationService { } /** - * 计算省团队权益 (20 USDT) + * 计算省团队权益 (144 USDT) * 根据考核规则(500棵初审),可能返回多条分配记录 */ private async calculateProvinceTeamRight( @@ -456,7 +456,7 @@ export class RewardCalculationService { } /** - * 计算省区域权益 (15 USDT + 1%算力) + * 计算省区域权益 (108 USDT + 1%算力) * 根据考核规则(50000棵),可能返回多条分配记录 * - 未达标:进系统省账户 * - 已达标:进正式省公司账户 @@ -512,7 +512,7 @@ export class RewardCalculationService { } /** - * 计算市团队权益 (40 USDT) + * 计算市团队权益 (288 USDT) * 根据考核规则(100棵初审),可能返回多条分配记录 */ private async calculateCityTeamRight( @@ -568,7 +568,7 @@ export class RewardCalculationService { } /** - * 计算市区域权益 (35 USDT + 2%算力) + * 计算市区域权益 (252 USDT + 2%算力) * 根据考核规则(10000棵),可能返回多条分配记录 * - 未达标:进系统市账户 * - 已达标:进正式市公司账户 @@ -624,7 +624,7 @@ export class RewardCalculationService { } /** - * 计算社区权益 (80 USDT) + * 计算社区权益 (576 USDT) * 根据考核规则,可能返回多条分配记录: * - 权益已激活:全部给该社区 * - 权益未激活:考核前的部分给上级社区/总部,考核后的部分给该社区 diff --git a/backend/services/reward-service/src/domain/value-objects/right-type.enum.ts b/backend/services/reward-service/src/domain/value-objects/right-type.enum.ts index c330ebae..66e4eeb9 100644 --- a/backend/services/reward-service/src/domain/value-objects/right-type.enum.ts +++ b/backend/services/reward-service/src/domain/value-objects/right-type.enum.ts @@ -1,34 +1,34 @@ export enum RightType { // === 系统费用类 === - COST_FEE = 'COST_FEE', // 成本费 400U - OPERATION_FEE = 'OPERATION_FEE', // 运营费 300U - HEADQUARTERS_BASE_FEE = 'HEADQUARTERS_BASE_FEE', // 总部社区基础费 9U - RWAD_POOL_INJECTION = 'RWAD_POOL_INJECTION', // RWAD底池注入 800U + COST_FEE = 'COST_FEE', // 成本费 2800U + OPERATION_FEE = 'OPERATION_FEE', // 运营费 2100U + HEADQUARTERS_BASE_FEE = 'HEADQUARTERS_BASE_FEE', // 总部社区基础费 203U + RWAD_POOL_INJECTION = 'RWAD_POOL_INJECTION', // RWAD底池注入 5760U // === 用户权益类 === - SHARE_RIGHT = 'SHARE_RIGHT', // 分享权益 500U - PROVINCE_AREA_RIGHT = 'PROVINCE_AREA_RIGHT', // 省区域权益 15U + 1%算力 - PROVINCE_TEAM_RIGHT = 'PROVINCE_TEAM_RIGHT', // 省团队权益 20U - CITY_AREA_RIGHT = 'CITY_AREA_RIGHT', // 市区域权益 35U + 2%算力 - CITY_TEAM_RIGHT = 'CITY_TEAM_RIGHT', // 市团队权益 40U - COMMUNITY_RIGHT = 'COMMUNITY_RIGHT', // 社区权益 80U + SHARE_RIGHT = 'SHARE_RIGHT', // 分享权益 3600U + PROVINCE_AREA_RIGHT = 'PROVINCE_AREA_RIGHT', // 省区域权益 108U + 1%算力 + PROVINCE_TEAM_RIGHT = 'PROVINCE_TEAM_RIGHT', // 省团队权益 144U + CITY_AREA_RIGHT = 'CITY_AREA_RIGHT', // 市区域权益 252U + 2%算力 + CITY_TEAM_RIGHT = 'CITY_TEAM_RIGHT', // 市团队权益 288U + COMMUNITY_RIGHT = 'COMMUNITY_RIGHT', // 社区权益 576U } // 权益金额配置 export const RIGHT_AMOUNTS: Record = { // 系统费用类 - [RightType.COST_FEE]: { usdt: 400, hashpowerPercent: 0 }, - [RightType.OPERATION_FEE]: { usdt: 300, hashpowerPercent: 0 }, - [RightType.HEADQUARTERS_BASE_FEE]: { usdt: 9, hashpowerPercent: 0 }, - [RightType.RWAD_POOL_INJECTION]: { usdt: 800, hashpowerPercent: 0 }, + [RightType.COST_FEE]: { usdt: 2800, hashpowerPercent: 0 }, + [RightType.OPERATION_FEE]: { usdt: 2100, hashpowerPercent: 0 }, + [RightType.HEADQUARTERS_BASE_FEE]: { usdt: 203, hashpowerPercent: 0 }, + [RightType.RWAD_POOL_INJECTION]: { usdt: 5760, hashpowerPercent: 0 }, // 用户权益类 - [RightType.SHARE_RIGHT]: { usdt: 500, hashpowerPercent: 0 }, - [RightType.PROVINCE_AREA_RIGHT]: { usdt: 15, hashpowerPercent: 1 }, - [RightType.PROVINCE_TEAM_RIGHT]: { usdt: 20, hashpowerPercent: 0 }, - [RightType.CITY_AREA_RIGHT]: { usdt: 35, hashpowerPercent: 2 }, - [RightType.CITY_TEAM_RIGHT]: { usdt: 40, hashpowerPercent: 0 }, - [RightType.COMMUNITY_RIGHT]: { usdt: 80, hashpowerPercent: 0 }, + [RightType.SHARE_RIGHT]: { usdt: 3600, hashpowerPercent: 0 }, + [RightType.PROVINCE_AREA_RIGHT]: { usdt: 108, hashpowerPercent: 1 }, + [RightType.PROVINCE_TEAM_RIGHT]: { usdt: 144, hashpowerPercent: 0 }, + [RightType.CITY_AREA_RIGHT]: { usdt: 252, hashpowerPercent: 2 }, + [RightType.CITY_TEAM_RIGHT]: { usdt: 288, hashpowerPercent: 0 }, + [RightType.COMMUNITY_RIGHT]: { usdt: 576, hashpowerPercent: 0 }, }; -// 总金额验证: 400 + 300 + 9 + 800 + 500 + 15 + 20 + 35 + 40 + 80 = 2199 USDT +// 总金额验证: 2800 + 2100 + 203 + 5760 + 3600 + 108 + 144 + 252 + 288 + 576 = 15831 USDT diff --git a/frontend/admin-web/src/app/(dashboard)/settings/page.tsx b/frontend/admin-web/src/app/(dashboard)/settings/page.tsx index 1324ecd5..5ee2167c 100644 --- a/frontend/admin-web/src/app/(dashboard)/settings/page.tsx +++ b/frontend/admin-web/src/app/(dashboard)/settings/page.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { PageContainer } from '@/components/layout'; import { cn } from '@/utils/helpers'; import styles from './settings.module.scss'; +import { systemConfigService, type DisplaySettings } from '@/services/systemConfigService'; /** * 后台账号数据接口 @@ -84,6 +85,9 @@ export default function SettingsPage() { // 前端展示设置 const [allowNonAdopterView, setAllowNonAdopterView] = useState(false); const [heatDisplayMode, setHeatDisplayMode] = useState<'count' | 'level'>('count'); + const [displaySettingsLoading, setDisplaySettingsLoading] = useState(false); + const [displaySettingsSaving, setDisplaySettingsSaving] = useState(false); + const [displaySettingsError, setDisplaySettingsError] = useState(null); // 后台账号与安全 const [approvalCount, setApprovalCount] = useState('3'); @@ -91,6 +95,45 @@ export default function SettingsPage() { const [logDate, setLogDate] = useState(''); const [logSearch, setLogSearch] = useState(''); + // 加载前端展示设置 + const loadDisplaySettings = useCallback(async () => { + setDisplaySettingsLoading(true); + setDisplaySettingsError(null); + try { + const settings = await systemConfigService.getDisplaySettings(); + setAllowNonAdopterView(settings.allowNonAdopterViewHeat); + setHeatDisplayMode(settings.heatDisplayMode); + } catch (error) { + console.error('Failed to load display settings:', error); + setDisplaySettingsError('加载展示设置失败'); + } finally { + setDisplaySettingsLoading(false); + } + }, []); + + // 保存前端展示设置 + const saveDisplaySettings = useCallback(async () => { + setDisplaySettingsSaving(true); + setDisplaySettingsError(null); + try { + await systemConfigService.updateDisplaySettings({ + allowNonAdopterViewHeat: allowNonAdopterView, + heatDisplayMode: heatDisplayMode, + }); + alert('展示设置保存成功'); + } catch (error) { + console.error('Failed to save display settings:', error); + setDisplaySettingsError('保存展示设置失败'); + } finally { + setDisplaySettingsSaving(false); + } + }, [allowNonAdopterView, heatDisplayMode]); + + // 组件挂载时加载展示设置 + useEffect(() => { + loadDisplaySettings(); + }, [loadDisplaySettings]); + // 切换货币选择 const toggleCurrency = (currency: string) => { if (settlementCurrencies.includes(currency)) { @@ -364,8 +407,26 @@ export default function SettingsPage() {

前端展示设置

- +
+ + +
+ {displaySettingsError && ( +
{displaySettingsError}
+ )}
允许未认种用户查看各省认种热度 diff --git a/frontend/admin-web/src/app/(dashboard)/settings/settings.module.scss b/frontend/admin-web/src/app/(dashboard)/settings/settings.module.scss index 5b9f9b79..545408f7 100644 --- a/frontend/admin-web/src/app/(dashboard)/settings/settings.module.scss +++ b/frontend/admin-web/src/app/(dashboard)/settings/settings.module.scss @@ -95,6 +95,28 @@ &:hover { text-decoration: underline; } + + &:disabled { + color: #94a3b8; + cursor: not-allowed; + } +} + +.settings__sectionActions { + display: flex; + align-items: center; + gap: 12px; +} + +.settings__error { + align-self: stretch; + padding: 12px; + border-radius: 6px; + background-color: #fef2f2; + border: 1px solid #fecaca; + color: #dc2626; + font-size: 14px; + line-height: 20px; } /* 设置内容区域 */ diff --git a/frontend/admin-web/src/app/(dashboard)/users/page.tsx b/frontend/admin-web/src/app/(dashboard)/users/page.tsx index b27262e6..358c261b 100644 --- a/frontend/admin-web/src/app/(dashboard)/users/page.tsx +++ b/frontend/admin-web/src/app/(dashboard)/users/page.tsx @@ -13,7 +13,7 @@ import styles from './users.module.scss'; // 骨架屏组件 const TableRowSkeleton = () => (
- {Array.from({ length: 12 }).map((_, i) => ( + {Array.from({ length: 13 }).map((_, i) => (
@@ -350,6 +350,9 @@ export default function UsersPage() {
昵称
+
+ 手机号 +
账户认种量
@@ -437,6 +440,11 @@ export default function UsersPage() { {user.nickname || '-'}
+ {/* 手机号 */} +
+ {user.phoneNumberMasked || '-'} +
+ {/* 账户认种量 */}
{formatNumber(user.personalAdoptions)} diff --git a/frontend/admin-web/src/app/(dashboard)/users/users.module.scss b/frontend/admin-web/src/app/(dashboard)/users/users.module.scss index 06a64e55..6a069626 100644 --- a/frontend/admin-web/src/app/(dashboard)/users/users.module.scss +++ b/frontend/admin-web/src/app/(dashboard)/users/users.module.scss @@ -253,6 +253,11 @@ flex-shrink: 0; } + &--phone { + width: 100px; + flex-shrink: 0; + } + &--adoptions { width: 70px; flex-shrink: 0; @@ -358,6 +363,13 @@ font-weight: 500; } + &--phone { + width: 100px; + flex-shrink: 0; + font-family: 'Consolas', monospace; + color: #6b7280; + } + &--adoptions { width: 70px; flex-shrink: 0; diff --git a/frontend/admin-web/src/services/systemConfigService.ts b/frontend/admin-web/src/services/systemConfigService.ts new file mode 100644 index 00000000..ec0514e0 --- /dev/null +++ b/frontend/admin-web/src/services/systemConfigService.ts @@ -0,0 +1,71 @@ +/** + * 系统配置服务 + * 负责系统配置的API调用 + */ + +import apiClient from '@/infrastructure/api/client'; +import { API_ENDPOINTS } from '@/infrastructure/api/endpoints'; + +/** 前端展示设置 */ +export interface DisplaySettings { + /** 是否允许未认种用户查看各省认种热度 */ + allowNonAdopterViewHeat: boolean; + /** 热度展示方式: 'count' 显示具体数量, 'level' 仅显示热度等级 */ + heatDisplayMode: 'count' | 'level'; +} + +/** 更新前端展示设置请求 */ +export interface UpdateDisplaySettingsRequest { + allowNonAdopterViewHeat?: boolean; + heatDisplayMode?: 'count' | 'level'; +} + +/** 系统配置项 */ +export interface SystemConfigItem { + key: string; + value: string; + description?: string; + updatedAt: string; +} + +/** + * 系统配置服务 + */ +export const systemConfigService = { + /** + * 获取所有系统配置 + */ + async getAllConfigs(): Promise { + return apiClient.get(API_ENDPOINTS.SYSTEM_CONFIG.ALL); + }, + + /** + * 获取前端展示设置 + */ + async getDisplaySettings(): Promise { + return apiClient.get(API_ENDPOINTS.SYSTEM_CONFIG.DISPLAY_SETTINGS); + }, + + /** + * 更新前端展示设置 + */ + async updateDisplaySettings(settings: UpdateDisplaySettingsRequest): Promise { + return apiClient.put(API_ENDPOINTS.SYSTEM_CONFIG.DISPLAY_SETTINGS, settings); + }, + + /** + * 获取单个配置 + */ + async getConfigByKey(key: string): Promise { + return apiClient.get(API_ENDPOINTS.SYSTEM_CONFIG.BY_KEY(key)); + }, + + /** + * 更新单个配置 + */ + async updateConfigByKey(key: string, value: string, description?: string): Promise { + return apiClient.put(API_ENDPOINTS.SYSTEM_CONFIG.BY_KEY(key), { value, description }); + }, +}; + +export default systemConfigService; diff --git a/frontend/admin-web/src/services/userService.ts b/frontend/admin-web/src/services/userService.ts index ee036814..4d1dd41b 100644 --- a/frontend/admin-web/src/services/userService.ts +++ b/frontend/admin-web/src/services/userService.ts @@ -13,6 +13,7 @@ export interface UserListItem { accountSequence: string; avatar: string | null; nickname: string | null; + phoneNumberMasked: string | null; personalAdoptions: number; teamAddresses: number; teamAdoptions: number; @@ -32,7 +33,6 @@ export interface UserListItem { /** 用户详情 */ export interface UserDetail extends UserListItem { - phoneNumberMasked: string | null; kycStatus: string; registeredAt: string; lastActiveAt: string | null; diff --git a/frontend/mobile-app/assets/lottie/stickman_running.json b/frontend/mobile-app/assets/lottie/stickman_running.json new file mode 100644 index 00000000..823de778 --- /dev/null +++ b/frontend/mobile-app/assets/lottie/stickman_running.json @@ -0,0 +1,286 @@ +{ + "v": "5.7.4", + "fr": 30, + "ip": 0, + "op": 30, + "w": 100, + "h": 120, + "nm": "Stickman Running", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Head", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100 }, + "r": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [50, 20, 0] }, + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] } + }, + "ao": 0, + "shapes": [ + { + "ty": "el", + "s": { "a": 0, "k": [20, 20] }, + "p": { "a": 0, "k": [0, 0] }, + "nm": "Head Circle" + }, + { + "ty": "st", + "c": { "a": 0, "k": [0.83, 0.69, 0.22, 1] }, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 3 }, + "lc": 2, + "lj": 2 + }, + { + "ty": "fl", + "c": { "a": 0, "k": [0.83, 0.69, 0.22, 1] }, + "o": { "a": 0, "k": 100 } + } + ], + "ip": 0, + "op": 30, + "st": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "Body", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100 }, + "r": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [50, 50, 0] }, + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] } + }, + "ao": 0, + "shapes": [ + { + "ty": "sh", + "ks": { + "a": 0, + "k": { + "i": [[0, 0], [0, 0]], + "o": [[0, 0], [0, 0]], + "v": [[0, -20], [0, 15]], + "c": false + } + }, + "nm": "Body Line" + }, + { + "ty": "st", + "c": { "a": 0, "k": [0.83, 0.69, 0.22, 1] }, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 3 }, + "lc": 2, + "lj": 2 + } + ], + "ip": 0, + "op": 30, + "st": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 4, + "nm": "Left Arm", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100 }, + "r": { + "a": 1, + "k": [ + { "i": { "x": [0.5], "y": [1] }, "o": { "x": [0.5], "y": [0] }, "t": 0, "s": [-30] }, + { "i": { "x": [0.5], "y": [1] }, "o": { "x": [0.5], "y": [0] }, "t": 15, "s": [30] }, + { "t": 30, "s": [-30] } + ] + }, + "p": { "a": 0, "k": [50, 38, 0] }, + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] } + }, + "ao": 0, + "shapes": [ + { + "ty": "sh", + "ks": { + "a": 0, + "k": { + "i": [[0, 0], [0, 0]], + "o": [[0, 0], [0, 0]], + "v": [[0, 0], [-15, 15]], + "c": false + } + }, + "nm": "Left Arm Line" + }, + { + "ty": "st", + "c": { "a": 0, "k": [0.83, 0.69, 0.22, 1] }, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 3 }, + "lc": 2, + "lj": 2 + } + ], + "ip": 0, + "op": 30, + "st": 0 + }, + { + "ddd": 0, + "ind": 4, + "ty": 4, + "nm": "Right Arm", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100 }, + "r": { + "a": 1, + "k": [ + { "i": { "x": [0.5], "y": [1] }, "o": { "x": [0.5], "y": [0] }, "t": 0, "s": [30] }, + { "i": { "x": [0.5], "y": [1] }, "o": { "x": [0.5], "y": [0] }, "t": 15, "s": [-30] }, + { "t": 30, "s": [30] } + ] + }, + "p": { "a": 0, "k": [50, 38, 0] }, + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] } + }, + "ao": 0, + "shapes": [ + { + "ty": "sh", + "ks": { + "a": 0, + "k": { + "i": [[0, 0], [0, 0]], + "o": [[0, 0], [0, 0]], + "v": [[0, 0], [15, 15]], + "c": false + } + }, + "nm": "Right Arm Line" + }, + { + "ty": "st", + "c": { "a": 0, "k": [0.83, 0.69, 0.22, 1] }, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 3 }, + "lc": 2, + "lj": 2 + } + ], + "ip": 0, + "op": 30, + "st": 0 + }, + { + "ddd": 0, + "ind": 5, + "ty": 4, + "nm": "Left Leg", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100 }, + "r": { + "a": 1, + "k": [ + { "i": { "x": [0.5], "y": [1] }, "o": { "x": [0.5], "y": [0] }, "t": 0, "s": [30] }, + { "i": { "x": [0.5], "y": [1] }, "o": { "x": [0.5], "y": [0] }, "t": 15, "s": [-30] }, + { "t": 30, "s": [30] } + ] + }, + "p": { "a": 0, "k": [50, 65, 0] }, + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] } + }, + "ao": 0, + "shapes": [ + { + "ty": "sh", + "ks": { + "a": 0, + "k": { + "i": [[0, 0], [0, 0]], + "o": [[0, 0], [0, 0]], + "v": [[0, 0], [-10, 30]], + "c": false + } + }, + "nm": "Left Leg Line" + }, + { + "ty": "st", + "c": { "a": 0, "k": [0.83, 0.69, 0.22, 1] }, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 3 }, + "lc": 2, + "lj": 2 + } + ], + "ip": 0, + "op": 30, + "st": 0 + }, + { + "ddd": 0, + "ind": 6, + "ty": 4, + "nm": "Right Leg", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100 }, + "r": { + "a": 1, + "k": [ + { "i": { "x": [0.5], "y": [1] }, "o": { "x": [0.5], "y": [0] }, "t": 0, "s": [-30] }, + { "i": { "x": [0.5], "y": [1] }, "o": { "x": [0.5], "y": [0] }, "t": 15, "s": [30] }, + { "t": 30, "s": [-30] } + ] + }, + "p": { "a": 0, "k": [50, 65, 0] }, + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] } + }, + "ao": 0, + "shapes": [ + { + "ty": "sh", + "ks": { + "a": 0, + "k": { + "i": [[0, 0], [0, 0]], + "o": [[0, 0], [0, 0]], + "v": [[0, 0], [10, 30]], + "c": false + } + }, + "nm": "Right Leg Line" + }, + { + "ty": "st", + "c": { "a": 0, "k": [0.83, 0.69, 0.22, 1] }, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 3 }, + "lc": 2, + "lj": 2 + } + ], + "ip": 0, + "op": 30, + "st": 0 + } + ], + "markers": [] +} diff --git a/frontend/mobile-app/lib/core/constants/api_endpoints.dart b/frontend/mobile-app/lib/core/constants/api_endpoints.dart index a990ffe4..bcc6ffc6 100644 --- a/frontend/mobile-app/lib/core/constants/api_endpoints.dart +++ b/frontend/mobile-app/lib/core/constants/api_endpoints.dart @@ -79,10 +79,15 @@ class ApiEndpoints { static const String authorizations = '/authorizations'; static const String myAuthorizations = '$authorizations/my'; // 获取我的授权列表 static const String myCommunityHierarchy = '$authorizations/my/community-hierarchy'; // 获取社区层级 + static const String stickmanRanking = '$authorizations/ranking/stickman'; // 火柴人排名 // Telemetry (-> Reporting Service) static const String telemetry = '/telemetry'; static const String telemetrySession = '$telemetry/session'; static const String telemetryHeartbeat = '$telemetry/heartbeat'; static const String telemetryEvents = '$telemetry/events'; + + // System Config (-> Admin Service) + static const String systemConfig = '/system-config'; + static const String displaySettings = '$systemConfig/display/settings'; } diff --git a/frontend/mobile-app/lib/core/di/injection_container.dart b/frontend/mobile-app/lib/core/di/injection_container.dart index e7c828b9..10ad4c55 100644 --- a/frontend/mobile-app/lib/core/di/injection_container.dart +++ b/frontend/mobile-app/lib/core/di/injection_container.dart @@ -11,6 +11,7 @@ import '../services/wallet_service.dart'; import '../services/planting_service.dart'; import '../services/reward_service.dart'; import '../services/notification_service.dart'; +import '../services/system_config_service.dart'; // Storage Providers final secureStorageProvider = Provider((ref) { @@ -86,6 +87,12 @@ final notificationServiceProvider = Provider((ref) { return NotificationService(apiClient: apiClient); }); +// System Config Service Provider (调用 admin-service) +final systemConfigServiceProvider = Provider((ref) { + final apiClient = ref.watch(apiClientProvider); + return SystemConfigService(apiClient: apiClient); +}); + // Override provider with initialized instance ProviderContainer createProviderContainer(LocalStorage localStorage) { return ProviderContainer( diff --git a/frontend/mobile-app/lib/core/services/account_service.dart b/frontend/mobile-app/lib/core/services/account_service.dart index a6494bff..bfea0bc2 100644 --- a/frontend/mobile-app/lib/core/services/account_service.dart +++ b/frontend/mobile-app/lib/core/services/account_service.dart @@ -179,6 +179,43 @@ String _maskAddress(String? address) { return '${address.substring(0, 6)}...${address.substring(address.length - 4)}'; } +/// 短信验证码类型 +enum SmsCodeType { + register, // 注册 + login, // 登录 + bind, // 绑定手机号 + recover, // 恢复账号 +} + +/// 手机号注册响应 (与登录响应相同) +class PhoneAuthResponse { + final String userId; + final String accountSequence; // 用户序列号 + final String accessToken; + final String refreshToken; + + PhoneAuthResponse({ + required this.userId, + required this.accountSequence, + required this.accessToken, + required this.refreshToken, + }); + + factory PhoneAuthResponse.fromJson(Map json) { + debugPrint('[AccountService] 解析 PhoneAuthResponse: ${json.keys.toList()}'); + return PhoneAuthResponse( + userId: json['userId'] as String, + accountSequence: json['accountSequence']?.toString() ?? '', + accessToken: json['accessToken'] as String, + refreshToken: json['refreshToken'] as String, + ); + } + + @override + String toString() => + 'PhoneAuthResponse(userId: $userId, accountSequence: $accountSequence)'; +} + /// 恢复账户响应 class RecoverAccountResponse { final String userId; @@ -1252,4 +1289,327 @@ class AccountService { return null; } } + + // ==================== 手机号注册/登录 ==================== + + /// 验证短信验证码(仅验证,不登录/注册) + /// + /// [phoneNumber] - 手机号 + /// [smsCode] - 6位短信验证码 + /// [type] - 验证码类型 + Future verifySmsCode({ + required String phoneNumber, + required String smsCode, + required SmsCodeType type, + }) async { + debugPrint('$_tag verifySmsCode() - 验证短信验证码'); + debugPrint('$_tag verifySmsCode() - 手机号: ${_maskPhoneNumber(phoneNumber)}, 类型: ${type.name}'); + + try { + final typeStr = type.name.toUpperCase(); + + debugPrint('$_tag verifySmsCode() - 调用 POST /user/verify-sms-code'); + final response = await _apiClient.post( + '/user/verify-sms-code', + data: { + 'phoneNumber': phoneNumber, + 'smsCode': smsCode, + 'type': typeStr, + }, + ); + debugPrint('$_tag verifySmsCode() - API 响应状态码: ${response.statusCode}'); + + // 验证成功后保存手机号 + await _secureStorage.write( + key: StorageKeys.phoneNumber, + value: phoneNumber, + ); + debugPrint('$_tag verifySmsCode() - 短信验证码验证成功'); + } on ApiException catch (e) { + debugPrint('$_tag verifySmsCode() - API 异常: $e'); + rethrow; + } catch (e, stackTrace) { + debugPrint('$_tag verifySmsCode() - 未知异常: $e'); + debugPrint('$_tag verifySmsCode() - 堆栈: $stackTrace'); + throw ApiException('验证码验证失败: $e'); + } + } + + /// 发送短信验证码 + /// + /// [phoneNumber] - 手机号(中国大陆格式:1开头,11位) + /// [type] - 验证码类型 + Future sendSmsCode(String phoneNumber, SmsCodeType type) async { + debugPrint('$_tag sendSmsCode() - 发送短信验证码'); + debugPrint('$_tag sendSmsCode() - 手机号: ${_maskPhoneNumber(phoneNumber)}, 类型: ${type.name}'); + + try { + // 转换类型枚举为后端格式 + final typeStr = type.name.toUpperCase(); + + debugPrint('$_tag sendSmsCode() - 调用 POST /user/send-sms-code'); + final response = await _apiClient.post( + '/user/send-sms-code', + data: { + 'phoneNumber': phoneNumber, + 'type': typeStr, + }, + ); + debugPrint('$_tag sendSmsCode() - API 响应状态码: ${response.statusCode}'); + debugPrint('$_tag sendSmsCode() - 短信验证码已发送'); + } on ApiException catch (e) { + debugPrint('$_tag sendSmsCode() - API 异常: $e'); + rethrow; + } catch (e, stackTrace) { + debugPrint('$_tag sendSmsCode() - 未知异常: $e'); + debugPrint('$_tag sendSmsCode() - 堆栈: $stackTrace'); + throw ApiException('发送验证码失败: $e'); + } + } + + /// 手机号注册 + /// + /// [phoneNumber] - 手机号 + /// [smsCode] - 6位短信验证码 + /// [inviterReferralCode] - 邀请人推荐码(可选) + Future registerByPhone({ + required String phoneNumber, + required String smsCode, + String? inviterReferralCode, + }) async { + debugPrint('$_tag registerByPhone() - 开始手机号注册'); + debugPrint('$_tag registerByPhone() - 手机号: ${_maskPhoneNumber(phoneNumber)}'); + debugPrint('$_tag registerByPhone() - 邀请码: ${inviterReferralCode ?? "无"}'); + + try { + // 获取设备ID + final deviceId = await getDeviceId(); + debugPrint('$_tag registerByPhone() - 获取设备ID成功'); + + // 获取设备硬件信息 + final deviceInfo = await getDeviceHardwareInfo(); + debugPrint('$_tag registerByPhone() - 获取设备硬件信息成功'); + + final deviceName = '${deviceInfo.brand ?? ''} ${deviceInfo.model ?? ''}'.trim(); + + // 调用 API + debugPrint('$_tag registerByPhone() - 调用 POST /user/register'); + final response = await _apiClient.post( + '/user/register', + data: { + 'phoneNumber': phoneNumber, + 'smsCode': smsCode, + 'deviceId': deviceId, + 'deviceName': deviceName.isEmpty ? null : deviceName, + if (inviterReferralCode != null) 'inviterReferralCode': inviterReferralCode, + }, + ); + debugPrint('$_tag registerByPhone() - API 响应状态码: ${response.statusCode}'); + + if (response.data == null) { + debugPrint('$_tag registerByPhone() - 错误: API 返回空响应'); + throw const ApiException('注册失败: 空响应'); + } + + debugPrint('$_tag registerByPhone() - 解析响应数据'); + final responseData = response.data as Map; + final data = responseData['data'] as Map; + final result = PhoneAuthResponse.fromJson(data); + debugPrint('$_tag registerByPhone() - 解析成功: $result'); + + // 保存账号数据 + debugPrint('$_tag registerByPhone() - 保存账号数据'); + await _savePhoneAuthData(result, deviceId, phoneNumber); + + debugPrint('$_tag registerByPhone() - 手机号注册完成'); + return result; + } on ApiException catch (e) { + debugPrint('$_tag registerByPhone() - API 异常: $e'); + rethrow; + } catch (e, stackTrace) { + debugPrint('$_tag registerByPhone() - 未知异常: $e'); + debugPrint('$_tag registerByPhone() - 堆栈: $stackTrace'); + throw ApiException('注册失败: $e'); + } + } + + /// 手机号登录 + /// + /// [phoneNumber] - 手机号 + /// [smsCode] - 6位短信验证码 + Future loginByPhone({ + required String phoneNumber, + required String smsCode, + }) async { + debugPrint('$_tag loginByPhone() - 开始手机号登录'); + debugPrint('$_tag loginByPhone() - 手机号: ${_maskPhoneNumber(phoneNumber)}'); + + try { + // 获取设备ID + final deviceId = await getDeviceId(); + debugPrint('$_tag loginByPhone() - 获取设备ID成功'); + + // 调用 API + debugPrint('$_tag loginByPhone() - 调用 POST /user/login'); + final response = await _apiClient.post( + '/user/login', + data: { + 'phoneNumber': phoneNumber, + 'smsCode': smsCode, + 'deviceId': deviceId, + }, + ); + debugPrint('$_tag loginByPhone() - API 响应状态码: ${response.statusCode}'); + + if (response.data == null) { + debugPrint('$_tag loginByPhone() - 错误: API 返回空响应'); + throw const ApiException('登录失败: 空响应'); + } + + debugPrint('$_tag loginByPhone() - 解析响应数据'); + final responseData = response.data as Map; + final data = responseData['data'] as Map; + final result = PhoneAuthResponse.fromJson(data); + debugPrint('$_tag loginByPhone() - 解析成功: $result'); + + // 保存账号数据 + debugPrint('$_tag loginByPhone() - 保存账号数据'); + await _savePhoneAuthData(result, deviceId, phoneNumber); + + debugPrint('$_tag loginByPhone() - 手机号登录完成'); + return result; + } on ApiException catch (e) { + debugPrint('$_tag loginByPhone() - API 异常: $e'); + rethrow; + } catch (e, stackTrace) { + debugPrint('$_tag loginByPhone() - 未知异常: $e'); + debugPrint('$_tag loginByPhone() - 堆栈: $stackTrace'); + throw ApiException('登录失败: $e'); + } + } + + /// 保存手机号认证数据 + Future _savePhoneAuthData( + PhoneAuthResponse response, + String deviceId, + String phoneNumber, + ) async { + debugPrint('$_tag _savePhoneAuthData() - 开始保存手机号认证数据'); + + // 保存用户序列号 + debugPrint('$_tag _savePhoneAuthData() - 保存 userSerialNum: ${response.accountSequence}'); + await _secureStorage.write( + key: StorageKeys.userSerialNum, + value: response.accountSequence, + ); + + // 保存 Token + debugPrint('$_tag _savePhoneAuthData() - 保存 accessToken (长度: ${response.accessToken.length})'); + await _secureStorage.write( + key: StorageKeys.accessToken, + value: response.accessToken, + ); + + debugPrint('$_tag _savePhoneAuthData() - 保存 refreshToken (长度: ${response.refreshToken.length})'); + await _secureStorage.write( + key: StorageKeys.refreshToken, + value: response.refreshToken, + ); + + // 保存设备 ID + debugPrint('$_tag _savePhoneAuthData() - 保存 deviceId: ${_maskAddress(deviceId)}'); + await _secureStorage.write( + key: StorageKeys.deviceId, + value: deviceId, + ); + + // 保存手机号 + debugPrint('$_tag _savePhoneAuthData() - 保存手机号: ${_maskPhoneNumber(phoneNumber)}'); + await _secureStorage.write( + key: StorageKeys.phoneNumber, + value: phoneNumber, + ); + + // 标记账号已创建 + debugPrint('$_tag _savePhoneAuthData() - 标记账号已创建'); + await _secureStorage.write( + key: StorageKeys.isAccountCreated, + value: 'true', + ); + + // 设置遥测服务的用户ID + if (TelemetryService().isInitialized) { + TelemetryService().setUserId(response.accountSequence); + debugPrint('$_tag _savePhoneAuthData() - 设置TelemetryService userId: ${response.accountSequence}'); + } + + // 设置 Sentry 用户信息 + if (SentryService().isInitialized) { + SentryService().setUser(userId: response.accountSequence); + debugPrint('$_tag _savePhoneAuthData() - 设置SentryService userId: ${response.accountSequence}'); + } + + debugPrint('$_tag _savePhoneAuthData() - 手机号认证数据保存完成'); + } + + /// 获取手机号(从本地存储) + Future getPhoneNumber() async { + debugPrint('$_tag getPhoneNumber() - 获取手机号'); + final result = await _secureStorage.read(key: StorageKeys.phoneNumber); + if (result != null) { + debugPrint('$_tag getPhoneNumber() - 结果: ${_maskPhoneNumber(result)}'); + } else { + debugPrint('$_tag getPhoneNumber() - 未找到手机号'); + } + return result; + } + + /// 设置登录密码 + /// + /// [password] - 登录密码(6-20位,包含字母和数字) + Future setLoginPassword(String password) async { + debugPrint('$_tag setLoginPassword() - 开始设置登录密码'); + + try { + // 调用 API + debugPrint('$_tag setLoginPassword() - 调用 POST /user/set-password'); + final response = await _apiClient.post( + '/user/set-password', + data: { + 'password': password, + }, + ); + debugPrint('$_tag setLoginPassword() - API 响应状态码: ${response.statusCode}'); + + // 标记密码已设置 + await _secureStorage.write( + key: StorageKeys.isPasswordSet, + value: 'true', + ); + + debugPrint('$_tag setLoginPassword() - 登录密码设置完成'); + } on ApiException catch (e) { + debugPrint('$_tag setLoginPassword() - API 异常: $e'); + rethrow; + } catch (e, stackTrace) { + debugPrint('$_tag setLoginPassword() - 未知异常: $e'); + debugPrint('$_tag setLoginPassword() - 堆栈: $stackTrace'); + throw ApiException('设置密码失败: $e'); + } + } + + /// 检查是否已设置密码 + Future isPasswordSet() async { + debugPrint('$_tag isPasswordSet() - 检查是否已设置密码'); + final isSet = await _secureStorage.read(key: StorageKeys.isPasswordSet); + final result = isSet == 'true'; + debugPrint('$_tag isPasswordSet() - 结果: $result'); + return result; + } +} + +/// 遮蔽手机号中间部分,用于日志输出 +String _maskPhoneNumber(String phoneNumber) { + if (phoneNumber.length < 7) return phoneNumber; + return '${phoneNumber.substring(0, 3)}****${phoneNumber.substring(phoneNumber.length - 4)}'; } diff --git a/frontend/mobile-app/lib/core/services/authorization_service.dart b/frontend/mobile-app/lib/core/services/authorization_service.dart index 427da9a0..b3d6ca02 100644 --- a/frontend/mobile-app/lib/core/services/authorization_service.dart +++ b/frontend/mobile-app/lib/core/services/authorization_service.dart @@ -312,11 +312,42 @@ class CommunityHierarchy { } } +/// 火柴人排名响应 +class StickmanRankingResponse { + final String id; + final String nickname; + final String? avatarUrl; + final int completedCount; + final double monthlyEarnings; + final bool isCurrentUser; + + StickmanRankingResponse({ + required this.id, + required this.nickname, + this.avatarUrl, + required this.completedCount, + required this.monthlyEarnings, + required this.isCurrentUser, + }); + + factory StickmanRankingResponse.fromJson(Map json) { + return StickmanRankingResponse( + id: json['id']?.toString() ?? '', + nickname: json['nickname'] ?? '', + avatarUrl: json['avatarUrl'], + completedCount: json['completedCount'] ?? 0, + monthlyEarnings: (json['monthlyEarnings'] ?? 0).toDouble(), + isCurrentUser: json['isCurrentUser'] ?? false, + ); + } +} + /// 授权服务 /// /// 处理用户授权相关功能: /// - 获取用户授权列表 /// - 社区/省公司/市公司授权信息 +/// - 火柴人排名数据 class AuthorizationService { final ApiClient _apiClient; @@ -401,4 +432,52 @@ class AuthorizationService { rethrow; } } + + /// 获取火柴人排名数据 + /// + /// 用于显示省公司/市公司的排名赛跑 + /// [month] 月份格式: YYYY-MM + /// [roleType] 角色类型: AUTH_PROVINCE_COMPANY 或 AUTH_CITY_COMPANY + /// [regionCode] 区域代码/名称 + Future> getStickmanRanking({ + required String month, + required String roleType, + required String regionCode, + }) async { + try { + debugPrint('获取火柴人排名: month=$month, roleType=$roleType, regionCode=$regionCode'); + final response = await _apiClient.get( + ApiEndpoints.stickmanRanking, + queryParameters: { + 'month': month, + 'roleType': roleType, + 'regionCode': regionCode, + }, + ); + + if (response.statusCode == 200) { + final responseData = response.data; + List? dataList; + if (responseData is Map) { + dataList = responseData['data'] as List?; + } else if (responseData is List) { + dataList = responseData; + } + + if (dataList != null) { + final rankings = dataList + .map((e) => StickmanRankingResponse.fromJson(e as Map)) + .toList(); + debugPrint('火柴人排名获取成功: ${rankings.length} 个'); + return rankings; + } + return []; + } + + throw Exception('获取火柴人排名失败'); + } catch (e) { + debugPrint('获取火柴人排名失败: $e'); + rethrow; + } + } } diff --git a/frontend/mobile-app/lib/core/services/system_config_service.dart b/frontend/mobile-app/lib/core/services/system_config_service.dart new file mode 100644 index 00000000..10ce6a72 --- /dev/null +++ b/frontend/mobile-app/lib/core/services/system_config_service.dart @@ -0,0 +1,121 @@ +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; + +/// 热度展示方式 +enum HeatDisplayMode { + /// 显示具体认种数量 + count, + /// 仅显示热度等级(高/中/低) + level, +} + +/// 前端展示设置 +class DisplaySettings { + /// 是否允许未认种用户查看各省认种热度 + final bool allowNonAdopterViewHeat; + + /// 热度展示方式 + final HeatDisplayMode heatDisplayMode; + + DisplaySettings({ + required this.allowNonAdopterViewHeat, + required this.heatDisplayMode, + }); + + factory DisplaySettings.fromJson(Map json) { + return DisplaySettings( + allowNonAdopterViewHeat: json['allowNonAdopterViewHeat'] ?? false, + heatDisplayMode: _parseHeatDisplayMode(json['heatDisplayMode']), + ); + } + + static HeatDisplayMode _parseHeatDisplayMode(String? mode) { + switch (mode) { + case 'level': + return HeatDisplayMode.level; + case 'count': + default: + return HeatDisplayMode.count; + } + } + + /// 默认设置 + factory DisplaySettings.defaultSettings() { + return DisplaySettings( + allowNonAdopterViewHeat: false, + heatDisplayMode: HeatDisplayMode.count, + ); + } +} + +/// 系统配置服务 +class SystemConfigService { + final ApiClient _apiClient; + + /// 缓存的展示设置 + DisplaySettings? _cachedDisplaySettings; + + /// 上次获取设置的时间 + DateTime? _lastFetchTime; + + /// 缓存有效期(5分钟) + static const Duration _cacheExpiration = Duration(minutes: 5); + + SystemConfigService({required ApiClient apiClient}) : _apiClient = apiClient; + + /// 获取前端展示设置 + /// + /// [forceRefresh] 强制刷新,不使用缓存 + Future getDisplaySettings({bool forceRefresh = false}) async { + // 检查缓存是否有效 + if (!forceRefresh && _cachedDisplaySettings != null && _lastFetchTime != null) { + final cacheAge = DateTime.now().difference(_lastFetchTime!); + if (cacheAge < _cacheExpiration) { + return _cachedDisplaySettings!; + } + } + + try { + final response = await _apiClient.get( + '/admin-service/api/v1/system-config/display/settings', + ); + + _cachedDisplaySettings = DisplaySettings.fromJson(response.data); + _lastFetchTime = DateTime.now(); + + debugPrint('[SystemConfigService] 获取展示设置成功: allowNonAdopterViewHeat=${_cachedDisplaySettings!.allowNonAdopterViewHeat}, heatDisplayMode=${_cachedDisplaySettings!.heatDisplayMode}'); + + return _cachedDisplaySettings!; + } catch (e) { + debugPrint('[SystemConfigService] 获取展示设置失败: $e'); + + // 如果有缓存则返回缓存,否则返回默认设置 + return _cachedDisplaySettings ?? DisplaySettings.defaultSettings(); + } + } + + /// 清除缓存 + void clearCache() { + _cachedDisplaySettings = null; + _lastFetchTime = null; + } + + /// 检查用户是否可以查看省份热度 + /// + /// [hasAdopted] 用户是否已认种 + Future canViewProvinceHeat({required bool hasAdopted}) async { + if (hasAdopted) { + // 已认种用户始终可以查看 + return true; + } + + final settings = await getDisplaySettings(); + return settings.allowNonAdopterViewHeat; + } + + /// 获取当前的热度展示方式 + Future getHeatDisplayMode() async { + final settings = await getDisplaySettings(); + return settings.heatDisplayMode; + } +} diff --git a/frontend/mobile-app/lib/core/storage/storage_keys.dart b/frontend/mobile-app/lib/core/storage/storage_keys.dart index 5505d595..4d8771e1 100644 --- a/frontend/mobile-app/lib/core/storage/storage_keys.dart +++ b/frontend/mobile-app/lib/core/storage/storage_keys.dart @@ -14,6 +14,8 @@ class StorageKeys { static const String inviterSequence = 'inviter_sequence'; // 推荐人序列号 static const String inviterReferralCode = 'inviter_referral_code'; // 邀请人推荐码(注册前临时存储) static const String isAccountCreated = 'is_account_created'; // 账号是否已创建 + static const String phoneNumber = 'phone_number'; // 绑定的手机号 + static const String isPasswordSet = 'is_password_set'; // 登录密码是否已设置 /// 生成带账号前缀的存储键 /// 用于多账号隔离存储 diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/guide_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/guide_page.dart index 58044450..42cde51f 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/pages/guide_page.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/guide_page.dart @@ -9,6 +9,7 @@ import '../../../../core/di/injection_container.dart'; import '../../../../core/storage/storage_keys.dart'; import '../../../../routes/route_paths.dart'; import '../providers/auth_provider.dart'; +import 'phone_register_page.dart'; /// 向导页数据模型 class GuidePageData { @@ -340,22 +341,52 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> { context.push(RoutePaths.importMnemonic); } - /// 保存推荐码并继续下一步 + /// 手机号注册 + Future _goToPhoneRegister() async { + debugPrint('[GuidePage] _goToPhoneRegister - 跳转到手机号注册页面'); + + // 如果有推荐人且推荐码有效,先保存到本地存储 + String? inviterCode; + if (_hasReferrer && _referralCodeController.text.trim().isNotEmpty) { + inviterCode = _referralCodeController.text.trim(); + final secureStorage = ref.read(secureStorageProvider); + await secureStorage.write( + key: StorageKeys.inviterReferralCode, + value: inviterCode, + ); + debugPrint('[GuidePage] 保存邀请人推荐码: $inviterCode'); + } + + if (!mounted) return; + context.push( + RoutePaths.phoneRegister, + extra: PhoneRegisterParams(inviterReferralCode: inviterCode), + ); + } + + /// 保存推荐码并继续下一步 (跳转到手机号注册页面) Future _saveReferralCodeAndProceed() async { if (!_canProceed) return; // 如果有推荐人且推荐码有效,保存到本地存储 + String? inviterCode; if (_hasReferrer && _referralCodeController.text.trim().isNotEmpty) { + inviterCode = _referralCodeController.text.trim(); final secureStorage = ref.read(secureStorageProvider); await secureStorage.write( key: StorageKeys.inviterReferralCode, - value: _referralCodeController.text.trim(), + value: inviterCode, ); - debugPrint('[GuidePage] 保存邀请人推荐码: ${_referralCodeController.text.trim()}'); + debugPrint('[GuidePage] 保存邀请人推荐码: $inviterCode'); } - // 调用下一步回调 - widget.onNext(); + if (!mounted) return; + + // 跳转到手机号注册页面 + context.push( + RoutePaths.phoneRegister, + extra: PhoneRegisterParams(inviterReferralCode: inviterCode), + ); } /// 打开二维码扫描页面 @@ -725,6 +756,42 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> { ], ), SizedBox(height: 24.h), + // 手机号注册入口 + GestureDetector( + onTap: _goToPhoneRegister, + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: 14.h), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.phone_android, + size: 20.sp, + color: Colors.white, + ), + SizedBox(width: 8.w), + Text( + '使用手机号注册', + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + ], + ), + ), + ), + SizedBox(height: 12.h), // 导入助记词入口 GestureDetector( onTap: _importMnemonic, diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/phone_register_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/phone_register_page.dart new file mode 100644 index 00000000..234650e4 --- /dev/null +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/phone_register_page.dart @@ -0,0 +1,325 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; +import '../../../../core/di/injection_container.dart'; +import '../../../../core/services/account_service.dart'; +import '../../../../routes/route_paths.dart'; + +/// 手机号注册页面参数 +class PhoneRegisterParams { + final String? inviterReferralCode; // 邀请人推荐码(可选) + + PhoneRegisterParams({this.inviterReferralCode}); +} + +/// 手机号注册页面 +/// 输入手机号,发送验证码,跳转到验证码验证页面 +class PhoneRegisterPage extends ConsumerStatefulWidget { + final String? inviterReferralCode; + + const PhoneRegisterPage({ + super.key, + this.inviterReferralCode, + }); + + @override + ConsumerState createState() => _PhoneRegisterPageState(); +} + +class _PhoneRegisterPageState extends ConsumerState { + final TextEditingController _phoneController = TextEditingController(); + final FocusNode _phoneFocusNode = FocusNode(); + + bool _isSending = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + debugPrint('[PhoneRegisterPage] initState - inviterReferralCode: ${widget.inviterReferralCode}'); + _phoneController.addListener(_onPhoneChanged); + } + + @override + void dispose() { + _phoneController.removeListener(_onPhoneChanged); + _phoneController.dispose(); + _phoneFocusNode.dispose(); + super.dispose(); + } + + void _onPhoneChanged() { + // 清除错误信息 + if (_errorMessage != null) { + setState(() { + _errorMessage = null; + }); + } + } + + /// 验证手机号格式 + bool _isValidPhoneNumber(String phone) { + // 中国大陆手机号:1开头,第二位3-9,共11位 + final regex = RegExp(r'^1[3-9]\d{9}$'); + return regex.hasMatch(phone); + } + + /// 发送验证码并跳转 + Future _sendSmsCode() async { + final phone = _phoneController.text.trim(); + + // 验证手机号格式 + if (!_isValidPhoneNumber(phone)) { + setState(() { + _errorMessage = '请输入正确的手机号'; + }); + return; + } + + setState(() { + _isSending = true; + _errorMessage = null; + }); + + try { + final accountService = ref.read(accountServiceProvider); + + // 发送验证码 + debugPrint('[PhoneRegisterPage] 发送验证码...'); + await accountService.sendSmsCode(phone, SmsCodeType.register); + debugPrint('[PhoneRegisterPage] 验证码已发送'); + + if (!mounted) return; + + // 跳转到验证码页面 + context.push( + RoutePaths.smsVerify, + extra: SmsVerifyParams( + phoneNumber: phone, + type: SmsCodeType.register, + inviterReferralCode: widget.inviterReferralCode, + ), + ); + } catch (e) { + debugPrint('[PhoneRegisterPage] 发送验证码失败: $e'); + if (mounted) { + setState(() { + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } finally { + if (mounted) { + setState(() { + _isSending = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Color(0xFF333333)), + onPressed: () => context.pop(), + ), + title: Text( + '手机号注册', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF333333), + ), + ), + centerTitle: true, + ), + body: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 32.h), + // 标题 + Text( + '输入手机号', + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 8.h), + Text( + '我们将向您的手机发送验证码', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF999999), + ), + ), + SizedBox(height: 32.h), + // 手机号输入框 + _buildPhoneInput(), + // 错误信息 + if (_errorMessage != null) ...[ + SizedBox(height: 8.h), + Text( + _errorMessage!, + style: TextStyle( + fontSize: 12.sp, + color: Colors.red, + ), + ), + ], + const Spacer(), + // 下一步按钮 + _buildNextButton(), + SizedBox(height: 32.h), + ], + ), + ), + ), + ), + ); + } + + Widget _buildPhoneInput() { + return Container( + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: _errorMessage != null + ? Colors.red.withValues(alpha: 0.5) + : Colors.transparent, + ), + ), + child: Row( + children: [ + // 国家/地区代码 + Container( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), + decoration: BoxDecoration( + border: Border( + right: BorderSide( + color: const Color(0xFFE0E0E0), + width: 1.w, + ), + ), + ), + child: Text( + '+86', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w500, + color: const Color(0xFF333333), + ), + ), + ), + // 手机号输入 + Expanded( + child: TextField( + controller: _phoneController, + focusNode: _phoneFocusNode, + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(11), + ], + decoration: InputDecoration( + hintText: '请输入手机号', + hintStyle: TextStyle( + fontSize: 16.sp, + color: const Color(0xFFBBBBBB), + ), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: 16.w, + vertical: 16.h, + ), + ), + style: TextStyle( + fontSize: 16.sp, + color: const Color(0xFF333333), + ), + ), + ), + // 清除按钮 + if (_phoneController.text.isNotEmpty) + GestureDetector( + onTap: () { + _phoneController.clear(); + setState(() {}); + }, + child: Padding( + padding: EdgeInsets.only(right: 16.w), + child: Icon( + Icons.cancel, + size: 20.sp, + color: const Color(0xFFBBBBBB), + ), + ), + ), + ], + ), + ); + } + + Widget _buildNextButton() { + final isValid = _isValidPhoneNumber(_phoneController.text.trim()); + + return GestureDetector( + onTap: isValid && !_isSending ? _sendSmsCode : null, + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: 16.h), + decoration: BoxDecoration( + color: isValid && !_isSending + ? const Color(0xFF2E7D32) + : const Color(0xFFE0E0E0), + borderRadius: BorderRadius.circular(12.r), + ), + child: Center( + child: _isSending + ? SizedBox( + width: 24.sp, + height: 24.sp, + child: const CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + '获取验证码', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: isValid ? Colors.white : const Color(0xFF999999), + ), + ), + ), + ), + ); + } +} + +/// 短信验证码页面参数 +class SmsVerifyParams { + final String phoneNumber; + final SmsCodeType type; + final String? inviterReferralCode; + + SmsVerifyParams({ + required this.phoneNumber, + required this.type, + this.inviterReferralCode, + }); +} diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/set_password_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/set_password_page.dart new file mode 100644 index 00000000..931f8b08 --- /dev/null +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/set_password_page.dart @@ -0,0 +1,360 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; +import '../../../../core/di/injection_container.dart'; +import '../../../../routes/route_paths.dart'; + +/// 设置登录密码页面参数 +class SetPasswordParams { + final String userSerialNum; + final String? inviterReferralCode; + + SetPasswordParams({ + required this.userSerialNum, + this.inviterReferralCode, + }); +} + +/// 设置登录密码页面 +/// 用户完成短信验证后,设置登录密码 +class SetPasswordPage extends ConsumerStatefulWidget { + final String userSerialNum; + final String? inviterReferralCode; + + const SetPasswordPage({ + super.key, + required this.userSerialNum, + this.inviterReferralCode, + }); + + @override + ConsumerState createState() => _SetPasswordPageState(); +} + +class _SetPasswordPageState extends ConsumerState { + final _formKey = GlobalKey(); + final _passwordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + + bool _isPasswordVisible = false; + bool _isConfirmPasswordVisible = false; + bool _isSubmitting = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + debugPrint('[SetPasswordPage] initState - userSerialNum: ${widget.userSerialNum}'); + } + + @override + void dispose() { + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + /// 验证密码强度 + String? _validatePassword(String? value) { + if (value == null || value.isEmpty) { + return '请输入密码'; + } + if (value.length < 6) { + return '密码长度至少6位'; + } + if (value.length > 20) { + return '密码长度不能超过20位'; + } + // 检查是否包含字母和数字 + if (!RegExp(r'^(?=.*[A-Za-z])(?=.*\d).+$').hasMatch(value)) { + return '密码需包含字母和数字'; + } + return null; + } + + /// 验证确认密码 + String? _validateConfirmPassword(String? value) { + if (value == null || value.isEmpty) { + return '请再次输入密码'; + } + if (value != _passwordController.text) { + return '两次输入的密码不一致'; + } + return null; + } + + /// 提交设置密码 + Future _submit() async { + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() { + _isSubmitting = true; + _errorMessage = null; + }); + + try { + final accountService = ref.read(accountServiceProvider); + final password = _passwordController.text; + + debugPrint('[SetPasswordPage] 开始设置密码...'); + + // 调用设置密码 API + await accountService.setLoginPassword(password); + + debugPrint('[SetPasswordPage] 密码设置成功'); + + if (!mounted) return; + + // 跳转到主页 + context.go(RoutePaths.mining); + } catch (e) { + debugPrint('[SetPasswordPage] 设置密码失败: $e'); + if (mounted) { + setState(() { + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } finally { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Color(0xFF333333)), + onPressed: () => context.pop(), + ), + title: Text( + '设置登录密码', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF333333), + ), + ), + centerTitle: true, + ), + body: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 32.h), + // 标题 + Text( + '创建登录密码', + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 8.h), + Text( + '密码需包含6-20位字母和数字', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF999999), + ), + ), + SizedBox(height: 32.h), + // 密码输入框 + _buildPasswordField( + controller: _passwordController, + label: '登录密码', + hint: '请输入密码', + isPasswordVisible: _isPasswordVisible, + onToggleVisibility: () { + setState(() { + _isPasswordVisible = !_isPasswordVisible; + }); + }, + validator: _validatePassword, + ), + SizedBox(height: 16.h), + // 确认密码输入框 + _buildPasswordField( + controller: _confirmPasswordController, + label: '确认密码', + hint: '请再次输入密码', + isPasswordVisible: _isConfirmPasswordVisible, + onToggleVisibility: () { + setState(() { + _isConfirmPasswordVisible = !_isConfirmPasswordVisible; + }); + }, + validator: _validateConfirmPassword, + ), + // 错误信息 + if (_errorMessage != null) ...[ + SizedBox(height: 16.h), + Container( + padding: EdgeInsets.all(12.w), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8.r), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: Colors.red, + size: 20.sp, + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle( + fontSize: 14.sp, + color: Colors.red, + ), + ), + ), + ], + ), + ), + ], + SizedBox(height: 40.h), + // 提交按钮 + GestureDetector( + onTap: _isSubmitting ? null : _submit, + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: 16.h), + decoration: BoxDecoration( + color: _isSubmitting + ? const Color(0xFFCCCCCC) + : const Color(0xFF2E7D32), + borderRadius: BorderRadius.circular(12.r), + ), + child: _isSubmitting + ? Center( + child: SizedBox( + width: 24.sp, + height: 24.sp, + child: const CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ), + ) + : Text( + '完成注册', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + ), + ), + SizedBox(height: 32.h), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildPasswordField({ + required TextEditingController controller, + required String label, + required String hint, + required bool isPasswordVisible, + required VoidCallback onToggleVisibility, + required String? Function(String?) validator, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w500, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 8.h), + TextFormField( + controller: controller, + obscureText: !isPasswordVisible, + validator: validator, + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle( + fontSize: 16.sp, + color: const Color(0xFF999999), + ), + filled: true, + fillColor: const Color(0xFFF5F5F5), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: const BorderSide( + color: Color(0xFF2E7D32), + width: 2, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: const BorderSide( + color: Colors.red, + width: 1, + ), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: const BorderSide( + color: Colors.red, + width: 2, + ), + ), + contentPadding: EdgeInsets.symmetric( + horizontal: 16.w, + vertical: 16.h, + ), + suffixIcon: IconButton( + icon: Icon( + isPasswordVisible ? Icons.visibility_off : Icons.visibility, + color: const Color(0xFF999999), + ), + onPressed: onToggleVisibility, + ), + ), + style: TextStyle( + fontSize: 16.sp, + color: const Color(0xFF333333), + ), + ), + ], + ); + } +} diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/sms_verify_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/sms_verify_page.dart new file mode 100644 index 00000000..9587ccfc --- /dev/null +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/sms_verify_page.dart @@ -0,0 +1,446 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; +import '../../../../core/di/injection_container.dart'; +import '../../../../core/services/account_service.dart'; +import '../../../../core/services/multi_account_service.dart'; +import '../../../../routes/route_paths.dart'; +import 'set_password_page.dart'; + +/// 短信验证码页面 +/// 输入6位验证码完成注册/登录 +class SmsVerifyPage extends ConsumerStatefulWidget { + final String phoneNumber; + final SmsCodeType type; + final String? inviterReferralCode; + + const SmsVerifyPage({ + super.key, + required this.phoneNumber, + required this.type, + this.inviterReferralCode, + }); + + @override + ConsumerState createState() => _SmsVerifyPageState(); +} + +class _SmsVerifyPageState extends ConsumerState { + final List _controllers = List.generate( + 6, + (_) => TextEditingController(), + ); + final List _focusNodes = List.generate(6, (_) => FocusNode()); + + bool _isVerifying = false; + String? _errorMessage; + + // 倒计时 + int _countdown = 60; + Timer? _countdownTimer; + bool _canResend = false; + + @override + void initState() { + super.initState(); + debugPrint('[SmsVerifyPage] initState - phoneNumber: ${_maskPhoneNumber(widget.phoneNumber)}, type: ${widget.type}'); + _startCountdown(); + // 自动聚焦第一个输入框 + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNodes[0].requestFocus(); + }); + } + + @override + void dispose() { + _countdownTimer?.cancel(); + for (final controller in _controllers) { + controller.dispose(); + } + for (final focusNode in _focusNodes) { + focusNode.dispose(); + } + super.dispose(); + } + + void _startCountdown() { + _countdown = 60; + _canResend = false; + _countdownTimer?.cancel(); + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_countdown > 0) { + setState(() { + _countdown--; + }); + } else { + setState(() { + _canResend = true; + }); + timer.cancel(); + } + }); + } + + String _getVerificationCode() { + return _controllers.map((c) => c.text).join(); + } + + /// 重新发送验证码 + Future _resendCode() async { + if (!_canResend) return; + + try { + final accountService = ref.read(accountServiceProvider); + await accountService.sendSmsCode(widget.phoneNumber, widget.type); + + if (mounted) { + _startCountdown(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '验证码已发送', + style: TextStyle(fontSize: 14.sp), + ), + backgroundColor: const Color(0xFF2E7D32), + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.all(16.w), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + ), + ); + } + } catch (e) { + debugPrint('[SmsVerifyPage] 重新发送验证码失败: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '发送失败: ${e.toString().replaceAll('Exception: ', '')}', + style: TextStyle(fontSize: 14.sp), + ), + backgroundColor: Colors.red, + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.all(16.w), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + ), + ); + } + } + } + + /// 验证验证码 + Future _verifyCode() async { + final code = _getVerificationCode(); + if (code.length != 6) { + setState(() { + _errorMessage = '请输入完整的验证码'; + }); + return; + } + + setState(() { + _isVerifying = true; + _errorMessage = null; + }); + + try { + final accountService = ref.read(accountServiceProvider); + + if (widget.type == SmsCodeType.register) { + // 注册流程:先验证短信验证码,然后调用 auto-create 创建账号 + debugPrint('[SmsVerifyPage] 开始验证短信验证码...'); + + // 验证短信验证码 + await accountService.verifySmsCode( + phoneNumber: widget.phoneNumber, + smsCode: code, + type: SmsCodeType.register, + ); + debugPrint('[SmsVerifyPage] 短信验证码验证成功'); + + // 调用 auto-create 创建账号 + debugPrint('[SmsVerifyPage] 开始调用 auto-create 创建账号...'); + final response = await accountService.createAccount( + inviterReferralCode: widget.inviterReferralCode, + ); + debugPrint('[SmsVerifyPage] 账号创建成功: ${response.userSerialNum}'); + + if (!mounted) return; + + // 将账号添加到多账号列表 + final multiAccountService = ref.read(multiAccountServiceProvider); + await multiAccountService.addAccount( + AccountSummary( + userSerialNum: response.userSerialNum, + username: response.username, + avatarSvg: response.avatarSvg, + createdAt: DateTime.now(), + ), + ); + await multiAccountService.setCurrentAccountId(response.userSerialNum); + debugPrint('[SmsVerifyPage] 已添加到多账号列表'); + + if (!mounted) return; + + // 跳转到设置密码页面 + context.go( + RoutePaths.setPassword, + extra: SetPasswordParams( + userSerialNum: response.userSerialNum, + inviterReferralCode: widget.inviterReferralCode, + ), + ); + } else if (widget.type == SmsCodeType.login) { + // 登录 + debugPrint('[SmsVerifyPage] 开始登录...'); + final response = await accountService.loginByPhone( + phoneNumber: widget.phoneNumber, + smsCode: code, + ); + debugPrint('[SmsVerifyPage] 登录成功: ${response.accountSequence}'); + + if (!mounted) return; + + // 跳转到主页 + context.go(RoutePaths.mining); + } + } catch (e) { + debugPrint('[SmsVerifyPage] 验证失败: $e'); + if (mounted) { + setState(() { + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + // 清空验证码 + for (final controller in _controllers) { + controller.clear(); + } + _focusNodes[0].requestFocus(); + } + } finally { + if (mounted) { + setState(() { + _isVerifying = false; + }); + } + } + } + + void _onCodeChanged(int index, String value) { + // 清除错误信息 + if (_errorMessage != null) { + setState(() { + _errorMessage = null; + }); + } + + if (value.isNotEmpty) { + // 输入了字符,移动到下一个 + if (index < 5) { + _focusNodes[index + 1].requestFocus(); + } else { + // 输入完成,验证 + _focusNodes[index].unfocus(); + _verifyCode(); + } + } + } + + void _onKeyEvent(int index, KeyEvent event) { + if (event is KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.backspace) { + if (_controllers[index].text.isEmpty && index > 0) { + // 当前格为空,删除前一格 + _controllers[index - 1].clear(); + _focusNodes[index - 1].requestFocus(); + } + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Color(0xFF333333)), + onPressed: () => context.pop(), + ), + title: Text( + widget.type == SmsCodeType.register ? '手机号注册' : '手机号登录', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF333333), + ), + ), + centerTitle: true, + ), + body: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 32.h), + // 标题 + Text( + '输入验证码', + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 8.h), + Text( + '验证码已发送至 ${_formatPhoneNumber(widget.phoneNumber)}', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF999999), + ), + ), + SizedBox(height: 32.h), + // 验证码输入框 + _buildCodeInputs(), + // 错误信息 + if (_errorMessage != null) ...[ + SizedBox(height: 16.h), + Text( + _errorMessage!, + style: TextStyle( + fontSize: 14.sp, + color: Colors.red, + ), + ), + ], + SizedBox(height: 24.h), + // 重新发送按钮 + _buildResendButton(), + const Spacer(), + // 验证中提示 + if (_isVerifying) + Center( + child: Column( + children: [ + SizedBox( + width: 32.sp, + height: 32.sp, + child: const CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(Color(0xFF2E7D32)), + ), + ), + SizedBox(height: 12.h), + Text( + widget.type == SmsCodeType.register ? '正在注册...' : '正在登录...', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF666666), + ), + ), + ], + ), + ), + SizedBox(height: 32.h), + ], + ), + ), + ), + ), + ); + } + + Widget _buildCodeInputs() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(6, (index) { + return SizedBox( + width: 48.w, + height: 56.h, + child: KeyboardListener( + focusNode: FocusNode(), + onKeyEvent: (event) => _onKeyEvent(index, event), + child: TextField( + controller: _controllers[index], + focusNode: _focusNodes[index], + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + maxLength: 1, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + decoration: InputDecoration( + counterText: '', + filled: true, + fillColor: const Color(0xFFF5F5F5), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: const BorderSide( + color: Color(0xFF2E7D32), + width: 2, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: const BorderSide( + color: Colors.red, + width: 1, + ), + ), + ), + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: const Color(0xFF333333), + ), + onChanged: (value) => _onCodeChanged(index, value), + ), + ), + ); + }), + ); + } + + Widget _buildResendButton() { + return Center( + child: GestureDetector( + onTap: _canResend ? _resendCode : null, + child: Text( + _canResend ? '重新发送验证码' : '$_countdown秒后重新发送', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w500, + color: _canResend + ? const Color(0xFF2E7D32) + : const Color(0xFF999999), + ), + ), + ), + ); + } + + String _formatPhoneNumber(String phone) { + if (phone.length != 11) return phone; + return '${phone.substring(0, 3)} **** ${phone.substring(7)}'; + } + + String _maskPhoneNumber(String phone) { + if (phone.length < 7) return phone; + return '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}'; + } +} diff --git a/frontend/mobile-app/lib/features/authorization/presentation/pages/authorization_apply_page.dart b/frontend/mobile-app/lib/features/authorization/presentation/pages/authorization_apply_page.dart new file mode 100644 index 00000000..bf5cc0a0 --- /dev/null +++ b/frontend/mobile-app/lib/features/authorization/presentation/pages/authorization_apply_page.dart @@ -0,0 +1,898 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; + +/// 授权类型枚举 +enum AuthorizationType { + community, // 社区 + cityTeam, // 市团队 + provinceTeam, // 省团队 +} + +/// 授权类型显示信息 +extension AuthorizationTypeExtension on AuthorizationType { + String get displayName { + switch (this) { + case AuthorizationType.community: + return '社区'; + case AuthorizationType.cityTeam: + return '市团队'; + case AuthorizationType.provinceTeam: + return '省团队'; + } + } + + String get description { + switch (this) { + case AuthorizationType.community: + return '社区权益 - 576 USDT/棵'; + case AuthorizationType.cityTeam: + return '市团队权益 - 288 USDT/棵'; + case AuthorizationType.provinceTeam: + return '省团队权益 - 144 USDT/棵'; + } + } +} + +/// 自助申请授权页面 +class AuthorizationApplyPage extends ConsumerStatefulWidget { + const AuthorizationApplyPage({super.key}); + + @override + ConsumerState createState() => + _AuthorizationApplyPageState(); +} + +class _AuthorizationApplyPageState + extends ConsumerState { + /// 选中的授权类型 + AuthorizationType? _selectedType; + + /// 办公室照片列表 + final List _officePhotos = []; + + /// 图片选择器 + final ImagePicker _picker = ImagePicker(); + + /// 是否正在提交 + bool _isSubmitting = false; + + /// 是否正在加载 + bool _isLoading = true; + + /// 用户是否已认种 + bool _hasPlanted = false; + + /// 用户认种棵数 + int _plantedCount = 0; + + /// 用户已有的授权 + List _existingAuthorizations = []; + + @override + void initState() { + super.initState(); + _loadUserStatus(); + } + + /// 加载用户状态 + Future _loadUserStatus() async { + setState(() { + _isLoading = true; + }); + + try { + // TODO: 调用API获取用户认种状态和已有授权 + // final authService = ref.read(authorizationServiceProvider); + // final status = await authService.getUserAuthorizationStatus(); + + // 模拟数据 + await Future.delayed(const Duration(milliseconds: 500)); + + if (mounted) { + setState(() { + _hasPlanted = true; // 模拟已认种 + _plantedCount = 10; // 模拟认种10棵 + _existingAuthorizations = []; // 模拟无已有授权 + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + }); + _showErrorSnackBar('加载状态失败: ${e.toString()}'); + } + } + } + + /// 返回上一页 + void _goBack() { + context.pop(); + } + + /// 选择照片 + Future _pickPhoto() async { + if (_officePhotos.length >= 6) { + _showErrorSnackBar('最多上传6张照片'); + return; + } + + try { + final XFile? image = await _picker.pickImage( + source: ImageSource.gallery, + maxWidth: 1920, + maxHeight: 1920, + imageQuality: 85, + ); + + if (image != null && mounted) { + setState(() { + _officePhotos.add(File(image.path)); + }); + } + } catch (e) { + _showErrorSnackBar('选择图片失败'); + } + } + + /// 拍照 + Future _takePhoto() async { + if (_officePhotos.length >= 6) { + _showErrorSnackBar('最多上传6张照片'); + return; + } + + try { + final XFile? image = await _picker.pickImage( + source: ImageSource.camera, + maxWidth: 1920, + maxHeight: 1920, + imageQuality: 85, + ); + + if (image != null && mounted) { + setState(() { + _officePhotos.add(File(image.path)); + }); + } + } catch (e) { + _showErrorSnackBar('拍照失败'); + } + } + + /// 删除照片 + void _removePhoto(int index) { + setState(() { + _officePhotos.removeAt(index); + }); + } + + /// 提交申请 + Future _submitApplication() async { + // 验证 + if (_selectedType == null) { + _showErrorSnackBar('请选择要申请的授权类型'); + return; + } + + if (!_hasPlanted) { + _showErrorSnackBar('您还未认种,请先进行认种'); + return; + } + + if (_officePhotos.isEmpty) { + _showErrorSnackBar('请上传办公室照片'); + return; + } + + if (_officePhotos.length < 2) { + _showErrorSnackBar('请至少上传2张办公室照片'); + return; + } + + // 检查是否已有该类型授权 + final typeName = _selectedType!.displayName; + if (_existingAuthorizations.contains(typeName)) { + _showErrorSnackBar('您已拥有${typeName}授权'); + return; + } + + setState(() { + _isSubmitting = true; + }); + + try { + // TODO: 调用API提交申请 + // final authService = ref.read(authorizationServiceProvider); + // await authService.submitAuthorizationApplication( + // type: _selectedType!, + // officePhotos: _officePhotos, + // ); + + // 模拟请求 + await Future.delayed(const Duration(seconds: 2)); + + if (mounted) { + setState(() { + _isSubmitting = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('申请已提交,请等待审核'), + backgroundColor: Color(0xFF4CAF50), + ), + ); + + context.pop(true); + } + } catch (e) { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + _showErrorSnackBar('提交失败: ${e.toString()}'); + } + } + } + + /// 显示错误提示 + void _showErrorSnackBar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.red, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFFFFF5E6), + Color(0xFFFFE4B5), + ], + ), + ), + child: SafeArea( + child: Column( + children: [ + // 顶部导航栏 + _buildAppBar(), + // 内容区域 + Expanded( + child: _isLoading + ? const Center( + child: CircularProgressIndicator( + valueColor: + AlwaysStoppedAnimation(Color(0xFFD4AF37)), + ), + ) + : SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 申请说明卡片 + _buildInfoCard(), + const SizedBox(height: 24), + + // 用户状态卡片 + _buildUserStatusCard(), + const SizedBox(height: 24), + + // 可申请的授权类型 + _buildAuthorizationTypes(), + const SizedBox(height: 24), + + // 不可自助申请的说明 + _buildNonSelfApplyInfo(), + const SizedBox(height: 24), + + // 办公室照片上传 + if (_selectedType != null) ...[ + _buildPhotoUpload(), + const SizedBox(height: 32), + ], + + // 提交按钮 + _buildSubmitButton(), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + /// 构建顶部导航栏 + Widget _buildAppBar() { + return Container( + height: 64, + padding: const EdgeInsets.only(top: 16, left: 16, right: 16), + child: Row( + children: [ + // 返回按钮 + GestureDetector( + onTap: _goBack, + child: Container( + width: 48, + height: 48, + alignment: Alignment.center, + child: const Icon( + Icons.arrow_back, + size: 24, + color: Color(0xFF5D4037), + ), + ), + ), + // 标题 + const Expanded( + child: Text( + '自助申请授权', + style: TextStyle( + fontSize: 18, + fontFamily: 'Inter', + fontWeight: FontWeight.w500, + height: 1.25, + letterSpacing: -0.27, + color: Color(0xFF5D4037), + ), + textAlign: TextAlign.center, + ), + ), + // 占位 + const SizedBox(width: 48), + ], + ), + ); + } + + /// 构建申请说明卡片 + Widget _buildInfoCard() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFFF5E6), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0x33D4AF37), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: const [ + Icon( + Icons.info_outline, + size: 20, + color: Color(0xFFD4AF37), + ), + SizedBox(width: 8), + Text( + '申请说明', + style: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + ], + ), + const SizedBox(height: 12), + const Text( + '1. 申请账户必须已完成认种\n' + '2. 需提供办公室照片(至少2张)\n' + '3. 社区、市团队、省团队支持自助申请\n' + '4. 提交后等待审核,审核通过后生效', + style: TextStyle( + fontSize: 14, + fontFamily: 'Inter', + height: 1.6, + color: Color(0xFF745D43), + ), + ), + ], + ), + ); + } + + /// 构建用户状态卡片 + Widget _buildUserStatusCard() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0x80FFFFFF), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0x33D4AF37), + width: 1, + ), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: _hasPlanted + ? const Color(0xFF4CAF50).withValues(alpha: 0.1) + : const Color(0xFFFF9800).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(24), + ), + child: Icon( + _hasPlanted ? Icons.check_circle : Icons.warning, + color: + _hasPlanted ? const Color(0xFF4CAF50) : const Color(0xFFFF9800), + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _hasPlanted ? '已认种' : '未认种', + style: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: _hasPlanted + ? const Color(0xFF4CAF50) + : const Color(0xFFFF9800), + ), + ), + const SizedBox(height: 4), + Text( + _hasPlanted ? '累计认种 $_plantedCount 棵' : '请先完成认种后再申请授权', + style: const TextStyle( + fontSize: 14, + fontFamily: 'Inter', + color: Color(0xFF745D43), + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// 构建授权类型选择 + Widget _buildAuthorizationTypes() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '选择申请类型', + style: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w500, + color: Color(0xFF5D4037), + ), + ), + const SizedBox(height: 12), + ...AuthorizationType.values.map((type) => _buildTypeItem(type)), + ], + ); + } + + /// 构建授权类型项 + Widget _buildTypeItem(AuthorizationType type) { + final isSelected = _selectedType == type; + final isAlreadyHas = _existingAuthorizations.contains(type.displayName); + + return GestureDetector( + onTap: isAlreadyHas + ? null + : () { + setState(() { + _selectedType = type; + }); + }, + child: Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isAlreadyHas + ? const Color(0xFFE0E0E0) + : isSelected + ? const Color(0xFFD4AF37).withValues(alpha: 0.1) + : const Color(0x80FFFFFF), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? const Color(0xFFD4AF37) + : const Color(0x33D4AF37), + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + // 选中指示器 + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: isAlreadyHas + ? const Color(0xFF9E9E9E) + : isSelected + ? const Color(0xFFD4AF37) + : const Color(0xFF8B5A2B), + width: 2, + ), + color: isSelected ? const Color(0xFFD4AF37) : Colors.transparent, + ), + child: isSelected + ? const Icon( + Icons.check, + size: 16, + color: Colors.white, + ) + : null, + ), + const SizedBox(width: 16), + // 类型信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + type.displayName, + style: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: isAlreadyHas + ? const Color(0xFF9E9E9E) + : const Color(0xFF5D4037), + ), + ), + const SizedBox(height: 4), + Text( + type.description, + style: TextStyle( + fontSize: 14, + fontFamily: 'Inter', + color: isAlreadyHas + ? const Color(0xFFBDBDBD) + : const Color(0xFF745D43), + ), + ), + ], + ), + ), + // 已拥有标签 + if (isAlreadyHas) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF9E9E9E), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + '已拥有', + style: TextStyle( + fontSize: 12, + fontFamily: 'Inter', + color: Colors.white, + ), + ), + ), + ], + ), + ), + ); + } + + /// 构建不可自助申请的说明 + Widget _buildNonSelfApplyInfo() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFEFEFEF), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: const [ + Icon( + Icons.block, + size: 20, + color: Color(0xFF9E9E9E), + ), + SizedBox(width: 8), + Text( + '不支持自助申请', + style: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: Color(0xFF757575), + ), + ), + ], + ), + const SizedBox(height: 12), + const Text( + '省区域和市区域授权不支持自助申请,获得方式:\n\n' + '1. 自动升级\n' + ' - 某省团队账号伞下第一个累计达到5万棵的账户\n' + ' 自动获得该省区域授权\n' + ' - 某市团队账号伞下第一个累计达到1万棵的账户\n' + ' 自动获得该市区域授权\n\n' + '2. 后台手动授权', + style: TextStyle( + fontSize: 13, + fontFamily: 'Inter', + height: 1.6, + color: Color(0xFF757575), + ), + ), + ], + ), + ); + } + + /// 构建照片上传区域 + Widget _buildPhotoUpload() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: const [ + Text( + '办公室照片', + style: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w500, + color: Color(0xFF5D4037), + ), + ), + SizedBox(width: 4), + Text( + '*', + style: TextStyle( + fontSize: 16, + color: Colors.red, + ), + ), + ], + ), + const SizedBox(height: 4), + const Text( + '请上传办公室照片,至少2张,最多6张', + style: TextStyle( + fontSize: 12, + fontFamily: 'Inter', + color: Color(0xFF745D43), + ), + ), + const SizedBox(height: 12), + // 照片网格 + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 1, + ), + itemCount: _officePhotos.length + 1, + itemBuilder: (context, index) { + if (index == _officePhotos.length) { + // 添加按钮 + return _buildAddPhotoButton(); + } + // 已选照片 + return _buildPhotoItem(index); + }, + ), + ], + ); + } + + /// 构建添加照片按钮 + Widget _buildAddPhotoButton() { + if (_officePhotos.length >= 6) { + return const SizedBox.shrink(); + } + + return GestureDetector( + onTap: () => _showPhotoSourceDialog(), + child: Container( + decoration: BoxDecoration( + color: const Color(0x80FFFFFF), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: const Color(0x33D4AF37), + width: 1, + style: BorderStyle.solid, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon( + Icons.add_a_photo, + size: 32, + color: Color(0xFFD4AF37), + ), + SizedBox(height: 4), + Text( + '添加照片', + style: TextStyle( + fontSize: 12, + fontFamily: 'Inter', + color: Color(0xFF745D43), + ), + ), + ], + ), + ), + ); + } + + /// 显示照片来源选择 + void _showPhotoSourceDialog() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.photo_library, color: Color(0xFFD4AF37)), + title: const Text('从相册选择'), + onTap: () { + Navigator.pop(context); + _pickPhoto(); + }, + ), + ListTile( + leading: const Icon(Icons.camera_alt, color: Color(0xFFD4AF37)), + title: const Text('拍照'), + onTap: () { + Navigator.pop(context); + _takePhoto(); + }, + ), + ListTile( + leading: const Icon(Icons.close, color: Color(0xFF757575)), + title: const Text('取消'), + onTap: () => Navigator.pop(context), + ), + ], + ), + ), + ); + } + + /// 构建已选照片项 + Widget _buildPhotoItem(int index) { + return Stack( + children: [ + // 照片 + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: DecorationImage( + image: FileImage(_officePhotos[index]), + fit: BoxFit.cover, + ), + ), + ), + // 删除按钮 + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: () => _removePhoto(index), + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + size: 16, + color: Colors.white, + ), + ), + ), + ), + ], + ); + } + + /// 构建提交按钮 + Widget _buildSubmitButton() { + final canSubmit = _hasPlanted && + _selectedType != null && + _officePhotos.length >= 2 && + !_isSubmitting; + + return GestureDetector( + onTap: canSubmit ? _submitApplication : null, + child: Container( + width: double.infinity, + height: 56, + decoration: BoxDecoration( + color: canSubmit + ? const Color(0xFFD4AF37) + : const Color(0xFFD4AF37).withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + boxShadow: canSubmit + ? const [ + BoxShadow( + color: Color(0x4DD4AF37), + blurRadius: 14, + offset: Offset(0, 4), + ), + ] + : null, + ), + child: Center( + child: _isSubmitting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text( + '提交申请', + style: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w700, + height: 1.5, + letterSpacing: 0.24, + color: Colors.white, + ), + ), + ), + ), + ); + } +} diff --git a/frontend/mobile-app/lib/features/authorization/presentation/widgets/stickman_race_widget.dart b/frontend/mobile-app/lib/features/authorization/presentation/widgets/stickman_race_widget.dart new file mode 100644 index 00000000..a157cb4d --- /dev/null +++ b/frontend/mobile-app/lib/features/authorization/presentation/widgets/stickman_race_widget.dart @@ -0,0 +1,585 @@ +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; + +/// 火柴人排名数据模型 +class StickmanRankingData { + final String id; + final String nickname; + final String? avatarUrl; + final int completedCount; // 完成数量 + final int targetCount; // 目标数量 (省: 50000, 市: 10000) + final double monthlyEarnings; // 本月可结算收益 + final bool isCurrentUser; // 是否是当前用户 + + StickmanRankingData({ + required this.id, + required this.nickname, + this.avatarUrl, + required this.completedCount, + required this.targetCount, + required this.monthlyEarnings, + this.isCurrentUser = false, + }); + + /// 完成进度 (0.0 - 1.0) + double get progress => (completedCount / targetCount).clamp(0.0, 1.0); +} + +/// 授权类型 +enum AuthorizationRoleType { + province, // 省公司 (目标: 5万) + city, // 市公司 (目标: 1万) +} + +/// 火柴人赛跑排名组件 +class StickmanRaceWidget extends StatefulWidget { + final List rankings; + final AuthorizationRoleType roleType; + final String regionName; // 省/市名称 + + const StickmanRaceWidget({ + super.key, + required this.rankings, + required this.roleType, + required this.regionName, + }); + + @override + State createState() => _StickmanRaceWidgetState(); +} + +class _StickmanRaceWidgetState extends State + with TickerProviderStateMixin { + late AnimationController _bounceController; + + @override + void initState() { + super.initState(); + // 用于火柴人上下弹跳效果 + _bounceController = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + )..repeat(reverse: true); + } + + @override + void dispose() { + _bounceController.dispose(); + super.dispose(); + } + + /// 获取目标数量 + int get targetCount => + widget.roleType == AuthorizationRoleType.province ? 50000 : 10000; + + /// 获取目标文本 + String get targetText => + widget.roleType == AuthorizationRoleType.province ? '5万' : '1万'; + + @override + Widget build(BuildContext context) { + if (widget.rankings.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0x80FFFFFF), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0x33D4AF37), + width: 1, + ), + boxShadow: const [ + BoxShadow( + color: Color(0x1A000000), + blurRadius: 8, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题行 + _buildHeader(), + const SizedBox(height: 16), + + // 赛道区域 + _buildRaceTrack(), + const SizedBox(height: 12), + + // 进度条和目标 + _buildProgressBar(), + const SizedBox(height: 16), + + // 排名列表 + _buildRankingList(), + ], + ), + ); + } + + /// 构建标题 + Widget _buildHeader() { + final roleText = + widget.roleType == AuthorizationRoleType.province ? '省公司' : '市公司'; + return Row( + children: [ + Container( + width: 4, + height: 20, + decoration: BoxDecoration( + color: const Color(0xFFD4AF37), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + '${widget.regionName}$roleText排名', + style: const TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + const Spacer(), + Text( + '目标: $targetText棵', + style: const TextStyle( + fontSize: 12, + fontFamily: 'Inter', + color: Color(0xFF8B5A2B), + ), + ), + ], + ); + } + + /// 构建赛道区域 + Widget _buildRaceTrack() { + // 按完成数量排序 + final sortedRankings = List.from(widget.rankings) + ..sort((a, b) => b.completedCount.compareTo(a.completedCount)); + + return SizedBox( + height: 160, + child: Stack( + children: [ + // 背景赛道线 + Positioned.fill( + child: CustomPaint( + painter: _TrackPainter( + trackCount: sortedRankings.length, + ), + ), + ), + + // 终点红旗 + Positioned( + right: 8, + top: 0, + bottom: 40, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon( + Icons.flag, + color: Colors.red, + size: 32, + ), + SizedBox(height: 4), + Text( + '终点', + style: TextStyle( + fontSize: 10, + fontFamily: 'Inter', + color: Color(0xFF8B5A2B), + ), + ), + ], + ), + ), + + // 火柴人们 + ...sortedRankings.asMap().entries.map((entry) { + final index = entry.key; + final data = entry.value; + return _buildStickman(data, index, sortedRankings.length); + }), + ], + ), + ); + } + + /// 构建单个火柴人 + Widget _buildStickman(StickmanRankingData data, int rank, int total) { + // 计算水平位置 (根据进度) + final horizontalProgress = data.progress; + + // 计算垂直位置 (不同排名在不同跑道) + final trackHeight = 120.0 / total; + final verticalPosition = rank * trackHeight + 10; + + return AnimatedBuilder( + animation: _bounceController, + builder: (context, child) { + // 添加上下弹跳效果 + final bounce = _bounceController.value * 3; + + return Positioned( + left: 20 + (MediaQuery.of(context).size.width - 120) * horizontalProgress, + top: verticalPosition - bounce, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 完成数量标签 + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: data.isCurrentUser + ? const Color(0xFFD4AF37) + : const Color(0xFF8B5A2B), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${_formatNumber(data.completedCount)}棵', + style: const TextStyle( + fontSize: 9, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + const SizedBox(height: 2), + + // 火柴人动画 + SizedBox( + width: 40, + height: 50, + child: Lottie.asset( + 'assets/lottie/stickman_running.json', + fit: BoxFit.contain, + repeat: true, + ), + ), + + // 昵称标签 + Container( + constraints: const BoxConstraints(maxWidth: 60), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: data.isCurrentUser + ? const Color(0xFFD4AF37).withValues(alpha: 0.2) + : Colors.white.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(4), + border: data.isCurrentUser + ? Border.all(color: const Color(0xFFD4AF37), width: 1) + : null, + ), + child: Text( + data.nickname, + style: TextStyle( + fontSize: 9, + fontFamily: 'Inter', + fontWeight: + data.isCurrentUser ? FontWeight.w600 : FontWeight.w400, + color: data.isCurrentUser + ? const Color(0xFFD4AF37) + : const Color(0xFF5D4037), + ), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + }, + ); + } + + /// 构建进度条 + Widget _buildProgressBar() { + // 找出领先者的进度 + final maxProgress = widget.rankings.isEmpty + ? 0.0 + : widget.rankings + .map((r) => r.progress) + .reduce((a, b) => a > b ? a : b); + + return Column( + children: [ + // 进度条 + Container( + height: 8, + decoration: BoxDecoration( + color: const Color(0xFFE0E0E0), + borderRadius: BorderRadius.circular(4), + ), + child: Stack( + children: [ + // 进度填充 + FractionallySizedBox( + widthFactor: maxProgress, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFD4AF37), Color(0xFFF5D799)], + ), + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 4), + // 进度标签 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '0', + style: TextStyle( + fontSize: 10, + fontFamily: 'Inter', + color: Color(0xFF8B5A2B), + ), + ), + Text( + targetText, + style: const TextStyle( + fontSize: 10, + fontFamily: 'Inter', + color: Color(0xFF8B5A2B), + ), + ), + ], + ), + ], + ); + } + + /// 构建排名列表 + Widget _buildRankingList() { + // 按完成数量排序 + final sortedRankings = List.from(widget.rankings) + ..sort((a, b) => b.completedCount.compareTo(a.completedCount)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '排名详情', + style: TextStyle( + fontSize: 14, + fontFamily: 'Inter', + fontWeight: FontWeight.w500, + color: Color(0xFF5D4037), + ), + ), + const SizedBox(height: 8), + ...sortedRankings.asMap().entries.map((entry) { + final rank = entry.key + 1; + final data = entry.value; + return _buildRankingItem(rank, data); + }), + ], + ); + } + + /// 构建排名项 + Widget _buildRankingItem(int rank, StickmanRankingData data) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: data.isCurrentUser + ? const Color(0xFFD4AF37).withValues(alpha: 0.1) + : Colors.white.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + border: data.isCurrentUser + ? Border.all(color: const Color(0xFFD4AF37), width: 1) + : null, + ), + child: Row( + children: [ + // 排名 + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: _getRankColor(rank), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '$rank', + style: const TextStyle( + fontSize: 14, + fontFamily: 'Inter', + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(width: 12), + + // 昵称 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + data.nickname, + style: TextStyle( + fontSize: 14, + fontFamily: 'Inter', + fontWeight: data.isCurrentUser + ? FontWeight.w600 + : FontWeight.w500, + color: const Color(0xFF5D4037), + ), + ), + if (data.isCurrentUser) ...[ + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: const Color(0xFFD4AF37), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + '我', + style: TextStyle( + fontSize: 9, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + '本月收益: ${_formatCurrency(data.monthlyEarnings)} USDT', + style: const TextStyle( + fontSize: 11, + fontFamily: 'Inter', + color: Color(0xFF8B5A2B), + ), + ), + ], + ), + ), + + // 完成数量和进度 + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${_formatNumber(data.completedCount)}棵', + style: const TextStyle( + fontSize: 16, + fontFamily: 'Inter', + fontWeight: FontWeight.w700, + color: Color(0xFFD4AF37), + ), + ), + const SizedBox(height: 2), + Text( + '${(data.progress * 100).toStringAsFixed(1)}%', + style: const TextStyle( + fontSize: 11, + fontFamily: 'Inter', + color: Color(0xFF8B5A2B), + ), + ), + ], + ), + ], + ), + ); + } + + /// 获取排名颜色 + Color _getRankColor(int rank) { + switch (rank) { + case 1: + return const Color(0xFFFFD700); // 金色 + case 2: + return const Color(0xFFC0C0C0); // 银色 + case 3: + return const Color(0xFFCD7F32); // 铜色 + default: + return const Color(0xFF8B5A2B); + } + } + + /// 格式化数字 + String _formatNumber(int number) { + if (number >= 10000) { + return '${(number / 10000).toStringAsFixed(1)}万'; + } + return number.toString(); + } + + /// 格式化货币 + String _formatCurrency(double amount) { + if (amount >= 10000) { + return '${(amount / 10000).toStringAsFixed(2)}万'; + } + return amount.toStringAsFixed(2); + } +} + +/// 赛道绘制器 +class _TrackPainter extends CustomPainter { + final int trackCount; + + _TrackPainter({required this.trackCount}); + + @override + void paint(Canvas canvas, Size size) { + final dashedPaint = Paint() + ..color = const Color(0xFFBDBDBD) + ..strokeWidth = 1 + ..style = PaintingStyle.stroke; + + final trackHeight = (size.height - 40) / trackCount; + + for (int i = 0; i < trackCount; i++) { + final y = 20 + i * trackHeight + trackHeight / 2; + + // 绘制虚线跑道 + double startX = 0; + const dashWidth = 10.0; + const dashSpace = 5.0; + + while (startX < size.width) { + canvas.drawLine( + Offset(startX, y), + Offset(startX + dashWidth, y), + dashedPaint, + ); + startX += dashWidth + dashSpace; + } + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/frontend/mobile-app/lib/features/deposit/presentation/pages/deposit_usdt_page.dart b/frontend/mobile-app/lib/features/deposit/presentation/pages/deposit_usdt_page.dart index c5532a57..77e3d961 100644 --- a/frontend/mobile-app/lib/features/deposit/presentation/pages/deposit_usdt_page.dart +++ b/frontend/mobile-app/lib/features/deposit/presentation/pages/deposit_usdt_page.dart @@ -5,15 +5,9 @@ import 'package:go_router/go_router.dart'; import 'package:qr_flutter/qr_flutter.dart'; import '../../../../core/di/injection_container.dart'; -/// 网络类型枚举 -enum NetworkType { - kava, - bsc, - dst, -} - /// 充值 USDT 页面 -/// 显示充值二维码和地址,支持 KAVA 和 BSC 网络切换 +/// 显示用户ID(accountSequence)二维码作为充值地址 +/// 后端通过 accountSequence 映射到实际区块链地址 class DepositUsdtPage extends ConsumerStatefulWidget { const DepositUsdtPage({super.key}); @@ -22,60 +16,47 @@ class DepositUsdtPage extends ConsumerStatefulWidget { } class _DepositUsdtPageState extends ConsumerState { - /// 当前选中的网络 - NetworkType _selectedNetwork = NetworkType.kava; - /// 是否正在加载 bool _isLoading = true; /// USDT 余额 String _balance = '0.00'; - /// KAVA 网络充值地址 - String? _kavaAddress; - - /// BSC 网络充值地址 - String? _bscAddress; - - /// DST 网络充值地址 - String? _dstAddress; + /// 用户账户序列号(作为充值地址显示) + String? _accountSequence; @override void initState() { super.initState(); - _loadWalletData(); + _loadData(); } /// 错误信息 String? _errorMessage; - /// 加载钱包数据 - Future _loadWalletData() async { + /// 加载数据 + Future _loadData() async { try { setState(() { _isLoading = true; _errorMessage = null; }); - final depositService = ref.read(depositServiceProvider); + // 获取用户账户序列号 + final accountService = ref.read(accountServiceProvider); + final userSerialNum = await accountService.getUserSerialNum(); - // 获取充值地址 (验证后的安全地址) - final addressResponse = await depositService.getDepositAddresses(); - - if (!addressResponse.isValid) { - // 地址验证失败 + if (userSerialNum == null || userSerialNum.isEmpty) { if (mounted) { setState(() { - _errorMessage = addressResponse.message ?? '充值账户异常,请联系客服'; + _errorMessage = '无法获取用户信息,请重新登录'; _isLoading = false; }); } return; } - _kavaAddress = addressResponse.kavaAddress; - _bscAddress = addressResponse.bscAddress; - _dstAddress = addressResponse.dstAddress; + _accountSequence = userSerialNum; // 从 wallet-service 查询钱包余额 try { @@ -98,7 +79,7 @@ class _DepositUsdtPageState extends ConsumerState { } } } catch (e) { - debugPrint('加载钱包数据失败: $e'); + debugPrint('加载数据失败: $e'); if (mounted) { setState(() { _errorMessage = '加载失败,请重试'; @@ -108,35 +89,19 @@ class _DepositUsdtPageState extends ConsumerState { } } - /// 获取当前网络的充值地址 - String get _currentAddress { - switch (_selectedNetwork) { - case NetworkType.kava: - return _kavaAddress ?? ''; - case NetworkType.bsc: - return _bscAddress ?? ''; - case NetworkType.dst: - return _dstAddress ?? ''; - } - } - - /// 切换网络 - void _switchNetwork(NetworkType network) { - if (_selectedNetwork != network) { - setState(() { - _selectedNetwork = network; - }); - } + /// 获取充值地址(用户ID) + String get _depositAddress { + return _accountSequence ?? ''; } /// 复制地址到剪贴板 void _copyAddress() { - if (_currentAddress.isEmpty) return; + if (_depositAddress.isEmpty) return; - Clipboard.setData(ClipboardData(text: _currentAddress)); + Clipboard.setData(ClipboardData(text: _depositAddress)); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('充值地址已复制'), + content: Text('充值ID已复制'), backgroundColor: Color(0xFFD4AF37), duration: Duration(seconds: 2), ), @@ -192,9 +157,7 @@ class _DepositUsdtPageState extends ConsumerState { children: [ // 余额显示 _buildBalanceSection(), - // 网络切换 - _buildNetworkSwitch(), - const SizedBox(height: 8), + const SizedBox(height: 16), // 二维码卡片 _buildQrCodeCard(), // 警告提示 @@ -274,87 +237,6 @@ class _DepositUsdtPageState extends ConsumerState { ); } - /// 构建网络切换 - Widget _buildNetworkSwitch() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Container( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.all(4), - child: Row( - children: [ - // KAVA 网络按钮 - Expanded( - child: _buildNetworkButton( - label: 'KAVA', - network: NetworkType.kava, - isSelected: _selectedNetwork == NetworkType.kava, - ), - ), - // BSC 网络按钮(暂时隐藏) - // Expanded( - // child: _buildNetworkButton( - // label: 'BSC', - // network: NetworkType.bsc, - // isSelected: _selectedNetwork == NetworkType.bsc, - // ), - // ), - // DST 网络按钮(暂时隐藏) - // Expanded( - // child: _buildNetworkButton( - // label: 'DST', - // network: NetworkType.dst, - // isSelected: _selectedNetwork == NetworkType.dst, - // ), - // ), - ], - ), - ), - ); - } - - /// 构建网络按钮 - Widget _buildNetworkButton({ - required String label, - required NetworkType network, - required bool isSelected, - }) { - return GestureDetector( - onTap: () => _switchNetwork(network), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 9.5, horizontal: 12), - decoration: BoxDecoration( - color: isSelected ? const Color(0xFFD4AF37) : Colors.transparent, - borderRadius: BorderRadius.circular(8), - boxShadow: isSelected - ? const [ - BoxShadow( - color: Color(0x66D4AF37), - blurRadius: 3, - offset: Offset(0, 1), - ), - ] - : null, - ), - child: Center( - child: Text( - label, - style: TextStyle( - fontSize: 14, - fontFamily: 'Inter', - fontWeight: FontWeight.w500, - height: 1.5, - color: isSelected ? Colors.white : const Color(0xFF745D43), - ), - ), - ), - ), - ); - } - /// 构建二维码卡片 Widget _buildQrCodeCard() { return Container( @@ -446,7 +328,7 @@ class _DepositUsdtPageState extends ConsumerState { } // 地址为空 - if (_currentAddress.isEmpty) { + if (_depositAddress.isEmpty) { return Container( width: 192, height: 192, @@ -456,7 +338,7 @@ class _DepositUsdtPageState extends ConsumerState { ), child: const Center( child: Text( - '暂无充值地址', + '暂无充值ID', style: TextStyle( fontSize: 14, color: Color(0xFF5D4037), @@ -483,7 +365,7 @@ class _DepositUsdtPageState extends ConsumerState { child: ClipRRect( borderRadius: BorderRadius.circular(8), child: QrImageView( - data: _currentAddress, + data: _depositAddress, version: QrVersions.auto, size: 192, backgroundColor: Colors.white, @@ -505,9 +387,9 @@ class _DepositUsdtPageState extends ConsumerState { Widget _buildAddressInfo() { return Column( children: [ - // 充值地址标题 + // 充值ID标题 const Text( - '充值地址', + '充值ID', style: TextStyle( fontSize: 16, fontFamily: 'Inter', @@ -517,16 +399,18 @@ class _DepositUsdtPageState extends ConsumerState { ), ), const SizedBox(height: 12), - // 地址文本 + // ID文本 Container( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( - _isLoading ? '加载中...' : _currentAddress, + _isLoading ? '加载中...' : _depositAddress, style: TextStyle( - fontSize: 14, + fontSize: 20, fontFamily: 'Inter', + fontWeight: FontWeight.w600, height: 1.5, - color: const Color(0xFF5D4037).withValues(alpha: 0.8), + letterSpacing: 1.5, + color: const Color(0xFF5D4037).withValues(alpha: 0.9), ), textAlign: TextAlign.center, ), @@ -545,7 +429,7 @@ class _DepositUsdtPageState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Text( - '复制地址', + '复制ID', style: TextStyle( fontSize: 14, fontFamily: 'Inter', @@ -577,7 +461,7 @@ class _DepositUsdtPageState extends ConsumerState { return Container( padding: const EdgeInsets.fromLTRB(20, 16, 20, 48), child: Text( - '仅支持 绿积分,错充将无法追回', + '请使用此充值ID进行充值', style: TextStyle( fontSize: 12, fontFamily: 'Inter', diff --git a/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart b/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart index b75827fe..39780862 100644 --- a/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart +++ b/frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart @@ -19,6 +19,7 @@ import '../../../../routes/app_router.dart'; import '../../../auth/presentation/providers/auth_provider.dart'; import '../widgets/team_tree_widget.dart'; import '../widgets/stacked_cards_widget.dart'; +import '../../../authorization/presentation/widgets/stickman_race_widget.dart'; /// 个人中心页面 - 显示用户信息、社区数据、收益和设置 /// 包含用户资料、推荐信息、社区考核、收益领取等功能 @@ -103,6 +104,14 @@ class _ProfilePageState extends ConsumerState { int _provinceCompanyMonthlyTarget = 500; int _provinceCompanyMonthIndex = 0; + // 火柴人排名数据(省公司/市公司排名赛跑) + List _provinceRankings = []; + List _cityRankings = []; + String _provinceRegionName = ''; // 省名称 + String _cityRegionName = ''; // 市名称 + bool _isLoadingStickmanRanking = false; + bool _hasLoadedStickmanRanking = false; + // 收益数据(从 reward-service 直接获取) double _pendingUsdt = 0.0; double _pendingPower = 0.0; @@ -513,6 +522,9 @@ class _ProfilePageState extends ConsumerState { _hasProvinceCompanyAuth = false; } }); + + // 授权数据加载完成后,加载火柴人排名数据 + _loadStickmanRankingData(); } } catch (e, stackTrace) { debugPrint('[ProfilePage] 加载授权数据失败: $e'); @@ -533,6 +545,106 @@ class _ProfilePageState extends ConsumerState { } } + /// 加载火柴人排名数据 + Future _loadStickmanRankingData() async { + // 只有当用户有省公司或市公司授权时才加载 + if (!_hasAuthProvinceCompanyAuth && !_hasAuthCityCompanyAuth) { + return; + } + + if (_isLoadingStickmanRanking) return; + + setState(() { + _isLoadingStickmanRanking = true; + }); + + try { + debugPrint('[ProfilePage] 开始加载火柴人排名数据...'); + final authorizationService = ref.read(authorizationServiceProvider); + + // 获取当前月份 + final now = DateTime.now(); + final month = '${now.year}-${now.month.toString().padLeft(2, '0')}'; + + // 如果有省公司授权,加载省排名 + if (_hasAuthProvinceCompanyAuth && _authProvinceCompany != '--') { + try { + // 从省公司名称中提取省代码(假设格式为 "XX省" 或直接省名) + final provinceName = _authProvinceCompany; + _provinceRegionName = provinceName; + + final rankings = await authorizationService.getStickmanRanking( + month: month, + roleType: 'AUTH_PROVINCE_COMPANY', + regionCode: provinceName, + ); + + if (mounted) { + setState(() { + _provinceRankings = rankings.map((r) => StickmanRankingData( + id: r.id, + nickname: r.nickname, + avatarUrl: r.avatarUrl, + completedCount: r.completedCount, + targetCount: 50000, // 省公司目标5万 + monthlyEarnings: r.monthlyEarnings, + isCurrentUser: r.isCurrentUser, + )).toList(); + }); + } + debugPrint('[ProfilePage] 省公司排名加载成功: ${_provinceRankings.length}条'); + } catch (e) { + debugPrint('[ProfilePage] 加载省公司排名失败: $e'); + } + } + + // 如果有市公司授权,加载市排名 + if (_hasAuthCityCompanyAuth && _authCityCompany != '--') { + try { + final cityName = _authCityCompany; + _cityRegionName = cityName; + + final rankings = await authorizationService.getStickmanRanking( + month: month, + roleType: 'AUTH_CITY_COMPANY', + regionCode: cityName, + ); + + if (mounted) { + setState(() { + _cityRankings = rankings.map((r) => StickmanRankingData( + id: r.id, + nickname: r.nickname, + avatarUrl: r.avatarUrl, + completedCount: r.completedCount, + targetCount: 10000, // 市公司目标1万 + monthlyEarnings: r.monthlyEarnings, + isCurrentUser: r.isCurrentUser, + )).toList(); + }); + } + debugPrint('[ProfilePage] 市公司排名加载成功: ${_cityRankings.length}条'); + } catch (e) { + debugPrint('[ProfilePage] 加载市公司排名失败: $e'); + } + } + + if (mounted) { + setState(() { + _isLoadingStickmanRanking = false; + _hasLoadedStickmanRanking = true; + }); + } + } catch (e) { + debugPrint('[ProfilePage] 加载火柴人排名失败: $e'); + if (mounted) { + setState(() { + _isLoadingStickmanRanking = false; + }); + } + } + } + /// 加载通知未读数量 Future _loadUnreadNotificationCount() async { try { @@ -972,6 +1084,11 @@ class _ProfilePageState extends ConsumerState { context.push(RoutePaths.bindEmail); } + /// 自助申请授权 + void _goToAuthorizationApply() { + context.push(RoutePaths.authorizationApply); + } + /// 编辑资料 Future _goToEditProfile() async { final result = await context.push(RoutePaths.editProfile); @@ -1113,6 +1230,8 @@ class _ProfilePageState extends ConsumerState { // 用户头像和基本信息 _buildUserHeader(), const SizedBox(height: 16), + // 火柴人排名赛跑(省公司/市公司) + _buildStickmanRaceSection(), // 推荐人信息卡片(懒加载:推荐数据) VisibilityDetector( key: const Key('referral_section'), @@ -1292,6 +1411,71 @@ class _ProfilePageState extends ConsumerState { ); } + /// 构建火柴人赛跑排名区域 + Widget _buildStickmanRaceSection() { + // 如果没有省公司或市公司授权,不显示 + if (!_hasAuthProvinceCompanyAuth && !_hasAuthCityCompanyAuth) { + return const SizedBox.shrink(); + } + + // 如果正在加载,显示加载提示 + if (_isLoadingStickmanRanking && !_hasLoadedStickmanRanking) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0x80FFFFFF), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0x33D4AF37), + width: 1, + ), + ), + child: const Center( + child: Column( + children: [ + CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Color(0xFFD4AF37)), + ), + SizedBox(height: 8), + Text( + '加载排名数据...', + style: TextStyle( + fontSize: 14, + fontFamily: 'Inter', + color: Color(0xFF8B5A2B), + ), + ), + ], + ), + ), + ); + } + + return Column( + children: [ + // 省公司排名赛跑 + if (_hasAuthProvinceCompanyAuth && _provinceRankings.isNotEmpty) ...[ + StickmanRaceWidget( + rankings: _provinceRankings, + roleType: AuthorizationRoleType.province, + regionName: _provinceRegionName, + ), + const SizedBox(height: 16), + ], + // 市公司排名赛跑 + if (_hasAuthCityCompanyAuth && _cityRankings.isNotEmpty) ...[ + StickmanRaceWidget( + rankings: _cityRankings, + roleType: AuthorizationRoleType.city, + regionName: _cityRegionName, + ), + const SizedBox(height: 16), + ], + ], + ); + } + /// 构建头像内容(优先本地文件,其次网络URL,最后SVG) Widget _buildAvatarContent() { debugPrint('[ProfilePage] _buildAvatarContent() - 开始构建头像'); @@ -3530,11 +3714,12 @@ class _ProfilePageState extends ConsumerState { ), child: Column( children: [ - _buildSettingItem( - icon: Icons.verified_user, - title: '谷歌验证器', - onTap: _goToGoogleAuth, - ), + // 谷歌验证器功能暂时隐藏(功能保留) + // _buildSettingItem( + // icon: Icons.verified_user, + // title: '谷歌验证器', + // onTap: _goToGoogleAuth, + // ), _buildSettingItem( icon: Icons.lock, title: '修改登录密码', @@ -3545,6 +3730,11 @@ class _ProfilePageState extends ConsumerState { title: '绑定邮箱', onTap: _goToBindEmail, ), + _buildSettingItem( + icon: Icons.verified, + title: '自助申请授权', + onTap: _goToAuthorizationApply, + ), ], ), ); diff --git a/frontend/mobile-app/lib/features/trading/presentation/pages/trading_page.dart b/frontend/mobile-app/lib/features/trading/presentation/pages/trading_page.dart index 5852addf..4047c8a1 100644 --- a/frontend/mobile-app/lib/features/trading/presentation/pages/trading_page.dart +++ b/frontend/mobile-app/lib/features/trading/presentation/pages/trading_page.dart @@ -407,18 +407,19 @@ class _TradingPageState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ - const Text( - '结算的币种', - style: TextStyle( - fontSize: 14, - fontFamily: 'Inter', - fontWeight: FontWeight.w700, - height: 1.5, - letterSpacing: 0.21, - color: Color(0xCC8B5A2B), - ), - ), - const SizedBox(height: 12), + // "结算的币种" label 已隐藏 + // const Text( + // '结算的币种', + // style: TextStyle( + // fontSize: 14, + // fontFamily: 'Inter', + // fontWeight: FontWeight.w700, + // height: 1.5, + // letterSpacing: 0.21, + // color: Color(0xCC8B5A2B), + // ), + // ), + // const SizedBox(height: 12), Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( @@ -432,7 +433,7 @@ class _TradingPageState extends ConsumerState { // const SizedBox(width: 8), // _buildCurrencyChip(SettlementCurrency.og, 'OG'), // const SizedBox(width: 8), - _buildCurrencyChip(SettlementCurrency.usdt, 'RMB/CNY'), + _buildCurrencyChip(SettlementCurrency.usdt, 'RMB/CNY提现'), // const SizedBox(width: 8), // _buildCurrencyChip(SettlementCurrency.dst, 'DST'), ], @@ -583,7 +584,7 @@ class _TradingPageState extends ConsumerState { ), const SizedBox(width: 8), Text( - '提取', + '划转', style: TextStyle( fontSize: 16, fontFamily: 'Inter', diff --git a/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_usdt_page.dart b/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_usdt_page.dart index ad8063d1..a183382e 100644 --- a/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_usdt_page.dart +++ b/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_usdt_page.dart @@ -137,7 +137,7 @@ class _WithdrawUsdtPageState extends ConsumerState { // 验证地址格式 if (!_isValidAddress(address)) { - _showErrorSnackBar('请输入有效的钱包地址'); + _showErrorSnackBar('请输入有效的充值用户ID'); return; } @@ -176,8 +176,16 @@ class _WithdrawUsdtPageState extends ConsumerState { /// 验证地址格式 bool _isValidAddress(String address) { - // 简单的地址格式验证 - // KAVA 和 BSC 地址都是以 0x 开头的 42 位十六进制字符串 + // 支持两种格式: + // 1. accountSequence 格式:D + 日期(6位) + 序号(5位),共12位,如 D25121400005 + // 2. 区块链地址格式 + + // 检查是否为 accountSequence 格式 + if (_isAccountSequence(address)) { + return true; + } + + // 检查区块链地址格式 if (_selectedNetwork == WithdrawNetwork.kava) { // KAVA 地址格式:kava1... 或 0x... return address.startsWith('kava1') || @@ -188,6 +196,16 @@ class _WithdrawUsdtPageState extends ConsumerState { } } + /// 检查是否为 accountSequence 格式 + /// 格式:D + 日期(6位) + 序号(5位),共12位,如 D25121400005 + bool _isAccountSequence(String address) { + if (address.length != 12) return false; + if (!address.startsWith('D')) return false; + // 检查后面11位是否都是数字 + final numericPart = address.substring(1); + return RegExp(r'^\d{11}$').hasMatch(numericPart); + } + /// 显示错误提示 void _showErrorSnackBar(String message) { ScaffoldMessenger.of(context).showSnackBar( @@ -579,7 +597,7 @@ class _WithdrawUsdtPageState extends ConsumerState { decoration: InputDecoration( contentPadding: const EdgeInsets.all(16), border: InputBorder.none, - hintText: '请输入接收绿积分的地址', + hintText: '请输入充值用户ID', hintStyle: const TextStyle( fontSize: 14, fontFamily: 'Inter', @@ -836,7 +854,7 @@ class _WithdrawUsdtPageState extends ConsumerState { const SizedBox(height: 12), _buildNoticeItem('请确保接收地址正确,错误地址将导致资产丢失'), _buildNoticeItem('请选择正确的网络,不同网络之间不可互转'), - _buildNoticeItem('提取需要进行谷歌验证器验证'), + _buildNoticeItem('提取需要进行手机短信验证'), _buildNoticeItem('提取通常在 1-30 分钟内到账'), ], ), @@ -1013,7 +1031,7 @@ class _AddressQrScannerPageState extends State<_AddressQrScannerPage> { onPressed: () => Navigator.of(context).pop(), ), title: Text( - '扫描钱包地址', + '扫描充值ID', style: TextStyle( fontSize: 18.sp, color: Colors.white, @@ -1047,7 +1065,7 @@ class _AddressQrScannerPageState extends State<_AddressQrScannerPage> { child: Column( children: [ Text( - '将钱包地址二维码放入框内', + '将充值ID二维码放入框内', style: TextStyle( fontSize: 14.sp, color: Colors.white70, diff --git a/frontend/mobile-app/lib/routes/app_router.dart b/frontend/mobile-app/lib/routes/app_router.dart index b978b2d8..ce2e96db 100644 --- a/frontend/mobile-app/lib/routes/app_router.dart +++ b/frontend/mobile-app/lib/routes/app_router.dart @@ -9,6 +9,9 @@ import '../features/auth/presentation/pages/backup_mnemonic_page.dart'; import '../features/auth/presentation/pages/verify_mnemonic_page.dart'; import '../features/auth/presentation/pages/wallet_created_page.dart'; import '../features/auth/presentation/pages/import_mnemonic_page.dart'; +import '../features/auth/presentation/pages/phone_register_page.dart'; +import '../features/auth/presentation/pages/sms_verify_page.dart'; +import '../features/auth/presentation/pages/set_password_page.dart'; import '../features/home/presentation/pages/home_shell_page.dart'; import '../features/ranking/presentation/pages/ranking_page.dart'; import '../features/mining/presentation/pages/mining_page.dart'; @@ -23,6 +26,7 @@ import '../features/planting/presentation/pages/planting_location_page.dart'; import '../features/security/presentation/pages/google_auth_page.dart'; import '../features/security/presentation/pages/change_password_page.dart'; import '../features/security/presentation/pages/bind_email_page.dart'; +import '../features/authorization/presentation/pages/authorization_apply_page.dart'; import '../features/withdraw/presentation/pages/withdraw_usdt_page.dart'; import '../features/withdraw/presentation/pages/withdraw_confirm_page.dart'; import '../features/notification/presentation/pages/notification_inbox_page.dart'; @@ -129,7 +133,46 @@ final appRouterProvider = Provider((ref) { builder: (context, state) => const ImportMnemonicPage(), ), - // Backup Mnemonic (备份助记词 - 会调用 API 获取钱包信息) + // Phone Register (手机号注册) + GoRoute( + path: RoutePaths.phoneRegister, + name: RouteNames.phoneRegister, + builder: (context, state) { + final params = state.extra as PhoneRegisterParams?; + return PhoneRegisterPage( + inviterReferralCode: params?.inviterReferralCode, + ); + }, + ), + + // SMS Verify (短信验证码) + GoRoute( + path: RoutePaths.smsVerify, + name: RouteNames.smsVerify, + builder: (context, state) { + final params = state.extra as SmsVerifyParams; + return SmsVerifyPage( + phoneNumber: params.phoneNumber, + type: params.type, + inviterReferralCode: params.inviterReferralCode, + ); + }, + ), + + // Set Password (设置登录密码) + GoRoute( + path: RoutePaths.setPassword, + name: RouteNames.setPassword, + builder: (context, state) { + final params = state.extra as SetPasswordParams; + return SetPasswordPage( + userSerialNum: params.userSerialNum, + inviterReferralCode: params.inviterReferralCode, + ); + }, + ), + + // Backup Mnemonic (备份助记词 - 会调用 API 获取钱包信息,已屏蔽) GoRoute( path: RoutePaths.backupMnemonic, name: RouteNames.backupMnemonic, @@ -258,6 +301,13 @@ final appRouterProvider = Provider((ref) { builder: (context, state) => const BindEmailPage(), ), + // Authorization Apply Page (自助申请授权) + GoRoute( + path: RoutePaths.authorizationApply, + name: RouteNames.authorizationApply, + builder: (context, state) => const AuthorizationApplyPage(), + ), + // Ledger Detail Page (账本明细) GoRoute( path: RoutePaths.ledgerDetail, diff --git a/frontend/mobile-app/lib/routes/route_names.dart b/frontend/mobile-app/lib/routes/route_names.dart index 0b7daa63..90c5878c 100644 --- a/frontend/mobile-app/lib/routes/route_names.dart +++ b/frontend/mobile-app/lib/routes/route_names.dart @@ -11,6 +11,9 @@ class RouteNames { static const walletCreated = 'wallet-created'; static const importWallet = 'import-wallet'; static const importMnemonic = 'import-mnemonic'; + static const phoneRegister = 'phone-register'; + static const smsVerify = 'sms-verify'; + static const setPassword = 'set-password'; // Main tabs static const ranking = 'ranking'; @@ -31,6 +34,7 @@ class RouteNames { static const googleAuth = 'google-auth'; static const changePassword = 'change-password'; static const bindEmail = 'bind-email'; + static const authorizationApply = 'authorization-apply'; static const transactionHistory = 'transaction-history'; static const ledgerDetail = 'ledger-detail'; static const withdrawUsdt = 'withdraw-usdt'; diff --git a/frontend/mobile-app/lib/routes/route_paths.dart b/frontend/mobile-app/lib/routes/route_paths.dart index 180b9e59..1820a656 100644 --- a/frontend/mobile-app/lib/routes/route_paths.dart +++ b/frontend/mobile-app/lib/routes/route_paths.dart @@ -11,6 +11,9 @@ class RoutePaths { static const walletCreated = '/auth/wallet-created'; static const importWallet = '/auth/import'; static const importMnemonic = '/auth/import-mnemonic'; + static const phoneRegister = '/auth/phone-register'; + static const smsVerify = '/auth/sms-verify'; + static const setPassword = '/auth/set-password'; // Main tabs static const ranking = '/ranking'; @@ -31,6 +34,7 @@ class RoutePaths { static const googleAuth = '/security/google-auth'; static const changePassword = '/security/password'; static const bindEmail = '/security/email'; + static const authorizationApply = '/authorization/apply'; static const transactionHistory = '/trading/history'; static const ledgerDetail = '/trading/ledger'; static const withdrawUsdt = '/withdraw/usdt';