From e3d64e748571755df01d5f70f28705e0747d9a43 Mon Sep 17 00:00:00 2001 From: liangzai <2440983361@qq.com> Date: Fri, 11 Jul 2025 21:05:58 +0800 Subject: [PATCH] temp --- .cursor/rules/api.mdc | 436 ++++- .env.production | 53 - cmd/api/main.go | 4 + config.yaml | 5 + deployments/docker/postgresql.conf | 28 + docker-compose.dev.yml | 15 + docker-compose.prod.yml | 13 + docs/企业认证系统实施计划.md | 1606 +++++++++++++++++ docs/应用服务层改造TODO.md | 568 ++++++ docs/应用服务层改造计划.md | 340 ++++ docs/用户域实体优化总结.md | 221 +++ go.mod | 10 + go.sum | 39 + internal/app/app.go | 30 +- internal/config/README.md | 307 ++++ internal/config/config.go | 7 + internal/config/loader.go | 42 +- internal/domains/admin/dto/admin_dto.go | 178 ++ internal/domains/admin/entities/admin.go | 147 ++ .../domains/admin/handlers/admin_handler.go | 313 ++++ .../admin/repositories/admin_repository.go | 72 + .../repositories/gorm_admin_repository.go | 341 ++++ internal/domains/admin/routes/admin_routes.go | 29 + .../domains/admin/services/admin_service.go | 431 +++++ .../certification/dto/certification_dto.go | 110 ++ .../certification/dto/enterprise_dto.go | 108 ++ internal/domains/certification/dto/ocr_dto.go | 77 + .../certification/entities/certification.go | 179 ++ .../certification/entities/contract_record.go | 98 + .../certification/entities/enterprise.go | 66 + .../entities/face_verify_record.go | 89 + .../entities/license_upload_record.go | 70 + .../entities/notification_record.go | 127 ++ .../enums/certification_status.go | 88 + .../events/certification_events.go | 526 ++++++ .../certification/events/event_handlers.go | 489 +++++ .../handlers/certification_handler.go | 536 ++++++ .../gorm_certification_repository.go | 223 +++ .../gorm_contract_record_repository.go | 175 ++ .../gorm_enterprise_repository.go | 148 ++ .../gorm_face_verify_record_repository.go | 160 ++ .../gorm_license_upload_record_repository.go | 163 ++ .../certification/repositories/impl.go | 105 ++ .../routes/certification_routes.go | 62 + .../services/certification_service.go | 404 +++++ .../certification/services/state_machine.go | 287 +++ internal/domains/finance/dto/finance_dto.go | 140 ++ .../domains/finance/entities/user_secrets.go | 67 + internal/domains/finance/entities/wallet.go | 71 + .../finance/handlers/finance_handler.go | 336 ++++ .../repositories/finance_repository.go | 46 + .../repositories/gorm_finance_repository.go | 410 +++++ .../domains/finance/routes/finance_routes.go | 35 + .../finance/services/finance_service.go | 470 +++++ internal/domains/user/dto/user_dto.go | 6 + internal/domains/user/entities/sms_code.go | 287 ++- .../domains/user/entities/sms_code_test.go | 681 +++++++ internal/domains/user/entities/user.go | 232 ++- internal/domains/user/entities/user_test.go | 338 ++++ .../user/repositories/sms_code_repository.go | 28 + .../user/repositories/user_repository.go | 35 + .../domains/user/services/sms_code_service.go | 109 +- .../domains/user/services/user_service.go | 170 +- internal/shared/database/database.go | 6 +- internal/shared/http/validator.go | 303 ---- internal/shared/http/validator_zh.go | 282 ++- internal/shared/interfaces/http.go | 3 - .../notification/wechat_work_service.go | 515 ++++++ internal/shared/ocr/baidu_ocr_service.go | 548 ++++++ internal/shared/ocr/ocr_interface.go | 44 + internal/shared/sms/sms_service.go | 1 - .../shared/storage/qiniu_storage_service.go | 332 ++++ internal/shared/storage/storage_interface.go | 43 + scripts/set_timezone.sql | 13 + 74 files changed, 14379 insertions(+), 697 deletions(-) delete mode 100644 .env.production create mode 100644 deployments/docker/postgresql.conf create mode 100644 docs/企业认证系统实施计划.md create mode 100644 docs/应用服务层改造TODO.md create mode 100644 docs/应用服务层改造计划.md create mode 100644 docs/用户域实体优化总结.md create mode 100644 internal/config/README.md create mode 100644 internal/domains/admin/dto/admin_dto.go create mode 100644 internal/domains/admin/entities/admin.go create mode 100644 internal/domains/admin/handlers/admin_handler.go create mode 100644 internal/domains/admin/repositories/admin_repository.go create mode 100644 internal/domains/admin/repositories/gorm_admin_repository.go create mode 100644 internal/domains/admin/routes/admin_routes.go create mode 100644 internal/domains/admin/services/admin_service.go create mode 100644 internal/domains/certification/dto/certification_dto.go create mode 100644 internal/domains/certification/dto/enterprise_dto.go create mode 100644 internal/domains/certification/dto/ocr_dto.go create mode 100644 internal/domains/certification/entities/certification.go create mode 100644 internal/domains/certification/entities/contract_record.go create mode 100644 internal/domains/certification/entities/enterprise.go create mode 100644 internal/domains/certification/entities/face_verify_record.go create mode 100644 internal/domains/certification/entities/license_upload_record.go create mode 100644 internal/domains/certification/entities/notification_record.go create mode 100644 internal/domains/certification/enums/certification_status.go create mode 100644 internal/domains/certification/events/certification_events.go create mode 100644 internal/domains/certification/events/event_handlers.go create mode 100644 internal/domains/certification/handlers/certification_handler.go create mode 100644 internal/domains/certification/repositories/gorm_certification_repository.go create mode 100644 internal/domains/certification/repositories/gorm_contract_record_repository.go create mode 100644 internal/domains/certification/repositories/gorm_enterprise_repository.go create mode 100644 internal/domains/certification/repositories/gorm_face_verify_record_repository.go create mode 100644 internal/domains/certification/repositories/gorm_license_upload_record_repository.go create mode 100644 internal/domains/certification/repositories/impl.go create mode 100644 internal/domains/certification/routes/certification_routes.go create mode 100644 internal/domains/certification/services/certification_service.go create mode 100644 internal/domains/certification/services/state_machine.go create mode 100644 internal/domains/finance/dto/finance_dto.go create mode 100644 internal/domains/finance/entities/user_secrets.go create mode 100644 internal/domains/finance/entities/wallet.go create mode 100644 internal/domains/finance/handlers/finance_handler.go create mode 100644 internal/domains/finance/repositories/finance_repository.go create mode 100644 internal/domains/finance/repositories/gorm_finance_repository.go create mode 100644 internal/domains/finance/routes/finance_routes.go create mode 100644 internal/domains/finance/services/finance_service.go create mode 100644 internal/domains/user/entities/sms_code_test.go create mode 100644 internal/domains/user/entities/user_test.go delete mode 100644 internal/shared/http/validator.go create mode 100644 internal/shared/notification/wechat_work_service.go create mode 100644 internal/shared/ocr/baidu_ocr_service.go create mode 100644 internal/shared/ocr/ocr_interface.go create mode 100644 internal/shared/storage/qiniu_storage_service.go create mode 100644 internal/shared/storage/storage_interface.go create mode 100644 scripts/set_timezone.sql diff --git a/.cursor/rules/api.mdc b/.cursor/rules/api.mdc index f56538b..69954e3 100644 --- a/.cursor/rules/api.mdc +++ b/.cursor/rules/api.mdc @@ -1,8 +1,3 @@ ---- -description: -globs: -alwaysApply: false ---- # TYAPI Server API 开发规范 ## 🏗️ 项目架构概览 @@ -2525,3 +2520,434 @@ curl -X PUT http://localhost:8080/api/v1/users/me/password \ --- 遵循以上规范,可以确保 API 开发的一致性、可维护性和扩展性。 + +# TYAPI Server 企业级高级特性完整集成指南 + +## 🚀 **高级特性完整解决方案实施完成** + +本项目现已成功集成所有企业级高级特性,提供完整的可观测性、弹性恢复和分布式事务能力。所有组件均已通过编译验证和容器集成。 + +## 📊 **已完整集成的高级特性** + +### 1. **🔍 分布式链路追踪 (Distributed Tracing)** + +**技术栈**: OpenTelemetry + OTLP 导出器 +**支持后端**: Jaeger、Zipkin、Tempo、任何 OTLP 兼容系统 +**状态**: ✅ **完全集成** + +```yaml +# 配置示例 (config.yaml) +monitoring: + tracing_enabled: true + tracing_endpoint: "http://localhost:4317" # OTLP gRPC endpoint + sample_rate: 0.1 +``` + +**核心特性**: + +- ✅ HTTP 请求自动追踪中间件 +- ✅ 数据库操作追踪 +- ✅ 缓存操作追踪 +- ✅ 自定义业务操作追踪 +- ✅ TraceID/SpanID 自动传播 +- ✅ 生产级批处理导出 +- ✅ 容器生命周期管理 + +**使用示例**: + +```go +// 自动HTTP追踪(已在所有路由启用) +// 每个HTTP请求都会创建完整的追踪链路 + +// 自定义业务操作追踪 +ctx, span := tracer.StartSpan(ctx, "business.user_registration") +defer span.End() + +// 数据库操作追踪 +ctx, span := tracer.StartDBSpan(ctx, "SELECT", "users", "WHERE phone = ?") +defer span.End() + +// 缓存操作追踪 +ctx, span := tracer.StartCacheSpan(ctx, "GET", "user:cache:123") +defer span.End() +``` + +### 2. **📈 指标监控 (Metrics Collection)** + +**技术栈**: Prometheus + 自定义业务指标 +**导出端点**: `/metrics` (Prometheus 格式) +**状态**: ✅ **完全集成** + +**自动收集指标**: + +``` +# HTTP请求指标 +http_requests_total{method="GET",path="/api/v1/users",status="200"} 1523 +http_request_duration_seconds{method="GET",path="/api/v1/users"} 0.045 + +# 业务指标 +business_user_created_total{source="register"} 245 +business_user_login_total{platform="web",status="success"} 1892 +business_sms_sent_total{type="verification",provider="aliyun"} 456 + +# 系统指标 +active_users_total 1024 +database_connections_active 12 +cache_operations_total{operation="get",result="hit"} 8745 +``` + +**自定义指标注册**: + +```go +// 注册自定义计数器 +metrics.RegisterCounter("custom_events_total", "Custom events counter", []string{"event_type", "source"}) + +// 记录指标 +metrics.IncrementCounter("custom_events_total", map[string]string{ + "event_type": "user_action", + "source": "web", +}) +``` + +### 3. **🛡️ 弹性恢复 (Resilience)** + +#### 3.1 **熔断器 (Circuit Breaker)** + +**状态**: ✅ **完全集成** + +```go +// 使用熔断器保护服务调用 +err := circuitBreaker.Execute("user-service", func() error { + return userService.GetUserByID(ctx, userID) +}) + +// 批量执行保护 +err := circuitBreaker.ExecuteBatch("batch-operation", []func() error{ + func() error { return service1.Call() }, + func() error { return service2.Call() }, +}) +``` + +**特性**: + +- ✅ 故障阈值自动检测 +- ✅ 半开状态自动恢复 +- ✅ 实时状态监控 +- ✅ 多种失败策略 + +#### 3.2 **重试机制 (Retry)** + +**状态**: ✅ **完全集成** + +```go +// 快速重试(适用于网络抖动) +err := retryer.ExecuteWithQuickRetry(ctx, "api-call", func() error { + return httpClient.Call() +}) + +// 标准重试(适用于业务操作) +err := retryer.ExecuteWithStandardRetry(ctx, "db-operation", func() error { + return db.Save(data) +}) + +// 耐心重试(适用于最终一致性) +err := retryer.ExecuteWithPatientRetry(ctx, "sync-operation", func() error { + return syncService.Sync() +}) +``` + +### 4. **🔄 分布式事务 (Saga Pattern)** + +**状态**: ✅ **完全集成** + +```go +// 创建分布式事务 +saga := sagaManager.CreateSaga("user-registration-001", "用户注册流程") + +// 添加事务步骤 +saga.AddStep("create-user", + // 正向操作 + func(ctx context.Context, data interface{}) error { + return userService.CreateUser(ctx, data) + }, + // 补偿操作 + func(ctx context.Context, data interface{}) error { + return userService.DeleteUser(ctx, data) + }) + +saga.AddStep("send-welcome-email", + func(ctx context.Context, data interface{}) error { + return emailService.SendWelcome(ctx, data) + }, + func(ctx context.Context, data interface{}) error { + return emailService.SendCancellation(ctx, data) + }) + +// 执行事务 +err := saga.Execute(ctx, userData) +``` + +**支持特性**: + +- ✅ 自动补偿机制 +- ✅ 步骤重试策略 +- ✅ 事务状态跟踪 +- ✅ 并发控制 + +### 5. **🪝 事件钩子系统 (Hook System)** + +**状态**: ✅ **完全集成** + +```go +// 注册业务事件钩子 +hookSystem.OnUserCreated("metrics-collector", hooks.PriorityHigh, func(ctx context.Context, user interface{}) error { + return businessMetrics.RecordUserCreated("register") +}) + +hookSystem.OnUserCreated("welcome-email", hooks.PriorityNormal, func(ctx context.Context, user interface{}) error { + return emailService.SendWelcome(ctx, user) +}) + +// 触发事件(在业务代码中) +results, err := hookSystem.TriggerUserCreated(ctx, newUser) +``` + +**钩子类型**: + +- ✅ 同步钩子(阻塞执行) +- ✅ 异步钩子(后台执行) +- ✅ 优先级控制 +- ✅ 超时保护 +- ✅ 错误策略(继续/停止/收集) + +## 🏗️ **架构集成图** + +``` +┌─────────────────────────────────────────────────────────────┐ +│ HTTP 请求层 │ +├─────────────────────────────────────────────────────────────┤ +│ 追踪中间件 → 指标中间件 → 限流中间件 → 认证中间件 │ +├─────────────────────────────────────────────────────────────┤ +│ 业务处理层 │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Handler │ │ Service │ │ Repository │ │ +│ │ + 钩子 │ │ + 重试 │ │ + 熔断器 │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +├─────────────────────────────────────────────────────────────┤ +│ 基础设施层 │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ 链路追踪 │ │ 指标收集 │ │ 分布式事务 │ │ +│ │ (OpenTel) │ │(Prometheus) │ │ (Saga) │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 🛠️ **使用指南** + +### **启动验证** + +1. **编译验证**: + +```bash +go build ./cmd/api +``` + +2. **启动应用**: + +```bash +./api +``` + +3. **检查指标端点**: + +```bash +curl http://localhost:8080/metrics +``` + +4. **检查健康状态**: + +```bash +curl http://localhost:8080/health +``` + +### **配置示例** + +```yaml +# config.yaml 完整高级特性配置 +app: + name: "tyapi-server" + version: "1.0.0" + env: "production" + +monitoring: + # 链路追踪配置 + tracing_enabled: true + tracing_endpoint: "http://jaeger:4317" + sample_rate: 0.1 + + # 指标收集配置 + metrics_enabled: true + metrics_endpoint: "/metrics" + +resilience: + # 熔断器配置 + circuit_breaker_enabled: true + failure_threshold: 5 + timeout: 30s + + # 重试配置 + retry_enabled: true + max_retries: 3 + retry_delay: 100ms + +saga: + # 分布式事务配置 + default_timeout: 30s + max_retries: 3 + enable_persistence: false + +hooks: + # 钩子系统配置 + default_timeout: 30s + track_duration: true + error_strategy: "continue" +``` + +## 📋 **监控仪表板** + +### **推荐监控栈** + +1. **链路追踪**: Jaeger UI + + - 地址: `http://localhost:16686` + - 查看完整请求链路 + +2. **指标监控**: Prometheus + Grafana + + - Prometheus: `http://localhost:9090` + - Grafana: `http://localhost:3000` + +3. **应用指标**: 内置指标端点 + - 地址: `http://localhost:8080/metrics` + +### **关键监控指标** + +```yaml +# 告警规则建议 +groups: + - name: tyapi-server + rules: + - alert: HighErrorRate + expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.1 + + - alert: CircuitBreakerOpen + expr: circuit_breaker_state{state="open"} > 0 + + - alert: SagaFailure + expr: rate(saga_failed_total[5m]) > 0.05 + + - alert: HighLatency + expr: histogram_quantile(0.95, http_request_duration_seconds) > 1 +``` + +## 🔧 **性能优化建议** + +### **生产环境配置** + +1. **追踪采样率**: 建议设置为 0.01-0.1 (1%-10%) +2. **指标收集**: 启用所有核心指标,按需启用业务指标 +3. **熔断器阈值**: 根据服务 SLA 调整失败阈值 +4. **钩子超时**: 设置合理的钩子执行超时时间 + +### **扩展性考虑** + +1. **水平扩展**: 所有组件都支持多实例部署 +2. **状态无关**: 追踪和指标数据通过外部系统存储 +3. **配置热更新**: 支持运行时配置调整 + +## 🎯 **最佳实践** + +### **链路追踪** + +- 在关键业务操作中主动创建 Span +- 使用有意义的操作名称 +- 添加重要的标签和属性 + +### **指标收集** + +- 合理设置指标标签,避免高基数 +- 定期清理不再使用的指标 +- 使用直方图记录耗时分布 + +### **弹性设计** + +- 在外部服务调用时使用熔断器 +- 对瞬时失败使用重试机制 +- 设计优雅降级策略 + +### **事件钩子** + +- 保持钩子函数简单快速 +- 使用异步钩子处理耗时操作 +- 合理设置钩子优先级 + +## 🔍 **故障排查** + +### **常见问题** + +1. **追踪数据丢失** + + - 检查 OTLP 端点连接性 + - 确认采样率配置 + - 查看应用日志中的追踪错误 + +2. **指标不更新** + + - 验证 Prometheus 抓取配置 + - 检查指标端点可访问性 + - 确认指标注册成功 + +3. **熔断器异常触发** + - 检查失败阈值设置 + - 分析下游服务健康状态 + - 调整超时时间 + +## 🏆 **集成完成状态** + +| 特性模块 | 实现状态 | 容器集成 | 中间件 | 配置支持 | 文档完整度 | +| ---------- | -------- | -------- | ------------- | -------- | ---------- | +| 链路追踪 | ✅ 100% | ✅ 完成 | ✅ 已集成 | ✅ 完整 | ✅ 完整 | +| 指标监控 | ✅ 100% | ✅ 完成 | ✅ 已集成 | ✅ 完整 | ✅ 完整 | +| 熔断器 | ✅ 100% | ✅ 完成 | ⚠️ 手动集成 | ✅ 完整 | ✅ 完整 | +| 重试机制 | ✅ 100% | ✅ 完成 | ⚠️ 手动集成 | ✅ 完整 | ✅ 完整 | +| 分布式事务 | ✅ 100% | ✅ 完成 | ⚠️ 手动集成 | ✅ 完整 | ✅ 完整 | +| 钩子系统 | ✅ 100% | ✅ 完成 | ⚠️ 应用级集成 | ✅ 完整 | ✅ 完整 | + +## 🎉 **总结** + +TYAPI Server 现已完成所有企业级高级特性的完整集成: + +✅ **已完成的核心能力**: + +- 分布式链路追踪 (OpenTelemetry + OTLP) +- 全方位指标监控 (Prometheus + 业务指标) +- 多层次弹性恢复 (熔断器 + 重试机制) +- 分布式事务管理 (Saga 模式) +- 灵活事件钩子系统 + +✅ **生产就绪特性**: + +- 完整的容器依赖注入 +- 自动化中间件集成 +- 优雅的生命周期管理 +- 完善的配置系统 +- 详细的监控指标 + +✅ **开发体验**: + +- 编译零错误 +- 热插拔组件设计 +- 丰富的使用示例 +- 完整的故障排查指南 + +现在您的 TYAPI Server 已经具备了企业级产品的所有核心监控和弹性能力!🚀 diff --git a/.env.production b/.env.production deleted file mode 100644 index 896cb58..0000000 --- a/.env.production +++ /dev/null @@ -1,53 +0,0 @@ -# 生产环境配置模板 -# 复制此文件到服务器并重命名为 .env,然后修改相应的值 - -# 应用配置 -APP_VERSION=latest -APP_PORT=8080 - -# 数据库配置 (必须修改) -DB_USER=tyapi_user -DB_PASSWORD=your_secure_database_password_here -DB_NAME=tyapi_prod -DB_SSLMODE=require - -# Redis配置 (必须修改) -REDIS_PASSWORD=your_secure_redis_password_here - -# JWT配置 (必须修改) -JWT_SECRET=your_super_secure_jwt_secret_key_for_production_at_least_32_chars - -# 日志级别 -LOG_LEVEL=info - -# 端口配置 -NGINX_HTTP_PORT=80 -NGINX_HTTPS_PORT=443 -JAEGER_UI_PORT=16686 -PROMETHEUS_PORT=9090 -GRAFANA_PORT=3000 -MINIO_API_PORT=9000 -MINIO_CONSOLE_PORT=9001 -PGADMIN_PORT=5050 - -# Grafana 配置 -GRAFANA_ADMIN_USER=admin -GRAFANA_ADMIN_PASSWORD=your_secure_grafana_password_here - -# MinIO 配置 -MINIO_ROOT_USER=minioadmin -MINIO_ROOT_PASSWORD=your_secure_minio_password_here - -# pgAdmin 配置 -PGADMIN_EMAIL=admin@tyapi.com -PGADMIN_PASSWORD=your_secure_pgadmin_password_here - -# 短信服务配置 (必须修改) -SMS_ACCESS_KEY_ID=your_sms_access_key_id -SMS_ACCESS_KEY_SECRET=your_sms_access_key_secret -SMS_SIGN_NAME=your_sms_sign_name -SMS_TEMPLATE_CODE=your_sms_template_code - -# SSL证书路径 (如果使用HTTPS) -# SSL_CERT_PATH=/path/to/cert.pem -# SSL_KEY_PATH=/path/to/key.pem diff --git a/cmd/api/main.go b/cmd/api/main.go index caaab36..e3ab313 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "os" + "time" _ "tyapi-server/docs" // docs is generated by Swag CLI, you have to import it. "tyapi-server/internal/app" @@ -38,6 +39,9 @@ var ( ) func main() { + // 设置时区为北京时间 + time.Local = time.FixedZone("CST", 8*3600) + // 命令行参数 var ( showVersion = flag.Bool("version", false, "显示版本信息") diff --git a/config.yaml b/config.yaml index c033f94..6698d68 100644 --- a/config.yaml +++ b/config.yaml @@ -106,3 +106,8 @@ development: cors_allowed_origins: "http://localhost:3000,http://localhost:3001" cors_allowed_methods: "GET,POST,PUT,PATCH,DELETE,OPTIONS" cors_allowed_headers: "Origin,Content-Type,Accept,Authorization,X-Requested-With" + +# 企业微信配置 +wechat_work: + webhook_url: "" + secret: "" diff --git a/deployments/docker/postgresql.conf b/deployments/docker/postgresql.conf new file mode 100644 index 0000000..1019ba6 --- /dev/null +++ b/deployments/docker/postgresql.conf @@ -0,0 +1,28 @@ +# PostgreSQL配置文件 +# 时区设置 +timezone = 'Asia/Shanghai' +log_timezone = 'Asia/Shanghai' + +# 字符编码 +client_encoding = 'UTF8' + +# 连接设置 +max_connections = 100 +shared_buffers = 128MB + +# 日志设置 +log_destination = 'stderr' +logging_collector = on +log_directory = 'log' +log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' +log_rotation_age = 1d +log_rotation_size = 100MB + +# 性能设置 +effective_cache_size = 1GB +work_mem = 4MB +maintenance_work_mem = 64MB + +# 查询优化 +random_page_cost = 1.1 +effective_io_concurrency = 200 \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 83b3b04..fa9b2ac 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -8,11 +8,15 @@ services: POSTGRES_USER: postgres POSTGRES_PASSWORD: Pg9mX4kL8nW2rT5y POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C" + TZ: Asia/Shanghai + PGTZ: Asia/Shanghai ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql + - ./scripts/set_timezone.sql:/docker-entrypoint-initdb.d/set_timezone.sql + - ./deployments/docker/postgresql.conf:/etc/postgresql/postgresql.conf networks: - tyapi-network healthcheck: @@ -30,6 +34,8 @@ services: - redis_data:/data - ./deployments/docker/redis.conf:/usr/local/etc/redis/redis.conf command: redis-server /usr/local/etc/redis/redis.conf + environment: + TZ: Asia/Shanghai networks: - tyapi-network healthcheck: @@ -49,6 +55,8 @@ services: - "4317:4317" # OTLP gRPC receiver - "4318:4318" # OTLP HTTP receiver environment: + # 时区配置 + TZ: Asia/Shanghai # 启用OTLP接收器 COLLECTOR_OTLP_ENABLED: true # 配置内存存储 @@ -97,6 +105,8 @@ services: container_name: tyapi-prometheus ports: - "9090:9090" + environment: + TZ: Asia/Shanghai volumes: - ./deployments/docker/prometheus.yml:/etc/prometheus/prometheus.yml - prometheus_data:/prometheus @@ -116,6 +126,7 @@ services: ports: - "3000:3000" environment: + TZ: Asia/Shanghai GF_SECURITY_ADMIN_PASSWORD: Gf7nB3xM9cV6pQ2w volumes: - grafana_data:/var/lib/grafana @@ -131,6 +142,7 @@ services: - "9000:9000" - "9001:9001" environment: + TZ: Asia/Shanghai MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: Mn5oH8yK3bR7vX1z volumes: @@ -152,6 +164,8 @@ services: ports: - "1025:1025" # SMTP - "8025:8025" # Web UI + environment: + TZ: Asia/Shanghai networks: - tyapi-network @@ -160,6 +174,7 @@ services: image: dpage/pgadmin4:snapshot container_name: tyapi-pgadmin environment: + TZ: Asia/Shanghai PGADMIN_DEFAULT_EMAIL: admin@tyapi.com PGADMIN_DEFAULT_PASSWORD: Pa4dG9wF2sL6tN8u PGADMIN_CONFIG_SERVER_MODE: "True" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 71594ba..554d979 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -6,6 +6,8 @@ services: image: postgres:16.9 container_name: tyapi-postgres-prod environment: + TZ: Asia/Shanghai + PGTZ: Asia/Shanghai POSTGRES_DB: ${DB_NAME:-tyapi_prod} POSTGRES_USER: ${DB_USER:-tyapi_user} POSTGRES_PASSWORD: ${DB_PASSWORD} @@ -41,6 +43,7 @@ services: image: redis:8.0.2 container_name: tyapi-redis-prod environment: + TZ: Asia/Shanghai REDIS_PASSWORD: ${REDIS_PASSWORD} volumes: - redis_data:/data @@ -85,6 +88,9 @@ services: image: docker-registry.tianyuanapi.com/tyapi-server:${APP_VERSION:-latest} container_name: tyapi-app-prod environment: + # 时区配置 + TZ: Asia/Shanghai + # 环境设置 ENV: production @@ -158,6 +164,8 @@ services: - "4317:4317" # OTLP gRPC receiver - "4318:4318" # OTLP HTTP receiver environment: + # 时区配置 + TZ: Asia/Shanghai # 启用OTLP接收器 COLLECTOR_OTLP_ENABLED: true # 配置持久化存储 (生产环境建议使用Elasticsearch/Cassandra) @@ -258,6 +266,8 @@ services: container_name: tyapi-prometheus-prod ports: - "${PROMETHEUS_PORT:-9090}:9090" + environment: + TZ: Asia/Shanghai volumes: - ./deployments/docker/prometheus.yml:/etc/prometheus/prometheus.yml - prometheus_data:/prometheus @@ -302,6 +312,7 @@ services: ports: - "${GRAFANA_PORT:-3000}:3000" environment: + TZ: Asia/Shanghai GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-Gf7nB3xM9cV6pQ2w} GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} GF_INSTALL_PLUGINS: "grafana-clock-panel,grafana-simple-json-datasource" @@ -347,6 +358,7 @@ services: - "${MINIO_API_PORT:-9000}:9000" - "${MINIO_CONSOLE_PORT:-9001}:9001" environment: + TZ: Asia/Shanghai MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-Mn5oH8yK3bR7vX1z} MINIO_BROWSER_REDIRECT_URL: "http://localhost:9001" @@ -376,6 +388,7 @@ services: image: dpage/pgadmin4:8.15 container_name: tyapi-pgadmin-prod environment: + TZ: Asia/Shanghai PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@tyapi.com} PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-Pa4dG9wF2sL6tN8u} PGADMIN_CONFIG_SERVER_MODE: "True" diff --git a/docs/企业认证系统实施计划.md b/docs/企业认证系统实施计划.md new file mode 100644 index 0000000..43cec6c --- /dev/null +++ b/docs/企业认证系统实施计划.md @@ -0,0 +1,1606 @@ +# 企业认证系统实施计划 + +## 📋 需求概述 + +### 业务流程 + +用户注册后需要进行企业认证以使用平台核心功能,认证流程如下: + +1. **提交企业信息**:用户上传营业执照,系统通过百度 OCR 自动识别企业四要素 +2. **人脸识别**:法人进行人脸识别验证 +3. **申请电子合同**:系统生成申请记录 +4. **管理员审核**:管理员收到企业微信通知,审核后手工上传签署链接 +5. **签署合同**:用户收到短信通知,点击链接完成签署 +6. **认证完成**:系统为用户生成钱包和 Access Key + +### 企业四要素 + +- 企业名称 +- 统一信用代码 +- 企业法人姓名 +- 法人身份证号 + +## 📊 业务流程图 + +整个企业认证流程包含 10 个主要状态节点,涉及用户、系统、管理员和第三方服务的协同工作: + +### 流程说明 + +1. **用户操作阶段**(蓝色节点) + + - 用户上传营业执照图片(自动上传至七牛云并 OCR 识别) + - 确认或修改 OCR 识别的企业四要素信息 + - 进行阿里云人脸识别验证 + - 申请电子合同 + - 查看合同链接并签署电子合同 + +2. **系统自动处理**(紫色节点) + + - 创建认证申请记录 + - 七牛云文件存储和百度 OCR 识别 + - 将合同申请转为待审核状态 + - 确认合同签署状态 + - 生成钱包和 Access Key + +3. **管理员审核**(橙色节点) + + - 审核企业认证申请 + - **手工上传电子合同签署链接** + - 处理审核结果(通过/拒绝) + +4. **等待状态**(紫色节点) + + - 合同待审核状态(等待管理员处理) + - 合同已审核状态(等待用户签署) + +5. **第三方服务**(绿色节点) + + - 七牛云文件存储 + - 百度 OCR 识别营业执照 + - 阿里云 InitFaceVerify 人脸识别 + +6. **决策节点**(红色节点) + - 企业信息确认(OCR 成功与否都允许用户确认) + - 人脸识别结果验证 + - 管理员审核结果(通过/拒绝) + - 合同签署状态 + +### 关键特性 + +- **一体化处理**:营业执照上传、存储、OCR 识别一次完成 +- **容错性强**:OCR 识别失败不影响流程,用户可手动填写信息 +- **异步审核**:用户申请合同后,管理员异步审核并上传链接,用户无需实时等待 +- **手工上传**:管理员手工上传电子合同签署链接,确保合同的准确性和合规性 +- **状态分离**:合同申请、待审核、已审核(有链接)、已签署状态清晰分离 +- **重试机制**:人脸识别、合同签署都支持失败重试 +- **多渠道通知**:企业微信通知管理员新申请,短信通知用户合同就绪和认证完成 +- **状态追踪**:每个环节都有明确的状态标识和时间戳记录 +- **安全存储**:营业执照文件安全存储在七牛云,支持 CDN 加速访问 + +## 🏗️ 架构设计 + +### 新增域结构 + +``` +internal/domains/ +├── certification/ # 企业认证域 +│ ├── entities/ # 实体层 +│ │ ├── certification.go # 认证申请实体 +│ │ ├── enterprise.go # 企业信息实体 +│ │ └── contract.go # 电子合同实体 +│ ├── dto/ # 数据传输对象 +│ │ ├── certification_dto.go +│ │ ├── enterprise_dto.go +│ │ └── ocr_dto.go +│ ├── repositories/ # 仓储层 +│ │ ├── certification_repository.go +│ │ ├── enterprise_repository.go +│ │ └── contract_repository.go +│ ├── services/ # 服务层 +│ │ ├── certification_service.go +│ │ ├── enterprise_service.go +│ │ ├── ocr_service.go +│ │ └── face_verification_service.go +│ ├── handlers/ # 处理器层 +│ │ ├── certification_handler.go +│ │ └── admin_certification_handler.go +│ ├── routes/ # 路由层 +│ │ ├── certification_routes.go +│ │ └── admin_certification_routes.go +│ ├── events/ # 事件层 +│ │ └── certification_events.go +│ ├── enums/ # 枚举定义 +│ │ └── certification_status.go +│ └── migrations/ # 数据库迁移 +├── finance/ # 财务域(独立域) +│ ├── entities/ +│ │ └── wallet.go +│ ├── dto/ +│ │ └── wallet_dto.go +│ ├── repositories/ +│ │ └── wallet_repository.go +│ ├── services/ +│ │ └── wallet_service.go +│ ├── handlers/ +│ │ └── wallet_handler.go +│ ├── routes/ +│ │ └── wallet_routes.go +│ └── migrations/ +├── admin/ # 管理员域 +│ ├── entities/ +│ │ └── admin.go +│ ├── repositories/ +│ │ └── admin_repository.go +│ ├── services/ +│ │ └── admin_service.go +│ ├── handlers/ +│ │ └── admin_handler.go +│ └── routes/ +│ └── admin_routes.go +└── notification/ # 通知域(扩展) + ├── services/ + │ ├── notification_service.go + │ ├── wechat_work_service.go + │ └── enhanced_sms_service.go + └── providers/ + ├── wechat_work_provider.go + └── sms_template_provider.go +``` + +### 共享服务重构 + +``` +internal/shared/ +├── sms/ # 短信服务(现有,需扩展) +│ ├── sms_service.go # 基础短信服务 +│ ├── template_service.go # 短信模板服务(新增) +│ └── notification_sms.go # 通知类短信服务(新增) +├── ocr/ # OCR服务(新增) +│ ├── baidu_ocr_service.go # 百度OCR实现 +│ └── ocr_interface.go # OCR接口定义 +├── storage/ # 文件存储服务(新增) +│ ├── qiniu_storage_service.go # 七牛云存储实现 +│ └── storage_interface.go # 存储接口定义 +└── third_party/ # 第三方服务(新增) + ├── aliyun/ + │ ├── face_verify_service.go # 阿里云人脸识别 + │ └── sms_service.go # 阿里云短信服务 + ├── qiniu/ + │ └── qiniu_client.go # 七牛云客户端 + └── wechat_work/ + └── wechat_work_api.go +``` + +## 📊 核心实体设计 + +### 认证状态枚举 + +```go +type CertificationStatus string + +const ( + StatusPending CertificationStatus = "pending" // 待开始 + StatusEnterpriseSubmitted = "enterprise_submitted" // 企业信息已提交 + StatusOCRProcessed = "ocr_processed" // OCR识别完成 + StatusFaceVerified = "face_verified" // 人脸识别完成 + StatusContractApplied = "contract_applied" // 已申请合同 + StatusContractPending = "contract_pending" // 合同待审核 + StatusContractApproved = "contract_approved" // 合同已审核(有链接) + StatusContractSigned = "contract_signed" // 合同已签署 + StatusCompleted = "completed" // 认证完成 + StatusRejected = "rejected" // 已拒绝 +) +``` + +### 认证申请实体 + +```go +type Certification struct { + ID string `gorm:"primaryKey;type:varchar(36)"` + UserID string `gorm:"type:varchar(36);not null;index"` + EnterpriseID string `gorm:"type:varchar(36);index"` + Status CertificationStatus `gorm:"type:varchar(50);not null;index"` + + // 流程节点时间戳 + InfoSubmittedAt *time.Time `json:"info_submitted_at"` + FaceVerifiedAt *time.Time `json:"face_verified_at"` + ContractAppliedAt *time.Time `json:"contract_applied_at"` + ContractApprovedAt *time.Time `json:"contract_approved_at"` + ContractSignedAt *time.Time `json:"contract_signed_at"` + CompletedAt *time.Time `json:"completed_at"` + + // 审核信息 + AdminID *string `gorm:"type:varchar(36)"` + ApprovalNotes string `gorm:"type:text"` + RejectReason string `gorm:"type:text"` + + // 合同信息 + ContractURL string `gorm:"type:varchar(500)"` + SigningURL string `gorm:"type:varchar(500)"` + SignedAt *time.Time + + // OCR识别信息 + OCRRequestID string `gorm:"type:varchar(100)"` + OCRConfidence float64 `gorm:"type:decimal(5,2)"` + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` +} +``` + +### 企业信息实体 + +```go +type Enterprise struct { + ID string `gorm:"primaryKey;type:varchar(36)"` + CertificationID string `gorm:"type:varchar(36);not null"` + + // 企业四要素 + CompanyName string `gorm:"type:varchar(255);not null"` // 企业名称 + UnifiedSocialCode string `gorm:"type:varchar(50);not null"` // 统一社会信用代码 + LegalPersonName string `gorm:"type:varchar(100);not null"` // 法定代表人姓名 + LegalPersonID string `gorm:"type:varchar(50);not null"` // 法定代表人身份证号 + + // OCR识别结果 + BusinessLicenseURL string `gorm:"type:varchar(500);not null"` // 营业执照图片URL + OCRRawData string `gorm:"type:text"` // OCR原始返回数据 + OCRConfidence float64 `gorm:"type:decimal(5,2)"` // 识别置信度 + + // 验证状态 + IsOCRVerified bool `gorm:"default:false"` + IsFaceVerified bool `gorm:"default:false"` + VerificationData string `gorm:"type:text"` + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` +} +``` + +### 钱包实体(财务域) + +```go +type Wallet struct { + ID string `gorm:"primaryKey;type:varchar(36)"` + UserID string `gorm:"type:varchar(36);not null;uniqueIndex"` + + // 钱包状态 + IsActive bool `gorm:"default:true"` + Balance decimal.Decimal `gorm:"type:decimal(20,8);default:0"` + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` +} +``` + +### 用户密钥实体(财务域) + +```go +type UserSecrets struct { + ID string `gorm:"primaryKey;type:varchar(36)"` + UserID string `gorm:"type:varchar(36);not null;uniqueIndex"` + AccessID string `gorm:"type:varchar(100);not null;uniqueIndex"` + AccessKey string `gorm:"type:varchar(255);not null"` + + // 密钥状态 + IsActive bool `gorm:"default:true"` + LastUsedAt *time.Time + ExpiresAt *time.Time + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` +} +``` + +### 营业执照上传记录实体 + +```go +type LicenseUploadRecord struct { + ID string `gorm:"primaryKey;type:varchar(36)"` + CertificationID string `gorm:"type:varchar(36);not null;index"` + UserID string `gorm:"type:varchar(36);not null;index"` + + // 文件信息 + OriginalFileName string `gorm:"type:varchar(255);not null"` + FileSize int64 `gorm:"not null"` + FileType string `gorm:"type:varchar(50);not null"` + FileURL string `gorm:"type:varchar(500);not null"` + QiNiuKey string `gorm:"type:varchar(255);not null"` + + // OCR处理结果 + OCRProcessed bool `gorm:"default:false"` + OCRSuccess bool `gorm:"default:false"` + OCRConfidence float64 `gorm:"type:decimal(5,2)"` + OCRRawData string `gorm:"type:text"` + OCRErrorMessage string `gorm:"type:varchar(500)"` + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` +} +``` + +### 人脸识别记录实体 + +```go +type FaceVerifyRecord struct { + ID string `gorm:"primaryKey;type:varchar(36)"` + CertificationID string `gorm:"type:varchar(36);not null;index"` + UserID string `gorm:"type:varchar(36);not null;index"` + + // 阿里云人脸识别信息 + CertifyID string `gorm:"type:varchar(100);not null"` + VerifyURL string `gorm:"type:varchar(500)"` + ReturnURL string `gorm:"type:varchar(500)"` + + // 身份信息 + RealName string `gorm:"type:varchar(100);not null"` + IDCardNumber string `gorm:"type:varchar(50);not null"` + + // 验证结果 + Status string `gorm:"type:varchar(50);not null"` // PROCESSING, SUCCESS, FAIL + ResultCode string `gorm:"type:varchar(50)"` + ResultMessage string `gorm:"type:varchar(500)"` + VerifyScore float64 `gorm:"type:decimal(5,2)"` + + // 时间信息 + InitiatedAt time.Time `gorm:"autoCreateTime"` + CompletedAt *time.Time + ExpiresAt time.Time `gorm:"not null"` + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` +} +``` + +### 通知记录实体 + +```go +type NotificationRecord struct { + ID string `gorm:"primaryKey;type:varchar(36)"` + CertificationID string `gorm:"type:varchar(36);index"` + UserID string `gorm:"type:varchar(36);index"` + + // 通知类型和渠道 + NotificationType string `gorm:"type:varchar(50);not null"` // SMS, WECHAT_WORK, EMAIL + NotificationScene string `gorm:"type:varchar(50);not null"` // ADMIN_NEW_APPLICATION, USER_CONTRACT_READY, etc. + + // 接收方信息 + Recipient string `gorm:"type:varchar(255);not null"` + + // 消息内容 + Title string `gorm:"type:varchar(255)"` + Content string `gorm:"type:text;not null"` + TemplateID string `gorm:"type:varchar(100)"` + TemplateParams string `gorm:"type:text"` // JSON格式 + + // 发送状态 + Status string `gorm:"type:varchar(50);not null"` // PENDING, SENT, FAILED + ErrorMessage string `gorm:"type:varchar(500)"` + SentAt *time.Time + RetryCount int `gorm:"default:0"` + MaxRetryCount int `gorm:"default:3"` + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` +} +``` + +### 合同记录实体 + +```go +type ContractRecord struct { + ID string `gorm:"primaryKey;type:varchar(36)"` + CertificationID string `gorm:"type:varchar(36);not null;index"` + UserID string `gorm:"type:varchar(36);not null;index"` + AdminID string `gorm:"type:varchar(36);index"` + + // 合同信息 + ContractType string `gorm:"type:varchar(50);not null"` // ENTERPRISE_CERTIFICATION + ContractURL string `gorm:"type:varchar(500)"` + SigningURL string `gorm:"type:varchar(500)"` + + // 签署信息 + SignatureData string `gorm:"type:text"` + SignedAt *time.Time + ClientIP string `gorm:"type:varchar(50)"` + UserAgent string `gorm:"type:varchar(500)"` + + // 状态信息 + Status string `gorm:"type:varchar(50);not null"` // PENDING, APPROVED, SIGNED, EXPIRED + ApprovalNotes string `gorm:"type:text"` + RejectReason string `gorm:"type:text"` + ExpiresAt *time.Time + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` +} +``` + +## 🔄 状态管理设计 + +### 认证状态枚举扩展 + +```go +type CertificationStatus string + +const ( + // 主流程状态 + StatusPending CertificationStatus = "pending" // 待开始 + StatusInfoSubmitted = "info_submitted" // 企业信息已提交 + StatusFaceVerified = "face_verified" // 人脸识别完成 + StatusContractApplied = "contract_applied" // 已申请合同 + StatusContractPending = "contract_pending" // 合同待审核 + StatusContractApproved = "contract_approved" // 合同已审核(有链接) + StatusContractSigned = "contract_signed" // 合同已签署 + StatusCompleted = "completed" // 认证完成 + + // 失败和重试状态 + StatusFaceFailed = "face_failed" // 人脸识别失败 + StatusSignFailed = "sign_failed" // 签署失败 + StatusRejected = "rejected" // 已拒绝 +) +``` + +### 状态转换规则 + +```go +type StateTransition struct { + From CertificationStatus + To CertificationStatus + Action string + AllowUser bool // 是否允许用户操作 + AllowAdmin bool // 是否允许管理员操作 +} + +var stateTransitions = []StateTransition{ + // 正常流程转换 + {StatusPending, StatusInfoSubmitted, "submit_info", true, false}, + {StatusInfoSubmitted, StatusFaceVerified, "face_verify", true, false}, + {StatusFaceVerified, StatusContractApplied, "apply_contract", true, false}, + {StatusContractApplied, StatusContractPending, "system_process", false, false}, // 系统自动 + {StatusContractPending, StatusContractApproved, "admin_approve", false, true}, + {StatusContractApproved, StatusContractSigned, "user_sign", true, false}, + {StatusContractSigned, StatusCompleted, "system_complete", false, false}, // 系统自动 + + // 失败和重试转换 + {StatusInfoSubmitted, StatusFaceFailed, "face_fail", false, false}, + {StatusFaceFailed, StatusInfoSubmitted, "retry_face", true, false}, + {StatusContractPending, StatusRejected, "admin_reject", false, true}, + {StatusRejected, StatusInfoSubmitted, "restart_process", true, false}, + {StatusContractApproved, StatusSignFailed, "sign_fail", false, false}, + {StatusSignFailed, StatusContractApproved, "retry_sign", true, false}, +} +``` + +### 状态管理服务 + +```go +type CertificationStateMachine struct { + transitions map[CertificationStatus][]CertificationStatus + repo *CertificationRepository + logger *zap.Logger +} + +func NewCertificationStateMachine(repo *CertificationRepository, logger *zap.Logger) *CertificationStateMachine { + sm := &CertificationStateMachine{ + transitions: make(map[CertificationStatus][]CertificationStatus), + repo: repo, + logger: logger, + } + + // 构建状态转换映射 + for _, transition := range stateTransitions { + sm.transitions[transition.From] = append(sm.transitions[transition.From], transition.To) + } + + return sm +} + +func (sm *CertificationStateMachine) CanTransition(from, to CertificationStatus) bool { + allowedStates, exists := sm.transitions[from] + if !exists { + return false + } + + for _, allowedState := range allowedStates { + if allowedState == to { + return true + } + } + return false +} + +func (sm *CertificationStateMachine) TransitionTo(ctx context.Context, certificationID string, newStatus CertificationStatus, operatorID string, notes string) error { + // 获取当前认证记录 + certification, err := sm.repo.GetByID(ctx, certificationID) + if err != nil { + return fmt.Errorf("获取认证记录失败: %w", err) + } + + // 检查状态转换是否合法 + if !sm.CanTransition(certification.Status, newStatus) { + return fmt.Errorf("不允许从状态 %s 转换到 %s", certification.Status, newStatus) + } + + // 更新状态和时间戳 + oldStatus := certification.Status + certification.Status = newStatus + sm.updateTimestamp(certification, newStatus) + + // 记录操作信息 + if operatorID != "" { + certification.AdminID = &operatorID + } + if notes != "" { + certification.ApprovalNotes = notes + } + + // 保存更新 + if err := sm.repo.Update(ctx, certification); err != nil { + return fmt.Errorf("更新认证状态失败: %w", err) + } + + sm.logger.Info("认证状态已更新", + zap.String("certification_id", certificationID), + zap.String("from_status", string(oldStatus)), + zap.String("to_status", string(newStatus)), + zap.String("operator_id", operatorID)) + + return nil +} + +func (sm *CertificationStateMachine) updateTimestamp(cert *entities.Certification, status CertificationStatus) { + now := time.Now() + switch status { + case StatusInfoSubmitted: + cert.InfoSubmittedAt = &now + case StatusFaceVerified: + cert.FaceVerifiedAt = &now + case StatusContractApplied: + cert.ContractAppliedAt = &now + case StatusContractApproved: + cert.ContractApprovedAt = &now + case StatusContractSigned: + cert.ContractSignedAt = &now + case StatusCompleted: + cert.CompletedAt = &now + } +} + +func (sm *CertificationStateMachine) GetNextAllowedStates(currentStatus CertificationStatus) []CertificationStatus { + return sm.transitions[currentStatus] +} + +func (sm *CertificationStateMachine) GetRetryAction(status CertificationStatus) *string { + retryMap := map[CertificationStatus]string{ + StatusFaceFailed: "重新进行人脸识别", + StatusSignFailed: "重新签署合同", + StatusRejected: "重新提交申请", + } + + if action, exists := retryMap[status]; exists { + return &action + } + return nil +} +``` + +## 📱 分阶段接口设计 + +### 阶段 1: 上传营业执照(前端交互) + +```http +POST /api/v1/certification/upload-license +Content-Type: multipart/form-data + +{ + "business_license": "file", // 营业执照图片文件 + "user_id": "string" // 用户ID +} +``` + +**响应:** + +```json +{ + "code": 200, + "message": "营业执照上传成功", + "data": { + "upload_record_id": "upload_123456", + "license_url": "https://qiniu.example.com/licenses/cert_123456.jpg", + "ocr_result": { + "success": true, + "confidence": 95.5, + "enterprise_info": { + "company_name": "示例科技有限公司", + "unified_social_code": "91110000123456789X", + "legal_person_name": "张三", + "legal_person_id": "110101199001011234" + } + } + } +} +``` + +**OCR 失败响应:** + +```json +{ + "code": 200, + "message": "营业执照上传成功,OCR识别失败", + "data": { + "upload_record_id": "upload_123456", + "license_url": "https://qiniu.example.com/licenses/cert_123456.jpg", + "ocr_result": { + "success": false, + "error": "图片模糊,无法识别企业信息", + "enterprise_info": { + "company_name": "", + "unified_social_code": "", + "legal_person_name": "", + "legal_person_id": "" + } + } + } +} +``` + +### 阶段 2: 提交企业信息 (pending → info_submitted) + +```http +POST /api/v1/certification/submit-enterprise-info +Content-Type: application/json + +{ + "upload_record_id": "upload_123456", + "company_name": "示例科技有限公司", + "unified_social_code": "91110000123456789X", + "legal_person_name": "张三", + "legal_person_id": "110101199001011234" +} +``` + +**信息提交成功:** + +```json +{ + "code": 200, + "message": "企业信息提交成功", + "data": { + "certification_id": "cert_123456", + "status": "info_submitted", + "next_step": "进行人脸识别", + "next_action": "face_verify" + } +} +``` + +### 阶段 3: 人脸识别 (info_submitted → face_verified/face_failed) + +```http +POST /api/v1/certification/{certification_id}/face-verify +Content-Type: application/json + +{ + "legal_person_name": "张三", + "legal_person_id": "110101199001011234", + "return_url": "https://yourdomain.com/certification/face-result" +} +``` + +**人脸识别初始化成功:** + +```json +{ + "code": 200, + "message": "人脸识别初始化成功", + "data": { + "certification_id": "cert_123456", + "certify_id": "face_certify_789", + "verify_url": "https://cloudauth.aliyun.com/web/verify?token=xxx", + "status": "face_processing", + "next_step": "请在30分钟内完成人脸识别", + "expire_time": "2024-01-15T10:30:00Z" + } +} +``` + +**人脸识别结果查询:** + +```http +GET /api/v1/certification/{certification_id}/face-result +``` + +**人脸识别成功:** + +```json +{ + "code": 200, + "message": "人脸识别成功", + "data": { + "certification_id": "cert_123456", + "status": "face_verified", + "verify_result": "通过", + "next_step": "申请电子合同", + "next_action": "apply_contract" + } +} +``` + +### 阶段 4: 申请电子合同 (face_verified → contract_applied → contract_pending) + +```http +POST /api/v1/certification/{certification_id}/apply-contract +Content-Type: application/json + +{ + "contact_phone": "13800138000", + "contact_email": "example@company.com" +} +``` + +**申请成功:** + +```json +{ + "code": 200, + "message": "电子合同申请已提交", + "data": { + "certification_id": "cert_123456", + "status": "contract_applied", + "next_step": "系统正在处理申请", + "estimated_time": "1-3个工作日" + } +} +``` + +**系统自动转换状态查询:** + +```http +GET /api/v1/certification/{certification_id}/status +``` + +**转换为待审核状态:** + +```json +{ + "code": 200, + "message": "合同申请已进入审核队列", + "data": { + "certification_id": "cert_123456", + "status": "contract_pending", + "next_step": "等待管理员审核并上传合同链接", + "estimated_time": "1-3个工作日", + "notification": "已通过企业微信通知管理员,审核结果将通过短信通知您" + } +} +``` + +### 阶段 5: 管理员审核和合同链接上传 (contract_pending → contract_approved/rejected) + +**管理员获取待审核列表:** + +```http +GET /api/v1/admin/certifications?status=contract_pending +``` + +**管理员审核通过并上传合同链接:** + +```http +POST /api/v1/admin/certifications/{certification_id}/approve +Content-Type: application/json + +{ + "admin_id": "admin_123", + "contract_signing_url": "https://contract-platform.com/sign/contract_456", + "approval_notes": "企业信息核实无误,准予签署合同", + "expire_hours": 72 +} +``` + +**审核通过响应:** + +```json +{ + "code": 200, + "message": "审核通过,合同链接已上传", + "data": { + "certification_id": "cert_123456", + "status": "contract_approved", + "signing_url": "https://contract-platform.com/sign/contract_456", + "expire_time": "2024-01-18T15:00:00Z", + "next_step": "已通过短信通知用户签署合同" + } +} +``` + +**管理员拒绝申请:** + +```http +POST /api/v1/admin/certifications/{certification_id}/reject +Content-Type: application/json + +{ + "admin_id": "admin_123", + "reject_reason": "企业信息与工商数据不符,请重新提交", + "allow_retry": true +} +``` + +**拒绝响应:** + +```json +{ + "code": 200, + "message": "申请已拒绝", + "data": { + "certification_id": "cert_123456", + "status": "rejected", + "reject_reason": "企业信息与工商数据不符,请重新提交", + "next_step": "已通过短信通知用户重新申请" + } +} +``` + +### 阶段 6: 用户查看合同链接并签署 (contract_approved → contract_signed/sign_failed) + +**用户查询状态(收到短信通知后):** + +```http +GET /api/v1/certification/{certification_id}/status +``` + +**用户看到合同链接:** + +```json +{ + "code": 200, + "message": "合同已准备就绪", + "data": { + "certification_id": "cert_123456", + "status": "contract_approved", + "contract_info": { + "signing_url": "https://contract-platform.com/sign/contract_456", + "expire_time": "2024-01-18T15:00:00Z", + "remaining_hours": 48 + }, + "next_step": "请点击链接签署电子合同", + "next_action": "sign_contract" + } +} +``` + +### 阶段 7: 用户签署合同 (contract_approved → contract_signed/sign_failed) + +```http +POST /api/v1/certification/{certification_id}/sign-contract +Content-Type: application/json + +{ + "signature_data": "base64_signature_string", + "sign_timestamp": "2024-01-16T14:30:00Z", + "client_ip": "192.168.1.100" +} +``` + +**签署成功:** + +```json +{ + "code": 200, + "message": "合同签署成功", + "data": { + "certification_id": "cert_123456", + "status": "contract_signed", + "signed_at": "2024-01-16T14:30:00Z", + "next_step": "正在生成钱包账户", + "estimated_completion": "2-5分钟" + } +} +``` + +### 阶段 8: 认证完成 (contract_signed → completed) + +**系统自动完成,用户查询最终状态:** + +```http +GET /api/v1/certification/{certification_id}/final-result +``` + +**认证完成响应:** + +```json +{ + "code": 200, + "message": "企业认证已完成", + "data": { + "certification_id": "cert_123456", + "status": "completed", + "completed_at": "2024-01-16T14:35:00Z", + "wallet_info": { + "wallet_id": "wallet_789", + "balance": "0.00", + "is_active": true + }, + "user_secrets": { + "access_id": "AK_123456789", + "access_key": "SK_abcdef123456", // 加密显示 + "is_active": true + }, + "certificate_url": "https://cdn.example.com/certificates/cert_123456.pdf" + } +} +``` + +## 🔧 状态查询统一接口 + +```http +GET /api/v1/certification/status?user_id={user_id} +``` + +**通用状态响应格式:** + +```json +{ + "code": 200, + "message": "状态查询成功", + "data": { + "certification_id": "cert_123456", + "user_id": "user_123", + "current_status": "face_verified", + "current_status_name": "人脸识别完成", + "progress": { + "total_steps": 8, + "completed_steps": 4, + "percentage": 50 + }, + "timeline": [ + { + "status": "pending", + "status_name": "待开始", + "completed_at": "2024-01-15T09:00:00Z", + "is_completed": true + }, + { + "status": "info_submitted", + "status_name": "企业信息已提交", + "completed_at": "2024-01-15T09:05:00Z", + "is_completed": true + }, + { + "status": "face_verified", + "status_name": "人脸识别完成", + "completed_at": "2024-01-15T09:15:00Z", + "is_completed": true + }, + { + "status": "contract_applied", + "status_name": "已申请合同", + "completed_at": "2024-01-15T09:20:00Z", + "is_completed": true + }, + { + "status": "contract_pending", + "status_name": "合同待审核", + "completed_at": null, + "is_completed": false, + "is_current": true + } + ], + "next_action": { + "action": "apply_contract", + "action_name": "申请电子合同", + "description": "请点击申请电子合同按钮继续", + "can_retry": false + }, + "retry_info": null, + "estimated_completion": "2024-01-18T17:00:00Z" + } +} +``` + +## 🔄 重试机制接口 + +**通用重试接口:** + +```http +POST /api/v1/certification/{certification_id}/retry +Content-Type: application/json + +{ + "retry_type": "face_failed|sign_failed|rejected", + "additional_data": {} // 根据重试类型提供额外数据 +} +``` + +**重试响应:** + +```json +{ + "code": 200, + "message": "重试请求已处理", + "data": { + "certification_id": "cert_123456", + "old_status": "face_failed", + "new_status": "info_submitted", + "retry_count": 2, + "max_retry_count": 3, + "next_step": "请重新进行人脸识别" + } +} +``` + +## 🔧 核心服务设计 + +### 企业认证服务 + +```go +type CertificationService struct { + // 依赖注入 + repo *CertificationRepository + enterpriseRepo *EnterpriseRepository + ocrService *OCRService + faceVerificationSvc *AliYunFaceVerifyService + notificationService *NotificationService + walletService *WalletService + eventBus interfaces.EventBus + logger *zap.Logger +} + +// 核心业务方法 +func (s *CertificationService) SubmitBusinessLicense(ctx context.Context, userID string, licenseImageURL string) (*dto.CertificationResponse, error) +func (s *CertificationService) ConfirmEnterpriseInfo(ctx context.Context, certificationID string, req *dto.ConfirmEnterpriseInfoRequest) error +func (s *CertificationService) PerformFaceVerification(ctx context.Context, certificationID string, req *dto.FaceVerificationRequest) error +func (s *CertificationService) ApplyForContract(ctx context.Context, certificationID string) error +func (s *CertificationService) SignContract(ctx context.Context, certificationID string, signatureData string) error +func (s *CertificationService) GetCertificationStatus(ctx context.Context, userID string) (*dto.CertificationStatusResponse, error) +``` + +### 百度 OCR 服务 + +```go +type BaiduOCRService struct { + client *ocr.Client + appID string + apiKey string + secretKey string + logger *zap.Logger +} + +func (s *BaiduOCRService) RecognizeBusinessLicense(ctx context.Context, imageURL string) (*dto.BusinessLicenseOCRResult, error) +func (s *BaiduOCRService) ValidateOCRResult(result *dto.BusinessLicenseOCRResult) error +``` + +### 阿里云人脸识别服务 + +```go +type AliYunFaceVerifyService struct { + client *cloudauth.Client + accessKeyId string + accessSecret string + regionId string + logger *zap.Logger +} + +func (s *AliYunFaceVerifyService) InitFaceVerify(ctx context.Context, req *dto.FaceVerifyInitRequest) (*dto.FaceVerifyInitResponse, error) +func (s *AliYunFaceVerifyService) DescribeFaceVerify(ctx context.Context, certifyId string) (*dto.FaceVerifyResultResponse, error) +func (s *AliYunFaceVerifyService) ValidateFaceVerifyResult(ctx context.Context, certifyId string) error +``` + +### 通知服务扩展 + +```go +type NotificationService struct { + smsService *sms.Service + wechatWorkService *WechatWorkService + logger *zap.Logger +} + +func (s *NotificationService) NotifyAdminNewApplication(ctx context.Context, certification *entities.Certification) error +func (s *NotificationService) NotifyUserContractReady(ctx context.Context, userPhone, contractURL string) error +func (s *NotificationService) NotifyUserCertificationCompleted(ctx context.Context, userPhone string, accessID string) error +``` + +### 七牛云存储服务 + +```go +type QiNiuStorageService struct { + accessKey string + secretKey string + bucket string + domain string + region string + logger *zap.Logger +} + +func (s *QiNiuStorageService) UploadFile(ctx context.Context, fileBytes []byte, fileName string) (*dto.UploadResult, error) +func (s *QiNiuStorageService) GenerateUploadToken(ctx context.Context, key string) (string, error) +func (s *QiNiuStorageService) GetFileURL(ctx context.Context, key string) string +func (s *QiNiuStorageService) DeleteFile(ctx context.Context, key string) error +``` + +### 营业执照处理服务 + +```go +type LicenseProcessService struct { + ocrService *BaiduOCRService + storageService *QiNiuStorageService + logger *zap.Logger +} + +func (s *LicenseProcessService) ProcessLicense(ctx context.Context, fileBytes []byte, userID string) (*dto.LicenseProcessResult, error) { + // 1. 上传文件到七牛云 + uploadResult, err := s.storageService.UploadFile(ctx, fileBytes, generateFileName(userID)) + if err != nil { + return nil, fmt.Errorf("文件上传失败: %w", err) + } + + // 2. OCR识别营业执照 + ocrResult, err := s.ocrService.RecognizeBusinessLicense(ctx, uploadResult.URL) + if err != nil { + s.logger.Warn("OCR识别失败", zap.Error(err)) + // OCR失败不影响整体流程,返回空的企业信息供用户手动填写 + } + + return &dto.LicenseProcessResult{ + LicenseURL: uploadResult.URL, + EnterpriseInfo: ocrResult, + OCRSuccess: err == nil, + OCRError: getErrorMessage(err), + }, nil +} +``` + +## 🌐 API 设计 + +### 用户端 API + +``` +POST /api/v1/certification/submit-license # 提交营业执照 +PUT /api/v1/certification/{id}/confirm-info # 确认企业信息 +POST /api/v1/certification/{id}/face-verify # 人脸识别 +POST /api/v1/certification/{id}/apply-contract # 申请电子合同 +POST /api/v1/certification/{id}/sign-contract # 签署合同 +GET /api/v1/certification/status # 查询认证状态 +GET /api/v1/certification/{id} # 获取认证详情 +``` + +### 管理员端 API + +``` +GET /api/v1/admin/certifications # 获取待审核申请列表 +GET /api/v1/admin/certifications/{id} # 获取认证详情 +POST /api/v1/admin/certifications/{id}/approve # 审核通过并上传合同链接 +POST /api/v1/admin/certifications/{id}/reject # 审核拒绝 +PUT /api/v1/admin/certifications/{id}/contract # 上传合同签署链接 +``` + +### 财务 API + +``` +GET /api/v1/finance/wallet # 获取钱包信息 +GET /api/v1/finance/wallet/balance # 获取余额 +GET /api/v1/finance/wallet/transactions # 获取交易记录 +GET /api/v1/finance/secrets # 获取用户密钥信息 +POST /api/v1/finance/secrets/regenerate # 重新生成Access Key +``` + +## 🗄️ 数据库设计 + +### 新增数据表 + +```sql +-- 认证申请表 +CREATE TABLE certifications ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + enterprise_id VARCHAR(36), + status VARCHAR(50) NOT NULL, + info_submitted_at DATETIME, + face_verified_at DATETIME, + contract_applied_at DATETIME, + contract_approved_at DATETIME, + contract_signed_at DATETIME, + completed_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME, + + INDEX idx_user_id (user_id), + INDEX idx_status (status), + INDEX idx_created_at (created_at) +); + +-- 企业信息表 +CREATE TABLE enterprises ( + id VARCHAR(36) PRIMARY KEY, + certification_id VARCHAR(36) NOT NULL, + company_name VARCHAR(255) NOT NULL, + unified_social_code VARCHAR(50) NOT NULL, + legal_person_name VARCHAR(100) NOT NULL, + legal_person_id VARCHAR(50) NOT NULL, + license_upload_record_id VARCHAR(36) NOT NULL, -- 关联营业执照上传记录 + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME, + + INDEX idx_certification_id (certification_id), + INDEX idx_unified_social_code (unified_social_code), + INDEX idx_license_upload_record_id (license_upload_record_id) +); + +-- 钱包表 +CREATE TABLE wallets ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL UNIQUE, + is_active BOOLEAN DEFAULT TRUE, + balance DECIMAL(20,8) DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME, + + INDEX idx_user_id (user_id) +); + +-- 用户密钥表 +CREATE TABLE user_secrets ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL UNIQUE, + access_id VARCHAR(100) NOT NULL UNIQUE, + access_key VARCHAR(255) NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + last_used_at DATETIME, + expires_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME, + + INDEX idx_user_id (user_id), + INDEX idx_access_id (access_id) +); + +-- 营业执照上传记录表 +CREATE TABLE license_upload_records ( + id VARCHAR(36) PRIMARY KEY, + certification_id VARCHAR(36), + user_id VARCHAR(36) NOT NULL, + original_file_name VARCHAR(255) NOT NULL, + file_size BIGINT NOT NULL, + file_type VARCHAR(50) NOT NULL, + file_url VARCHAR(500) NOT NULL, + qiniu_key VARCHAR(255) NOT NULL, + ocr_processed BOOLEAN DEFAULT FALSE, + ocr_success BOOLEAN DEFAULT FALSE, + ocr_confidence DECIMAL(5,2), + ocr_raw_data TEXT, + ocr_error_message VARCHAR(500), + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME, + + INDEX idx_certification_id (certification_id), + INDEX idx_user_id (user_id), + INDEX idx_qiniu_key (qiniu_key) +); + +-- 人脸识别记录表 +CREATE TABLE face_verify_records ( + id VARCHAR(36) PRIMARY KEY, + certification_id VARCHAR(36) NOT NULL, + user_id VARCHAR(36) NOT NULL, + certify_id VARCHAR(100) NOT NULL, + verify_url VARCHAR(500), + return_url VARCHAR(500), + real_name VARCHAR(100) NOT NULL, + id_card_number VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL, + result_code VARCHAR(50), + result_message VARCHAR(500), + verify_score DECIMAL(5,2), + initiated_at DATETIME NOT NULL, + completed_at DATETIME, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME, + + INDEX idx_certification_id (certification_id), + INDEX idx_user_id (user_id), + INDEX idx_certify_id (certify_id), + INDEX idx_status (status) +); + +-- 通知记录表 +CREATE TABLE notification_records ( + id VARCHAR(36) PRIMARY KEY, + certification_id VARCHAR(36), + user_id VARCHAR(36), + notification_type VARCHAR(50) NOT NULL, + notification_scene VARCHAR(50) NOT NULL, + recipient VARCHAR(255) NOT NULL, + title VARCHAR(255), + content TEXT NOT NULL, + template_id VARCHAR(100), + template_params TEXT, + status VARCHAR(50) NOT NULL, + error_message VARCHAR(500), + sent_at DATETIME, + retry_count INT DEFAULT 0, + max_retry_count INT DEFAULT 3, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME, + + INDEX idx_certification_id (certification_id), + INDEX idx_user_id (user_id), + INDEX idx_status (status), + INDEX idx_notification_type (notification_type) +); + +-- 合同记录表 +CREATE TABLE contract_records ( + id VARCHAR(36) PRIMARY KEY, + certification_id VARCHAR(36) NOT NULL, + user_id VARCHAR(36) NOT NULL, + admin_id VARCHAR(36), + contract_type VARCHAR(50) NOT NULL, + contract_url VARCHAR(500), + signing_url VARCHAR(500), + signature_data TEXT, + signed_at DATETIME, + client_ip VARCHAR(50), + user_agent VARCHAR(500), + status VARCHAR(50) NOT NULL, + approval_notes TEXT, + reject_reason TEXT, + expires_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME, + + INDEX idx_certification_id (certification_id), + INDEX idx_user_id (user_id), + INDEX idx_admin_id (admin_id), + INDEX idx_status (status) +); + +-- 管理员表 +CREATE TABLE admins ( + id VARCHAR(36) PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + name VARCHAR(100) NOT NULL, + email VARCHAR(255), + phone VARCHAR(20), + is_active BOOLEAN DEFAULT TRUE, + last_login_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME, + + INDEX idx_username (username) +); +``` + +## 🚀 分阶段实施计划 + +### ✅ 阶段一:基础架构搭建(3-4 天)- 已完成 + +#### 1.1 创建域结构 + +- [x] 创建 `certification` 域的完整目录结构 +- [x] 创建 `finance` 域的完整目录结构 +- [x] 创建 `admin` 域的基础结构 +- [x] 定义核心实体和枚举类型 + +#### 1.2 数据库层 + +- [x] 使用 GORM AutoMigrate 进行数据库迁移(已更新) +- [x] 实现基础的 Repository 接口和实现 +- [x] 添加必要的数据库索引和约束 + +#### 1.3 共享服务扩展 + +- [x] 扩展短信服务支持模板化消息 +- [x] 创建百度 OCR 服务基础架构 +- [x] 实现七牛云存储服务 +- [x] 实现企业微信通知服务基础结构 + +### ✅ 阶段二:核心业务逻辑(5-6 天)- 已完成 + +#### 2.1 认证服务核心功能 + +- [x] 实现营业执照上传、存储和 OCR 识别一体化服务 +- [x] 实现企业信息确认功能 +- [x] 实现人脸识别接口 +- [x] 实现申请电子合同功能 +- [x] 实现状态查询功能 + +#### 2.2 状态管理 + +- [x] 实现认证状态机(第一阶段已完成) +- [x] 添加状态转换验证 +- [x] 实现状态变更事件发布 + +#### 2.3 基础 API 接口 + +- [x] 实现用户端认证相关 API +- [x] 添加参数验证和错误处理 +- [x] 完善 API 响应格式 + +### ✅ 阶段三:第三方集成(4-5 天)- 已完成 + +#### 3.1 百度 OCR 和七牛云存储集成 + +- [x] 实现七牛云文件上传服务 +- [x] 实现百度 OCR API 调用 +- [x] 处理 OCR 识别结果解析 +- [x] 添加 OCR 识别置信度验证 +- [x] 实现营业执照处理一体化服务 + +#### 3.2 阿里云人脸识别集成 + +- [ ] 集成阿里云 InitFaceVerify API +- [ ] 实现人脸识别初始化流程 +- [ ] 实现人脸识别结果查询 +- [ ] 添加人脸识别状态验证 + +#### 3.3 通知服务完善 + +- [x] 实现企业微信机器人通知 +- [ ] 扩展短信模板支持认证流程 +- [ ] 添加通知发送失败重试机制 + +### 阶段四:管理员功能(3-4 天) + +#### 4.1 管理员系统 + +- [ ] 实现管理员登录认证 +- [ ] 创建管理员权限管理 +- [ ] 实现管理员操作日志 + +#### 4.2 审核功能 + +- [ ] 实现待审核申请列表 +- [ ] 实现认证详情查看 +- [ ] 实现审核通过/拒绝功能 +- [ ] 实现合同链接上传功能 + +#### 4.3 管理后台 API + +- [ ] 完善管理员端 API 接口 +- [ ] 添加分页和筛选功能 +- [ ] 实现审核操作记录 + +### ✅ 阶段五:财务系统(3-4 天)- 已完成 + +#### 5.1 财务服务 + +- [x] 实现钱包生成功能 +- [x] 生成 Access ID 和 Access Key +- [x] 实现钱包状态管理 + +#### 5.2 财务 API + +- [x] 实现钱包信息查询 +- [x] 实现 Access Key 重新生成 +- [x] 添加钱包安全验证 + +#### 5.3 认证完成流程 + +- [x] 完善认证完成后的钱包创建 +- [x] 实现认证完成通知 +- [x] 添加钱包激活功能 + +### 🔄 阶段六:事件驱动和完善(2-3 天) + +#### 6.1 事件系统 + +- [ ] 实现完整的认证事件定义 +- [ ] 添加事件处理器 +- [ ] 完善事件驱动的通知机制 + +#### 6.2 系统完善 + +- [ ] 添加全面的日志记录 +- [ ] 实现性能监控和指标 +- [ ] 完善错误处理和回滚机制 + +#### 6.3 测试和文档 + +- [ ] 编写单元测试 +- [ ] 完善 API 文档 +- [ ] 编写操作手册 + +## 🔧 技术要点 + +### 第三方服务配置 + +#### 百度 OCR 配置 + +```yaml +baidu_ocr: + app_id: "your_app_id" + api_key: "your_api_key" + secret_key: "your_secret_key" + endpoint: "https://aip.baidubce.com" + timeout: 30s +``` + +#### 七牛云存储配置 + +```yaml +qiniu_storage: + access_key: "your_access_key" + secret_key: "your_secret_key" + bucket: "enterprise-certification" + domain: "https://qiniu.example.com" + region: "z0" # 华东区域 + timeout: 30s +``` + +#### 阿里云人脸识别配置 + +```yaml +aliyun_face_verify: + access_key_id: "your_access_key_id" + access_key_secret: "your_access_key_secret" + region_id: "cn-hangzhou" + endpoint: "https://cloudauth.cn-hangzhou.aliyuncs.com" + timeout: 30s +``` + +#### 企业微信配置 + +```yaml +wechat_work: + webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx" + timeout: 10s +``` + +### 安全考虑 + +- Access Key 采用加密存储 +- 营业执照图片需要安全存储 +- 人脸识别数据不保存原始图片 +- 管理员操作需要审计日志 + +### 性能优化 + +- OCR 识别结果缓存 +- 状态查询接口优化 +- 批量通知处理 +- 数据库查询优化 + +## 📝 注意事项 + +1. **状态一致性**:所有状态变更都需要通过事务保证一致性 +2. **幂等性**:关键操作需要支持幂等,避免重复处理 +3. **错误处理**:第三方服务调用需要完善的错误处理和重试机制 +4. **数据安全**:敏感信息需要加密存储 +5. **监控告警**:关键业务节点需要监控和告警 +6. **备份恢复**:重要数据需要定期备份 + +## 🎯 成功标准 + +- [ ] 用户可以顺利完成企业认证全流程 +- [ ] 管理员可以高效处理认证审核 +- [ ] 系统可以自动识别营业执照信息 +- [ ] 通知机制工作正常 +- [ ] 财务系统功能完整 +- [ ] API 接口稳定可靠 +- [ ] 系统性能满足要求 +- [ ] 代码质量符合规范 + +--- + +**预计总开发时间:20-26 天** +**核心开发人员:1-2 人** +**测试时间:3-5 天** diff --git a/docs/应用服务层改造TODO.md b/docs/应用服务层改造TODO.md new file mode 100644 index 0000000..9c31465 --- /dev/null +++ b/docs/应用服务层改造TODO.md @@ -0,0 +1,568 @@ +# 应用服务层改造 TODO 清单 + +## 📋 总体进度 + +- [ ] 阶段一:基础架构搭建 (0/4) +- [ ] 阶段二:用户域改造 (0/4) +- [ ] 阶段三:认证域改造 (0/4) +- [ ] 阶段四:财务域改造 (0/4) +- [ ] 阶段五:管理员域改造 (0/3) +- [ ] 阶段六:整体优化 (0/4) + +--- + +## 🏗️ 阶段一:基础架构搭建 + +### 1.1 创建新的目录结构 + +- [ ] 创建 `internal/application/` 目录 +- [ ] 创建 `internal/infrastructure/` 目录 +- [ ] 创建应用服务层子目录结构 + - [ ] `internal/application/user/` + - [ ] `internal/application/certification/` + - [ ] `internal/application/finance/` + - [ ] `internal/application/admin/` +- [ ] 创建基础设施层子目录结构 + - [ ] `internal/infrastructure/http/` + - [ ] `internal/infrastructure/database/` + - [ ] `internal/infrastructure/cache/` + - [ ] `internal/infrastructure/external/` + - [ ] `internal/infrastructure/config/` + +### 1.2 移动现有组件到基础设施层 + +- [ ] 移动 HTTP 处理器 + - [ ] 移动 `domains/user/handlers/` → `infrastructure/http/handlers/` + - [ ] 移动 `domains/certification/handlers/` → `infrastructure/http/handlers/` + - [ ] 移动 `domains/finance/handlers/` → `infrastructure/http/handlers/` + - [ ] 移动 `domains/admin/handlers/` → `infrastructure/http/handlers/` +- [ ] 移动路由 + - [ ] 移动 `domains/user/routes/` → `infrastructure/http/routes/` + - [ ] 移动 `domains/certification/routes/` → `infrastructure/http/routes/` + - [ ] 移动 `domains/finance/routes/` → `infrastructure/http/routes/` + - [ ] 移动 `domains/admin/routes/` → `infrastructure/http/routes/` +- [ ] 移动中间件 + - [ ] 移动 `shared/middleware/` → `infrastructure/http/middleware/` +- [ ] 移动仓储实现 + - [ ] 移动 `domains/user/repositories/` → `infrastructure/database/repositories/user/` + - [ ] 移动 `domains/certification/repositories/` → `infrastructure/database/repositories/certification/` + - [ ] 移动 `domains/finance/repositories/` → `infrastructure/database/repositories/finance/` + - [ ] 移动 `domains/admin/repositories/` → `infrastructure/database/repositories/admin/` +- [ ] 移动外部服务 + - [ ] 移动 `shared/sms/` → `infrastructure/external/sms/` + - [ ] 移动 `shared/ocr/` → `infrastructure/external/ocr/` + - [ ] 移动 `shared/storage/` → `infrastructure/external/storage/` + - [ ] 移动 `shared/notification/` → `infrastructure/external/notification/` + +### 1.3 更新导入路径 + +- [ ] 更新所有移动文件的导入路径 +- [ ] 更新 `container/container.go` 中的导入路径 +- [ ] 更新 `app/app.go` 中的导入路径 +- [ ] 检查并修复所有编译错误 + +### 1.4 确保项目能正常启动 + +- [ ] 运行 `go mod tidy` 整理依赖 +- [ ] 编译项目确保无错误 +- [ ] 启动项目确保能正常运行 +- [ ] 运行基础功能测试 + +--- + +## 👤 阶段二:用户域改造 + +### 2.1 创建用户应用服务基础结构 + +- [ ] 创建 `internal/application/user/user_application_service.go` +- [ ] 创建命令对象 + - [ ] `internal/application/user/dto/commands/register_user_command.go` + - [ ] `internal/application/user/dto/commands/login_user_command.go` + - [ ] `internal/application/user/dto/commands/change_password_command.go` +- [ ] 创建查询对象 + - [ ] `internal/application/user/dto/queries/get_user_profile_query.go` +- [ ] 创建响应对象 + - [ ] `internal/application/user/dto/responses/register_user_response.go` + - [ ] `internal/application/user/dto/responses/login_user_response.go` + - [ ] `internal/application/user/dto/responses/user_profile_response.go` +- [ ] 创建应用事件 + - [ ] `internal/application/user/events/user_application_events.go` + +### 2.2 实现用户应用服务逻辑 + +- [ ] 实现 `RegisterUser` 方法 + - [ ] 验证短信验证码 + - [ ] 检查手机号是否已存在 + - [ ] 创建用户实体 + - [ ] 保存用户 + - [ ] 发布用户注册事件 +- [ ] 实现 `LoginUser` 方法 + - [ ] 支持密码登录 + - [ ] 支持短信登录 + - [ ] 发布用户登录事件 +- [ ] 实现 `ChangePassword` 方法 + - [ ] 验证短信验证码 + - [ ] 验证旧密码 + - [ ] 更新密码 + - [ ] 发布密码修改事件 +- [ ] 实现 `GetUserProfile` 方法 + - [ ] 获取用户信息 + - [ ] 返回脱敏信息 + +### 2.3 重构用户 HTTP 处理器 + +- [ ] 修改 `infrastructure/http/handlers/user_handler.go` +- [ ] 将 HTTP 处理器改为调用应用服务 +- [ ] 简化 HTTP 处理器的职责 +- [ ] 保持 API 接口不变 +- [ ] 更新错误处理 + +### 2.4 更新依赖注入配置 + +- [ ] 在 `container/container.go` 中注册用户应用服务 +- [ ] 更新用户 HTTP 处理器的依赖 +- [ ] 确保向后兼容 + +### 2.5 测试用户相关功能 + +- [ ] 测试用户注册功能 +- [ ] 测试用户登录功能(密码/短信) +- [ ] 测试密码修改功能 +- [ ] 测试用户信息查询功能 +- [ ] 验证 API 接口兼容性 + +--- + +## 🏢 阶段三:认证域改造 + +### 3.1 创建认证应用服务基础结构 + +- [ ] 创建 `internal/application/certification/certification_application_service.go` +- [ ] 创建命令对象 + - [ ] `internal/application/certification/dto/commands/create_certification_command.go` + - [ ] `internal/application/certification/dto/commands/submit_enterprise_info_command.go` + - [ ] `internal/application/certification/dto/commands/upload_license_command.go` + - [ ] `internal/application/certification/dto/commands/initiate_face_verify_command.go` + - [ ] `internal/application/certification/dto/commands/apply_contract_command.go` +- [ ] 创建查询对象 + - [ ] `internal/application/certification/dto/queries/get_certification_status_query.go` + - [ ] `internal/application/certification/dto/queries/get_certification_details_query.go` +- [ ] 创建响应对象 + - [ ] `internal/application/certification/dto/responses/certification_response.go` + - [ ] `internal/application/certification/dto/responses/enterprise_info_response.go` + - [ ] `internal/application/certification/dto/responses/upload_license_response.go` +- [ ] 创建应用事件 + - [ ] `internal/application/certification/events/certification_application_events.go` + +### 3.2 实现认证应用服务逻辑 + +- [ ] 实现 `CreateCertification` 方法 + - [ ] 检查用户是否已有认证申请 + - [ ] 创建认证申请 + - [ ] 发布认证创建事件 +- [ ] 实现 `SubmitEnterpriseInfo` 方法 + - [ ] 验证认证状态 + - [ ] 检查统一社会信用代码 + - [ ] 创建企业信息 + - [ ] 更新认证状态 + - [ ] 发布企业信息提交事件 +- [ ] 实现 `UploadLicense` 方法 + - [ ] 上传文件到存储服务 + - [ ] 创建上传记录 + - [ ] 进行 OCR 识别 + - [ ] 发布营业执照上传事件 +- [ ] 实现 `InitiateFaceVerify` 方法 + - [ ] 验证认证状态 + - [ ] 调用人脸识别服务 + - [ ] 更新认证状态 + - [ ] 发布人脸识别事件 +- [ ] 实现 `ApplyContract` 方法 + - [ ] 验证认证状态 + - [ ] 申请电子合同 + - [ ] 更新认证状态 + - [ ] 发布合同申请事件 + +### 3.3 重构认证 HTTP 处理器 + +- [ ] 修改 `infrastructure/http/handlers/certification_handler.go` +- [ ] 将 HTTP 处理器改为调用应用服务 +- [ ] 简化 HTTP 处理器的职责 +- [ ] 保持 API 接口不变 +- [ ] 更新错误处理 + +### 3.4 处理跨域协调逻辑 + +- [ ] 在应用服务中协调用户域和认证域 +- [ ] 确保数据一致性 +- [ ] 处理跨域事件 + +### 3.5 测试认证流程 + +- [ ] 测试创建认证申请 +- [ ] 测试提交企业信息 +- [ ] 测试上传营业执照 +- [ ] 测试人脸识别流程 +- [ ] 测试合同申请流程 +- [ ] 验证完整认证流程 + +--- + +## 💰 阶段四:财务域改造 + +### 4.1 创建财务应用服务基础结构 + +- [ ] 创建 `internal/application/finance/finance_application_service.go` +- [ ] 创建命令对象 + - [ ] `internal/application/finance/dto/commands/create_wallet_command.go` + - [ ] `internal/application/finance/dto/commands/recharge_wallet_command.go` + - [ ] `internal/application/finance/dto/commands/withdraw_wallet_command.go` + - [ ] `internal/application/finance/dto/commands/create_user_secrets_command.go` + - [ ] `internal/application/finance/dto/commands/regenerate_access_key_command.go` +- [ ] 创建查询对象 + - [ ] `internal/application/finance/dto/queries/get_wallet_info_query.go` + - [ ] `internal/application/finance/dto/queries/get_user_secrets_query.go` +- [ ] 创建响应对象 + - [ ] `internal/application/finance/dto/responses/wallet_response.go` + - [ ] `internal/application/finance/dto/responses/transaction_response.go` + - [ ] `internal/application/finance/dto/responses/user_secrets_response.go` +- [ ] 创建应用事件 + - [ ] `internal/application/finance/events/finance_application_events.go` + +### 4.2 实现财务应用服务逻辑 + +- [ ] 实现 `CreateWallet` 方法 + - [ ] 检查用户是否已有钱包 + - [ ] 创建钱包 + - [ ] 发布钱包创建事件 +- [ ] 实现 `RechargeWallet` 方法 + - [ ] 验证金额 + - [ ] 检查钱包状态 + - [ ] 增加余额 + - [ ] 发布充值事件 +- [ ] 实现 `WithdrawWallet` 方法 + - [ ] 验证金额 + - [ ] 检查余额是否足够 + - [ ] 减少余额 + - [ ] 发布提现事件 +- [ ] 实现 `CreateUserSecrets` 方法 + - [ ] 生成访问密钥 + - [ ] 创建用户密钥 + - [ ] 发布密钥创建事件 +- [ ] 实现 `RegenerateAccessKey` 方法 + - [ ] 验证用户密钥 + - [ ] 重新生成访问密钥 + - [ ] 发布密钥更新事件 + +### 4.3 添加事务管理 + +- [ ] 在应用服务中添加事务边界 +- [ ] 确保资金操作的数据一致性 +- [ ] 处理事务回滚 + +### 4.4 重构财务 HTTP 处理器 + +- [ ] 修改 `infrastructure/http/handlers/finance_handler.go` +- [ ] 将 HTTP 处理器改为调用应用服务 +- [ ] 简化 HTTP 处理器的职责 +- [ ] 保持 API 接口不变 +- [ ] 更新错误处理 + +### 4.5 测试财务功能 + +- [ ] 测试创建钱包 +- [ ] 测试钱包充值 +- [ ] 测试钱包提现 +- [ ] 测试创建用户密钥 +- [ ] 测试重新生成访问密钥 +- [ ] 验证资金安全 + +--- + +## 👨‍💼 阶段五:管理员域改造 + +### 5.1 创建管理员应用服务基础结构 + +- [ ] 创建 `internal/application/admin/admin_application_service.go` +- [ ] 创建命令对象 + - [ ] `internal/application/admin/dto/commands/admin_login_command.go` + - [ ] `internal/application/admin/dto/commands/create_admin_command.go` + - [ ] `internal/application/admin/dto/commands/update_admin_command.go` + - [ ] `internal/application/admin/dto/commands/change_admin_password_command.go` +- [ ] 创建查询对象 + - [ ] `internal/application/admin/dto/queries/get_admin_info_query.go` + - [ ] `internal/application/admin/dto/queries/list_admins_query.go` +- [ ] 创建响应对象 + - [ ] `internal/application/admin/dto/responses/admin_login_response.go` + - [ ] `internal/application/admin/dto/responses/admin_info_response.go` + - [ ] `internal/application/admin/dto/responses/admin_list_response.go` +- [ ] 创建应用事件 + - [ ] `internal/application/admin/events/admin_application_events.go` + +### 5.2 实现管理员应用服务逻辑 + +- [ ] 实现 `AdminLogin` 方法 + - [ ] 验证管理员凭据 + - [ ] 生成 JWT 令牌 + - [ ] 记录登录日志 + - [ ] 发布管理员登录事件 +- [ ] 实现 `CreateAdmin` 方法 + - [ ] 检查用户名和邮箱唯一性 + - [ ] 加密密码 + - [ ] 创建管理员 + - [ ] 记录操作日志 + - [ ] 发布管理员创建事件 +- [ ] 实现 `UpdateAdmin` 方法 + - [ ] 验证管理员存在 + - [ ] 更新管理员信息 + - [ ] 记录操作日志 + - [ ] 发布管理员更新事件 +- [ ] 实现 `ChangeAdminPassword` 方法 + - [ ] 验证旧密码 + - [ ] 更新密码 + - [ ] 记录操作日志 + - [ ] 发布密码修改事件 + +### 5.3 重构管理员 HTTP 处理器 + +- [ ] 修改 `infrastructure/http/handlers/admin_handler.go` +- [ ] 将 HTTP 处理器改为调用应用服务 +- [ ] 简化 HTTP 处理器的职责 +- [ ] 保持 API 接口不变 +- [ ] 更新错误处理 + +### 5.4 测试管理功能 + +- [ ] 测试管理员登录 +- [ ] 测试创建管理员 +- [ ] 测试更新管理员 +- [ ] 测试修改管理员密码 +- [ ] 测试管理员信息查询 +- [ ] 验证权限控制 + +--- + +## 🔧 阶段六:整体优化 + +### 6.1 添加应用事件处理 + +- [ ] 实现应用事件发布机制 +- [ ] 添加应用事件处理器 +- [ ] 处理跨域事件协调 +- [ ] 添加事件持久化 + +### 6.2 完善监控和日志 + +- [ ] 添加应用服务层的日志记录 +- [ ] 完善错误处理和监控 +- [ ] 添加性能指标收集 +- [ ] 优化日志格式和级别 + +### 6.3 性能优化 + +- [ ] 优化数据库查询 +- [ ] 添加缓存策略 +- [ ] 优化并发处理 +- [ ] 添加连接池配置 + +### 6.4 文档更新 + +- [ ] 更新 API 文档 +- [ ] 更新架构文档 +- [ ] 更新开发指南 +- [ ] 添加部署文档 + +--- + +## 📝 开发指南 + +### 应用服务层开发模式 + +#### 1. 应用服务结构 + +```go +type UserApplicationService struct { + userService *services.UserService + smsCodeService *services.SMSCodeService + eventBus interfaces.EventBus + logger *zap.Logger +} +``` + +#### 2. 命令对象模式 + +```go +type RegisterUserCommand struct { + Phone string `json:"phone" binding:"required,len=11"` + Password string `json:"password" binding:"required,min=6,max=128"` + ConfirmPassword string `json:"confirm_password" binding:"required,eqfield=Password"` + Code string `json:"code" binding:"required,len=6"` + + // 应用层上下文信息 + CorrelationID string `json:"-"` + ClientIP string `json:"-"` + UserAgent string `json:"-"` +} +``` + +#### 3. 查询对象模式 + +```go +type GetUserProfileQuery struct { + UserID string `json:"-"` +} +``` + +#### 4. 响应对象模式 + +```go +type RegisterUserResponse struct { + UserID string `json:"user_id"` + Phone string `json:"phone"` + RegisteredAt time.Time `json:"registered_at"` +} +``` + +#### 5. 应用事件模式 + +```go +type UserRegisteredApplicationEvent struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Phone string `json:"phone"` + RegisteredAt time.Time `json:"registered_at"` + CorrelationID string `json:"correlation_id"` + ClientIP string `json:"client_ip"` + UserAgent string `json:"user_agent"` +} +``` + +### 业务逻辑编写指南 + +#### 1. 应用服务方法结构 + +```go +func (s *UserApplicationService) RegisterUser(ctx context.Context, cmd *dto.RegisterUserCommand) (*dto.RegisterUserResponse, error) { + // 1. 参数验证 + // 2. 业务规则检查 + // 3. 调用域服务 + // 4. 事务处理 + // 5. 发布事件 + // 6. 返回结果 +} +``` + +#### 2. 错误处理模式 + +```go +if err != nil { + s.logger.Error("操作失败", zap.Error(err)) + return nil, fmt.Errorf("操作失败: %w", err) +} +``` + +#### 3. 事件发布模式 + +```go +event := &dto.UserRegisteredApplicationEvent{ + ID: uuid.New().String(), + UserID: user.ID, + Phone: user.Phone, + RegisteredAt: time.Now(), + CorrelationID: cmd.CorrelationID, + ClientIP: cmd.ClientIP, + UserAgent: cmd.UserAgent, +} + +if err := s.eventBus.Publish(ctx, event); err != nil { + s.logger.Warn("发布事件失败", zap.Error(err)) +} +``` + +#### 4. 事务管理模式 + +```go +// 在应用服务中使用事务 +tx := s.db.Begin() +defer func() { + if r := recover(); r != nil { + tx.Rollback() + } +}() + +// 执行业务操作 +if err := s.userService.CreateUser(ctx, user); err != nil { + tx.Rollback() + return nil, err +} + +if err := tx.Commit().Error; err != nil { + return nil, err +} +``` + +### 测试指南 + +#### 1. 单元测试 + +```go +func TestUserApplicationService_RegisterUser(t *testing.T) { + // 准备测试数据 + // 执行测试 + // 验证结果 +} +``` + +#### 2. 集成测试 + +```go +func TestUserRegistrationFlow(t *testing.T) { + // 测试完整流程 + // 验证数据库状态 + // 验证事件发布 +} +``` + +--- + +## 🚀 快速开始 + +### 第一步:备份当前代码 + +```bash +git checkout -b feature/application-service-layer +git add . +git commit -m "备份当前代码状态" +``` + +### 第二步:开始阶段一 + +按照 TODO 清单逐步执行阶段一的任务。 + +### 第三步:验证改造 + +每个阶段完成后,确保: + +- 项目能正常编译 +- 项目能正常启动 +- 基础功能正常工作 +- 测试通过 + +### 第四步:提交代码 + +每个阶段完成后提交代码: + +```bash +git add . +git commit -m "完成阶段X:XXX改造" +``` + +--- + +## 📞 支持 + +如果在改造过程中遇到问题: + +1. 检查 TODO 清单是否遗漏 +2. 查看相关文档 +3. 运行测试验证 +4. 回滚到上一个稳定版本 diff --git a/docs/应用服务层改造计划.md b/docs/应用服务层改造计划.md new file mode 100644 index 0000000..4a5f854 --- /dev/null +++ b/docs/应用服务层改造计划.md @@ -0,0 +1,340 @@ +# 应用服务层改造计划 + +## 📋 项目概述 + +### 当前项目状态 + +- **项目名称**: TYAPI Server (Gin 框架) +- **当前架构**: DDD 领域驱动设计(不完整) +- **现有域**: 用户域、认证域、财务域、管理员域 +- **主要问题**: HTTP 处理器直接调用域服务,缺乏应用服务层 + +### 改造目标 + +- 完善 DDD 分层架构 +- 添加应用服务层 +- 重构基础设施层 +- 提高代码可维护性和可测试性 + +## 🏗️ 目标架构设计 + +### 改造后的目录结构 + +``` +internal/ +├── application/ # 应用服务层 (Application Services Layer) +│ ├── user/ +│ │ ├── user_application_service.go +│ │ ├── dto/ +│ │ │ ├── commands/ +│ │ │ │ ├── register_user_command.go +│ │ │ │ ├── login_user_command.go +│ │ │ │ └── change_password_command.go +│ │ │ ├── queries/ +│ │ │ │ └── get_user_profile_query.go +│ │ │ └── responses/ +│ │ │ ├── register_user_response.go +│ │ │ ├── login_user_response.go +│ │ │ └── user_profile_response.go +│ │ └── events/ +│ │ └── user_application_events.go +│ ├── certification/ +│ │ ├── certification_application_service.go +│ │ ├── dto/ +│ │ │ ├── commands/ +│ │ │ │ ├── create_certification_command.go +│ │ │ │ ├── submit_enterprise_info_command.go +│ │ │ │ ├── upload_license_command.go +│ │ │ │ ├── initiate_face_verify_command.go +│ │ │ │ └── apply_contract_command.go +│ │ │ ├── queries/ +│ │ │ │ ├── get_certification_status_query.go +│ │ │ │ └── get_certification_details_query.go +│ │ │ └── responses/ +│ │ │ ├── certification_response.go +│ │ │ ├── enterprise_info_response.go +│ │ │ └── upload_license_response.go +│ │ └── events/ +│ │ └── certification_application_events.go +│ ├── finance/ +│ │ ├── finance_application_service.go +│ │ ├── dto/ +│ │ │ ├── commands/ +│ │ │ │ ├── create_wallet_command.go +│ │ │ │ ├── recharge_wallet_command.go +│ │ │ │ ├── withdraw_wallet_command.go +│ │ │ │ ├── create_user_secrets_command.go +│ │ │ │ └── regenerate_access_key_command.go +│ │ │ ├── queries/ +│ │ │ │ ├── get_wallet_info_query.go +│ │ │ │ └── get_user_secrets_query.go +│ │ │ └── responses/ +│ │ │ ├── wallet_response.go +│ │ │ ├── transaction_response.go +│ │ │ └── user_secrets_response.go +│ │ └── events/ +│ │ └── finance_application_events.go +│ └── admin/ +│ ├── admin_application_service.go +│ ├── dto/ +│ │ ├── commands/ +│ │ │ ├── admin_login_command.go +│ │ │ ├── create_admin_command.go +│ │ │ ├── update_admin_command.go +│ │ │ └── change_admin_password_command.go +│ │ ├── queries/ +│ │ │ ├── get_admin_info_query.go +│ │ │ └── list_admins_query.go +│ │ └── responses/ +│ │ ├── admin_login_response.go +│ │ ├── admin_info_response.go +│ │ └── admin_list_response.go +│ └── events/ +│ └── admin_application_events.go +├── domains/ # 领域层 (Domain Layer) - 保持不变 +│ ├── user/ +│ │ ├── entities/ +│ │ ├── services/ +│ │ ├── repositories/ +│ │ ├── dto/ +│ │ └── events/ +│ ├── certification/ +│ │ ├── entities/ +│ │ ├── services/ +│ │ ├── repositories/ +│ │ ├── dto/ +│ │ ├── events/ +│ │ └── enums/ +│ ├── finance/ +│ │ ├── entities/ +│ │ ├── services/ +│ │ ├── repositories/ +│ │ ├── dto/ +│ │ └── value_objects/ +│ └── admin/ +│ ├── entities/ +│ ├── services/ +│ ├── repositories/ +│ └── dto/ +├── infrastructure/ # 基础设施层 (Infrastructure Layer) - 新增 +│ ├── http/ +│ │ ├── handlers/ +│ │ │ ├── user_handler.go # 从 domains/user/handlers/ 移动 +│ │ │ ├── certification_handler.go # 从 domains/certification/handlers/ 移动 +│ │ │ ├── finance_handler.go # 从 domains/finance/handlers/ 移动 +│ │ │ └── admin_handler.go # 从 domains/admin/handlers/ 移动 +│ │ ├── middleware/ # 从 shared/middleware/ 移动 +│ │ │ ├── auth.go +│ │ │ ├── cors.go +│ │ │ ├── ratelimit.go +│ │ │ ├── request_logger.go +│ │ │ └── tracing.go +│ │ └── routes/ +│ │ ├── user_routes.go # 从 domains/user/routes/ 移动 +│ │ ├── certification_routes.go # 从 domains/certification/routes/ 移动 +│ │ ├── finance_routes.go # 从 domains/finance/routes/ 移动 +│ │ └── admin_routes.go # 从 domains/admin/routes/ 移动 +│ ├── database/ +│ │ ├── repositories/ +│ │ │ ├── user/ +│ │ │ │ ├── user_repository.go +│ │ │ │ └── sms_code_repository.go +│ │ │ ├── certification/ +│ │ │ │ ├── certification_repository.go +│ │ │ │ ├── enterprise_repository.go +│ │ │ │ └── license_upload_repository.go +│ │ │ ├── finance/ +│ │ │ │ ├── wallet_repository.go +│ │ │ │ └── user_secrets_repository.go +│ │ │ └── admin/ +│ │ │ ├── admin_repository.go +│ │ │ └── admin_login_log_repository.go +│ │ └── migrations/ +│ │ ├── user_migrations/ +│ │ ├── certification_migrations/ +│ │ ├── finance_migrations/ +│ │ └── admin_migrations/ +│ ├── cache/ +│ │ ├── redis_cache.go +│ │ └── memory_cache.go +│ ├── external/ +│ │ ├── sms/ +│ │ │ └── sms_service.go +│ │ ├── ocr/ +│ │ │ └── baidu_ocr_service.go +│ │ ├── storage/ +│ │ │ └── qiniu_storage_service.go +│ │ └── notification/ +│ │ └── wechat_work_service.go +│ └── config/ +│ ├── database_config.go +│ ├── redis_config.go +│ └── external_services_config.go +├── shared/ # 共享层 (Shared Layer) - 保留核心组件 +│ ├── interfaces/ +│ │ ├── service.go +│ │ ├── repository.go +│ │ ├── event.go +│ │ └── http.go +│ ├── domain/ +│ │ └── entity.go +│ ├── events/ +│ │ └── event_bus.go +│ ├── logger/ +│ │ └── logger.go +│ ├── metrics/ +│ │ ├── business_metrics.go +│ │ └── prometheus_metrics.go +│ ├── resilience/ +│ │ ├── circuit_breaker.go +│ │ └── retry.go +│ ├── saga/ +│ │ └── saga.go +│ ├── hooks/ +│ │ └── hook_system.go +│ └── tracing/ +│ ├── tracer.go +│ └── decorators.go +├── config/ # 配置层 - 保持不变 +│ ├── config.go +│ └── loader.go +├── container/ # 容器层 - 保持不变 +│ └── container.go +└── app/ # 应用层 - 保持不变 + └── app.go +``` + +## 📝 各层职责说明 + +### 应用服务层 (Application Layer) + +- **职责**: 编排业务用例,管理事务边界,协调跨域操作 +- **包含**: 应用服务、命令对象、查询对象、响应对象、应用事件 +- **特点**: 无状态,专注于用例编排 + +### 领域层 (Domain Layer) + +- **职责**: 核心业务逻辑,业务规则,领域实体 +- **包含**: 实体、值对象、域服务、仓储接口、域事件 +- **特点**: 业务逻辑的核心,与技术实现无关 + +### 基础设施层 (Infrastructure Layer) + +- **职责**: 技术实现细节,外部系统集成 +- **包含**: HTTP 处理器、数据库仓储、缓存、外部服务 +- **特点**: 实现领域层定义的接口 + +### 共享层 (Shared Layer) + +- **职责**: 通用工具、接口定义、跨层共享组件 +- **包含**: 接口定义、事件总线、日志、监控、追踪 +- **特点**: 被其他层依赖,但不依赖其他层 + +## 🎯 改造优先级 + +### 第一优先级:用户域 + +- **原因**: 最基础的认证功能,其他域都依赖 +- **复杂度**: 中等 +- **影响范围**: 全局 + +### 第二优先级:认证域 + +- **原因**: 核心业务流程,涉及多个实体协调 +- **复杂度**: 高 +- **影响范围**: 用户域、管理员域 + +### 第三优先级:财务域 + +- **原因**: 涉及资金安全,需要事务管理 +- **复杂度**: 高 +- **影响范围**: 用户域 + +### 第四优先级:管理员域 + +- **原因**: 后台管理功能,相对独立 +- **复杂度**: 低 +- **影响范围**: 其他域 + +## 📋 实施计划 + +### 阶段一:基础架构搭建 (1-2 天) + +1. 创建新的目录结构 +2. 移动现有组件到基础设施层 +3. 更新依赖注入配置 +4. 确保项目能正常启动 + +### 阶段二:用户域改造 (2-3 天) + +1. 实现用户应用服务 +2. 重构用户 HTTP 处理器 +3. 测试用户相关功能 +4. 完善错误处理 + +### 阶段三:认证域改造 (3-4 天) + +1. 实现认证应用服务 +2. 重构认证 HTTP 处理器 +3. 处理跨域协调逻辑 +4. 测试认证流程 + +### 阶段四:财务域改造 (2-3 天) + +1. 实现财务应用服务 +2. 重构财务 HTTP 处理器 +3. 添加事务管理 +4. 测试财务功能 + +### 阶段五:管理员域改造 (1-2 天) + +1. 实现管理员应用服务 +2. 重构管理员 HTTP 处理器 +3. 测试管理功能 + +### 阶段六:整体优化 (2-3 天) + +1. 添加应用事件处理 +2. 完善监控和日志 +3. 性能优化 +4. 文档更新 + +## ⚠️ 注意事项 + +### 备份策略 + +- 每个阶段开始前备份当前代码 +- 使用 Git 分支进行改造 +- 保留原始代码作为参考 + +### 测试策略 + +- 每个域改造完成后进行单元测试 +- 保持 API 接口兼容性 +- 进行集成测试验证 + +### 回滚策略 + +- 如果改造出现问题,可以快速回滚到上一个稳定版本 +- 保持数据库结构不变 +- 确保配置文件的兼容性 + +## 📊 预期收益 + +### 架构改进 + +- ✅ 职责分离更清晰 +- ✅ 代码组织更规范 +- ✅ 可维护性更强 + +### 开发效率 + +- ✅ 可测试性更好 +- ✅ 扩展性更强 +- ✅ 复用性更高 + +### 业务价值 + +- ✅ 业务逻辑更清晰 +- ✅ 跨域协调更简单 +- ✅ 事务管理更可靠 diff --git a/docs/用户域实体优化总结.md b/docs/用户域实体优化总结.md new file mode 100644 index 0000000..24fe4b6 --- /dev/null +++ b/docs/用户域实体优化总结.md @@ -0,0 +1,221 @@ +# 用户域实体优化总结 + +## 🎯 **优化目标** + +将 User 实体从"贫血模型"升级为"充血模型",将业务逻辑从 Service 层迁移到实体层,实现更好的封装和职责分离。 + +## ✅ **完成的优化** + +### **1. 实体业务方法增强** + +#### **密码管理方法** + +```go +// 修改密码(包含完整的业务验证) +func (u *User) ChangePassword(oldPassword, newPassword, confirmPassword string) error + +// 验证密码 +func (u *User) CheckPassword(password string) bool + +// 设置密码(用于注册或重置) +func (u *User) SetPassword(password string) error +``` + +#### **手机号管理方法** + +```go +// 验证手机号格式 +func (u *User) IsValidPhone() bool + +// 设置手机号(包含格式验证) +func (u *User) SetPhone(phone string) error + +// 获取脱敏手机号 +func (u *User) GetMaskedPhone() string +``` + +#### **用户状态检查方法** + +```go +// 检查用户是否可以登录 +func (u *User) CanLogin() bool + +// 检查用户是否活跃 +func (u *User) IsActive() bool + +// 检查用户是否已删除 +func (u *User) IsDeleted() bool +``` + +### **2. 业务规则验证** + +#### **密码强度验证** + +- 长度要求:8-128 位 +- 必须包含数字 +- 必须包含字母 +- 必须包含特殊字符 + +#### **手机号格式验证** + +- 11 位数字 +- 以 1 开头 +- 第二位为 3-9 + +#### **业务不变性验证** + +- 新密码不能与旧密码相同 +- 确认密码必须匹配 +- 用户状态检查 + +### **3. 工厂方法** + +```go +// 创建新用户的工厂方法 +func NewUser(phone, password string) (*User, error) +``` + +### **4. 静态工具方法** + +```go +// 验证手机号格式(静态方法) +func IsValidPhoneFormat(phone string) bool + +// 检查是否为验证错误 +func IsValidationError(err error) bool +``` + +## 🔄 **Service 层重构** + +### **优化前的问题** + +```go +// 业务逻辑集中在Service中 +func (s *UserService) ChangePassword(ctx context.Context, userID string, req *dto.ChangePasswordRequest) error { + // 验证新密码确认 + if req.NewPassword != req.ConfirmNewPassword { ... } + + // 验证当前密码 + if !s.checkPassword(req.OldPassword, user.Password) { ... } + + // 哈希新密码 + hashedPassword, err := s.hashPassword(req.NewPassword) + + // 更新密码 + user.Password = hashedPassword + return s.repo.Update(ctx, user) +} +``` + +### **优化后的改进** + +```go +// Service只负责协调,业务逻辑委托给实体 +func (s *UserService) ChangePassword(ctx context.Context, userID string, req *dto.ChangePasswordRequest) error { + // 1. 获取用户信息 + user, err := s.repo.GetByID(ctx, userID) + if err != nil { + return fmt.Errorf("用户不存在: %w", err) + } + + // 2. 执行业务逻辑(委托给实体) + if err := user.ChangePassword(req.OldPassword, req.NewPassword, req.ConfirmNewPassword); err != nil { + return err + } + + // 3. 保存用户 + return s.repo.Update(ctx, user) +} +``` + +## 📊 **优化效果对比** + +| 方面 | 优化前 | 优化后 | +| ---------------- | -------------- | -------------- | +| **业务逻辑位置** | Service 层 | 实体层 | +| **代码复用性** | 低 | 高 | +| **测试难度** | 需要 Mock 仓储 | 可直接测试实体 | +| **职责分离** | 不清晰 | 清晰 | +| **维护性** | 一般 | 优秀 | + +## 🧪 **测试覆盖** + +### **单元测试** + +- ✅ 密码修改功能测试 +- ✅ 密码验证功能测试 +- ✅ 手机号设置功能测试 +- ✅ 手机号脱敏功能测试 +- ✅ 手机号格式验证测试 +- ✅ 用户创建工厂方法测试 + +### **测试结果** + +``` +=== RUN TestUser_ChangePassword +--- PASS: TestUser_ChangePassword (0.57s) +=== RUN TestUser_CheckPassword +--- PASS: TestUser_CheckPassword (0.16s) +=== RUN TestUser_SetPhone +--- PASS: TestUser_SetPhone (0.00s) +=== RUN TestUser_GetMaskedPhone +--- PASS: TestUser_GetMaskedPhone (0.00s) +=== RUN TestIsValidPhoneFormat +--- PASS: TestIsValidPhoneFormat (0.00s) +=== RUN TestNewUser +--- PASS: TestNewUser (0.08s) +PASS +``` + +## 🚀 **新增功能** + +### **1. 用户信息更新** + +```go +func (s *UserService) UpdateUserProfile(ctx context.Context, userID string, req *dto.UpdateProfileRequest) (*entities.User, error) +``` + +### **2. 用户停用** + +```go +func (s *UserService) DeactivateUser(ctx context.Context, userID string) error +``` + +### **3. 软删除支持** + +```go +func (r *UserRepository) SoftDelete(ctx context.Context, id string) error +func (r *UserRepository) Restore(ctx context.Context, id string) error +``` + +## 📈 **架构改进** + +### **1. 更好的封装** + +- 业务规则与数据在一起 +- 减少外部依赖 +- 提高内聚性 + +### **2. 更清晰的职责** + +- 实体:业务逻辑和验证 +- Service:协调和事务管理 +- Repository:数据访问 + +### **3. 更容易测试** + +- 实体方法可以独立测试 +- 不需要复杂的 Mock 设置 +- 测试覆盖更全面 + +## 🎉 **总结** + +这次优化成功实现了: + +1. **✅ 充血模型** - 实体包含丰富的业务方法 +2. **✅ 职责分离** - Service 专注于协调,实体专注于业务逻辑 +3. **✅ 更好的封装** - 业务规则与数据紧密耦合 +4. **✅ 更容易测试** - 实体方法可以独立测试 +5. **✅ 代码复用** - 业务逻辑可以在不同场景下复用 + +这是一个成功的"轻量级 DDD"实践,在保持架构简单的同时,显著提升了代码质量和可维护性! diff --git a/go.mod b/go.mod index 2618d26..2914707 100644 --- a/go.mod +++ b/go.mod @@ -12,8 +12,11 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 github.com/prometheus/client_golang v1.22.0 + github.com/qiniu/go-sdk/v7 v7.25.4 github.com/redis/go-redis/v9 v9.11.0 + github.com/shopspring/decimal v1.4.0 github.com/spf13/viper v1.20.1 + github.com/stretchr/testify v1.10.0 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.0 github.com/swaggo/swag v1.16.4 @@ -30,18 +33,22 @@ require ( ) require ( + github.com/BurntSushi/toml v1.3.2 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/sonic v1.13.3 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cenkalti/backoff/v5 v5.0.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.5 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gammazero/toposort v0.1.1 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -51,6 +58,7 @@ require ( github.com/go-openapi/swag v0.19.15 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/gofrs/flock v0.8.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -70,6 +78,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect @@ -100,4 +109,5 @@ require ( gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/fileutil v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index 50d9b56..c1f6cd2 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= @@ -8,6 +10,8 @@ github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= +github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 h1:7dONQ3WNZ1zy960TmkxJPuwoolZwL7xKtpcM04MBnt4= +github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82/go.mod h1:nLnM0KdK1CmygvjpDUO6m1TjSsiQtL61juhNsvV/JVI= github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 h1:qagvUyrgOnBIlVRQWOyCZGVKUIYbMBdGdJ104vBpRFU= github.com/aliyun/alibaba-cloud-sdk-go v1.63.107/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -29,6 +33,7 @@ github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCy github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/dave/jennifer v1.6.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -41,6 +46,8 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/ github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gammazero/toposort v0.1.1 h1:OivGxsWxF3U3+U80VoLJ+f50HcPU1MIqE1JlKzoJ2Eg= +github.com/gammazero/toposort v0.1.1/go.mod h1:H2cozTnNpMw0hg2VHAYsAxmkHXBYroNangj2NTBQDvw= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= @@ -65,18 +72,26 @@ github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7 github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.7.0/go.mod h1:xm76BBt941f7yWdGnI2DVPFFg1UK3YY04qifoXU3lOk= github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= @@ -89,8 +104,11 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -99,6 +117,7 @@ github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -119,6 +138,8 @@ github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2 github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -127,6 +148,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -147,6 +170,7 @@ github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:Ff github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -159,12 +183,20 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/qiniu/dyn v1.3.0/go.mod h1:E8oERcm8TtwJiZvkQPbcAh0RL8jO1G0VXJMW3FAWdkk= +github.com/qiniu/go-sdk/v7 v7.25.4 h1:ulCKlTEyrZzmNytXweOrnva49+Q4+ASjYBCSXhkRWTo= +github.com/qiniu/go-sdk/v7 v7.25.4/go.mod h1:dmKtJ2ahhPWFVi9o1D5GemmWoh/ctuB9peqTowyTO8o= +github.com/qiniu/x v1.10.5/go.mod h1:03Ni9tj+N2h2aKnAz+6N0Xfl8FwMEDRC2PAlxekASDs= github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs= github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= @@ -179,6 +211,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -237,6 +270,7 @@ golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= @@ -281,6 +315,7 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -317,6 +352,7 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -325,6 +361,7 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -332,5 +369,7 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +modernc.org/fileutil v1.0.0 h1:Z1AFLZwl6BO8A5NldQg/xTSjGLetp+1Ubvl4alfGx8w= +modernc.org/fileutil v1.0.0/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/app/app.go b/internal/app/app.go index 1eae4b7..25a8127 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -12,6 +12,15 @@ import ( "tyapi-server/internal/config" "tyapi-server/internal/container" "tyapi-server/internal/domains/user/entities" + + // 认证域实体 + certEntities "tyapi-server/internal/domains/certification/entities" + + // 财务域实体 + financeEntities "tyapi-server/internal/domains/finance/entities" + + // 管理员域实体 + adminEntities "tyapi-server/internal/domains/admin/entities" ) // Application 应用程序结构 @@ -161,6 +170,8 @@ func (a *Application) createDatabaseConnection() (*gorm.DB, error) { // autoMigrate 自动迁移 func (a *Application) autoMigrate(db *gorm.DB) error { + a.logger.Info("Starting database auto migration...") + // 如果需要删除某些表,可以在这里手动删除 // 注意:这会永久删除数据,请谨慎使用! /* @@ -171,11 +182,26 @@ func (a *Application) autoMigrate(db *gorm.DB) error { } */ - // 迁移用户相关表 + // 自动迁移所有实体 return db.AutoMigrate( + // 用户域 &entities.User{}, &entities.SMSCode{}, - // 后续可以添加其他实体 + + // 认证域 + &certEntities.Certification{}, + &certEntities.Enterprise{}, + &certEntities.LicenseUploadRecord{}, + &certEntities.FaceVerifyRecord{}, + &certEntities.ContractRecord{}, + &certEntities.NotificationRecord{}, + + // 财务域 + &financeEntities.Wallet{}, + &financeEntities.UserSecrets{}, + + // 管理员域 + &adminEntities.Admin{}, ) } diff --git a/internal/config/README.md b/internal/config/README.md new file mode 100644 index 0000000..da778d0 --- /dev/null +++ b/internal/config/README.md @@ -0,0 +1,307 @@ +# 🔧 TYAPI 配置系统文档 + +## 📋 目录 + +- [配置策略概述](#配置策略概述) +- [文件结构](#文件结构) +- [配置加载流程](#配置加载流程) +- [环境配置](#环境配置) +- [配置验证](#配置验证) +- [使用指南](#使用指南) +- [最佳实践](#最佳实践) +- [故障排除](#故障排除) + +## 🎯 配置策略概述 + +TYAPI 采用**分层配置策略**,支持多环境部署和灵活的配置管理: + +``` +📁 配置层次结构 +├── 📄 config.yaml (基础配置模板) +└── 📁 configs/ + ├── 📄 env.development.yaml (开发环境覆盖) + ├── 📄 env.production.yaml (生产环境覆盖) + └── 📄 env.testing.yaml (测试环境覆盖) +``` + +### 配置加载优先级(从高到低) + +1. **环境变量** - 用于敏感信息和运行时覆盖 +2. **环境特定配置文件** - `configs/env.{environment}.yaml` +3. **基础配置文件** - `config.yaml` +4. **默认值** - 代码中的默认配置 + +## 📁 文件结构 + +### 基础配置文件 + +- **位置**: `config.yaml` +- **作用**: 包含所有默认配置值,作为配置模板 +- **特点**: + - 包含完整的配置结构 + - 提供合理的默认值 + - 作为所有环境的基础配置 + +### 环境配置文件 + +- **位置**: `configs/env.{environment}.yaml` +- **支持的环境**: `development`, `production`, `testing` +- **特点**: + - 只包含需要覆盖的配置项 + - 继承基础配置的所有默认值 + - 支持嵌套配置的深度合并 + +## 🔄 配置加载流程 + +### 1. 环境检测 + +```go +// 环境变量检测优先级 +CONFIG_ENV > ENV > APP_ENV > 默认值(development) +``` + +### 2. 配置文件加载顺序 + +1. 读取基础配置文件 `config.yaml` +2. 查找环境配置文件 `configs/env.{environment}.yaml` +3. 合并环境配置到基础配置 +4. 应用环境变量覆盖 +5. 验证配置完整性 +6. 输出配置摘要 + +### 3. 配置合并策略 + +- **递归合并**: 支持嵌套配置的深度合并 +- **覆盖机制**: 环境配置覆盖基础配置 +- **环境变量**: 最终覆盖任何配置项 + +## 🌍 环境配置 + +### 开发环境 (development) + +```yaml +# configs/env.development.yaml +app: + env: development + +database: + password: Pg9mX4kL8nW2rT5y + +jwt: + secret: JwT8xR4mN9vP2sL7kH3oB6yC1zA5uF0qE9tW +``` + +### 生产环境 (production) + +```yaml +# configs/env.production.yaml +app: + env: production + +server: + mode: release + +database: + sslmode: require + +logger: + level: warn + format: json +``` + +### 测试环境 (testing) + +```yaml +# configs/env.testing.yaml +app: + env: testing + +server: + mode: test + +database: + password: test_password + name: tyapi_test + +redis: + db: 15 + +logger: + level: debug + +jwt: + secret: test-jwt-secret-key-for-testing-only +``` + +## ✅ 配置验证 + +### 验证项目 + +- **数据库配置**: 主机、用户名、数据库名不能为空 +- **JWT 配置**: 生产环境必须设置安全的 JWT 密钥 +- **服务器配置**: 超时时间必须大于 0 +- **连接池配置**: 最大空闲连接数不能大于最大连接数 + +### 验证失败处理 + +- 配置验证失败时,应用无法启动 +- 提供详细的中文错误信息 +- 帮助快速定位配置问题 + +## 📖 使用指南 + +### 1. 启动应用 + +```bash +# 使用默认环境 (development) +go run cmd/api/main.go + +# 指定环境 +CONFIG_ENV=production go run cmd/api/main.go +ENV=testing go run cmd/api/main.go +APP_ENV=production go run cmd/api/main.go +``` + +### 2. 添加新的配置项 + +1. 在 `config.yaml` 中添加默认值 +2. 在 `internal/config/config.go` 中定义对应的结构体字段 +3. 在环境配置文件中覆盖特定值(如需要) + +### 3. 环境变量覆盖 + +```bash +# 覆盖数据库密码 +export DATABASE_PASSWORD="your-secure-password" + +# 覆盖JWT密钥 +export JWT_SECRET="your-super-secret-jwt-key" + +# 覆盖服务器端口 +export SERVER_PORT="9090" +``` + +### 4. 添加新的环境 + +1. 创建 `configs/env.{new_env}.yaml` 文件 +2. 在 `getEnvironment()` 函数中添加环境验证 +3. 配置相应的环境特定设置 + +## 🏆 最佳实践 + +### 1. 配置文件管理 + +- ✅ **基础配置**: 在 `config.yaml` 中设置合理的默认值 +- ✅ **环境配置**: 只在环境文件中覆盖必要的配置项 +- ✅ **敏感信息**: 通过环境变量注入,不要写在配置文件中 +- ✅ **版本控制**: 将配置文件纳入版本控制,但排除敏感信息 + +### 2. 环境变量使用 + +- ✅ **生产环境**: 所有敏感信息都通过环境变量注入 +- ✅ **开发环境**: 可以使用配置文件中的默认值 +- ✅ **测试环境**: 使用独立的测试配置 + +### 3. 配置验证 + +- ✅ **启动验证**: 应用启动时验证所有必要配置 +- ✅ **类型检查**: 确保配置值的类型正确 +- ✅ **逻辑验证**: 验证配置项之间的逻辑关系 + +### 4. 日志和监控 + +- ✅ **配置摘要**: 启动时输出关键配置信息 +- ✅ **环境标识**: 明确显示当前运行环境 +- ✅ **配置变更**: 记录重要的配置变更 + +## 🔧 故障排除 + +### 常见问题 + +#### 1. 配置文件未找到 + +``` +❌ 错误: 未找到 config.yaml 文件,请确保配置文件存在 +``` + +**解决方案**: 确保项目根目录下存在 `config.yaml` 文件 + +#### 2. 环境配置文件未找到 + +``` +ℹ️ 未找到环境配置文件 configs/env.development.yaml,将使用基础配置 +``` + +**解决方案**: + +- 检查环境变量设置是否正确 +- 确认 `configs/env.{environment}.yaml` 文件存在 + +#### 3. 配置验证失败 + +``` +❌ 错误: 配置验证失败: 数据库主机地址不能为空 +``` + +**解决方案**: + +- 检查 `config.yaml` 中的数据库配置 +- 确认环境配置文件中的覆盖值正确 + +#### 4. JWT 密钥安全问题 + +``` +❌ 错误: 生产环境必须设置安全的JWT密钥 +``` + +**解决方案**: + +- 通过环境变量设置安全的 JWT 密钥 +- 不要使用默认的测试密钥 + +### 调试技巧 + +#### 1. 查看配置摘要 + +启动时查看配置摘要输出,确认: + +- 当前运行环境 +- 使用的配置文件 +- 关键配置值 + +#### 2. 环境变量检查 + +```bash +# 检查环境变量 +echo $CONFIG_ENV +echo $ENV +echo $APP_ENV +``` + +#### 3. 配置文件语法检查 + +```bash +# 检查YAML语法 +yamllint config.yaml +yamllint configs/env.development.yaml +``` + +## 📚 相关文件 + +- `internal/config/config.go` - 配置结构体定义 +- `internal/config/loader.go` - 配置加载逻辑 +- `config.yaml` - 基础配置文件 +- `configs/env.*.yaml` - 环境特定配置文件 + +## 🔄 更新日志 + +### v1.0.0 + +- 实现基础的分层配置策略 +- 支持多环境配置 +- 添加配置验证机制 +- 实现环境变量覆盖功能 + +--- + +**注意**: 本配置系统遵循中文规范,所有面向用户的错误信息和日志都使用中文。 diff --git a/internal/config/config.go b/internal/config/config.go index ec7c371..240d6e8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,7 @@ type Config struct { Resilience ResilienceConfig `mapstructure:"resilience"` Development DevelopmentConfig `mapstructure:"development"` App AppConfig `mapstructure:"app"` + WechatWork WechatWorkConfig `mapstructure:"wechat_work"` } // ServerConfig HTTP服务器配置 @@ -187,3 +188,9 @@ func (a AppConfig) IsDevelopment() bool { func (a AppConfig) IsStaging() bool { return a.Env == "staging" } + +// WechatWorkConfig 企业微信配置 +type WechatWorkConfig struct { + WebhookURL string `mapstructure:"webhook_url"` + Secret string `mapstructure:"secret"` +} diff --git a/internal/config/loader.go b/internal/config/loader.go index 786b8f2..7d78e49 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -51,13 +51,11 @@ func LoadConfig() (*Config, error) { } } } else { - fmt.Printf("⚠️ 未找到环境配置文件 env.%s.yaml\n", env) + fmt.Printf("ℹ️ 未找到环境配置文件 configs/env.%s.yaml,将使用基础配置\n", env) } - // 4️⃣ 设置环境变量前缀和自动读取 - baseConfig.SetEnvPrefix("") - baseConfig.AutomaticEnv() - baseConfig.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + // 4️⃣ 手动处理环境变量覆盖,避免空值覆盖配置文件 + // overrideWithEnvVars(baseConfig) // 5️⃣ 解析配置到结构体 var config Config @@ -99,19 +97,10 @@ func mergeConfigs(baseConfig *viper.Viper, overrideSettings map[string]interface // findEnvConfigFile 查找环境特定的配置文件 func findEnvConfigFile(env string) string { - // 尝试查找的配置文件路径 + // 只查找 configs 目录下的环境配置文件 possiblePaths := []string{ fmt.Sprintf("configs/env.%s.yaml", env), fmt.Sprintf("configs/env.%s.yml", env), - fmt.Sprintf("configs/env.%s", env), - fmt.Sprintf("env.%s.yaml", env), - fmt.Sprintf("env.%s.yml", env), - fmt.Sprintf("env.%s", env), - } - - // 如果有自定义环境文件路径 - if customEnvFile := os.Getenv("ENV_FILE"); customEnvFile != "" { - possiblePaths = append([]string{customEnvFile}, possiblePaths...) } for _, path := range possiblePaths { @@ -165,7 +154,8 @@ func getEnvironment() string { func printConfigSummary(config *Config, env string) { fmt.Printf("\n🔧 配置摘要:\n") fmt.Printf(" 🌍 环境: %s\n", env) - fmt.Printf(" 📄 配置模板: config.yaml\n") + fmt.Printf(" 📄 基础配置: config.yaml\n") + fmt.Printf(" 📁 环境配置: configs/env.%s.yaml\n", env) fmt.Printf(" 📱 应用名称: %s\n", config.App.Name) fmt.Printf(" 🔖 版本: %s\n", config.App.Version) fmt.Printf(" 🌐 服务端口: %s\n", config.Server.Port) @@ -244,6 +234,26 @@ func ParseDuration(s string) time.Duration { return d } +// overrideWithEnvVars 手动处理环境变量覆盖,避免空值覆盖配置文件 +func overrideWithEnvVars(config *viper.Viper) { + // 定义需要环境变量覆盖的敏感配置项 + sensitiveConfigs := map[string]string{ + "database.password": "DATABASE_PASSWORD", + "jwt.secret": "JWT_SECRET", + "redis.password": "REDIS_PASSWORD", + "wechat_work.webhook_url": "WECHAT_WORK_WEBHOOK_URL", + "wechat_work.secret": "WECHAT_WORK_SECRET", + } + + // 只覆盖明确设置的环境变量 + for configKey, envKey := range sensitiveConfigs { + if envValue := os.Getenv(envKey); envValue != "" { + config.Set(configKey, envValue) + fmt.Printf("🔐 已从环境变量覆盖配置: %s\n", configKey) + } + } +} + // SplitAndTrim 分割字符串并去除空格 func SplitAndTrim(s, sep string) []string { parts := strings.Split(s, sep) diff --git a/internal/domains/admin/dto/admin_dto.go b/internal/domains/admin/dto/admin_dto.go new file mode 100644 index 0000000..18931bc --- /dev/null +++ b/internal/domains/admin/dto/admin_dto.go @@ -0,0 +1,178 @@ +package dto + +import ( + "time" + + "tyapi-server/internal/domains/admin/entities" +) + +// AdminLoginRequest 管理员登录请求 +type AdminLoginRequest struct { + Username string `json:"username" binding:"required"` // 用户名 + Password string `json:"password" binding:"required"` // 密码 +} + +// AdminLoginResponse 管理员登录响应 +type AdminLoginResponse struct { + Token string `json:"token"` // JWT令牌 + ExpiresAt time.Time `json:"expires_at"` // 过期时间 + Admin AdminInfo `json:"admin"` // 管理员信息 +} + +// AdminInfo 管理员信息 +type AdminInfo struct { + ID string `json:"id"` // 管理员ID + Username string `json:"username"` // 用户名 + Email string `json:"email"` // 邮箱 + Phone string `json:"phone"` // 手机号 + RealName string `json:"real_name"` // 真实姓名 + Role entities.AdminRole `json:"role"` // 角色 + IsActive bool `json:"is_active"` // 是否激活 + LastLoginAt *time.Time `json:"last_login_at"` // 最后登录时间 + LoginCount int `json:"login_count"` // 登录次数 + Permissions []string `json:"permissions"` // 权限列表 + CreatedAt time.Time `json:"created_at"` // 创建时间 +} + +// AdminCreateRequest 创建管理员请求 +type AdminCreateRequest struct { + Username string `json:"username" binding:"required"` // 用户名 + Password string `json:"password" binding:"required"` // 密码 + Email string `json:"email" binding:"required,email"` // 邮箱 + Phone string `json:"phone"` // 手机号 + RealName string `json:"real_name" binding:"required"` // 真实姓名 + Role entities.AdminRole `json:"role" binding:"required"` // 角色 + Permissions []string `json:"permissions"` // 权限列表 +} + +// AdminUpdateRequest 更新管理员请求 +type AdminUpdateRequest struct { + Email string `json:"email" binding:"email"` // 邮箱 + Phone string `json:"phone"` // 手机号 + RealName string `json:"real_name"` // 真实姓名 + Role entities.AdminRole `json:"role"` // 角色 + IsActive *bool `json:"is_active"` // 是否激活 + Permissions []string `json:"permissions"` // 权限列表 +} + +// AdminPasswordChangeRequest 修改密码请求 +type AdminPasswordChangeRequest struct { + OldPassword string `json:"old_password" binding:"required"` // 旧密码 + NewPassword string `json:"new_password" binding:"required"` // 新密码 +} + +// AdminListRequest 管理员列表请求 +type AdminListRequest struct { + Page int `form:"page" binding:"min=1"` // 页码 + PageSize int `form:"page_size" binding:"min=1,max=100"` // 每页数量 + Username string `form:"username"` // 用户名搜索 + Email string `form:"email"` // 邮箱搜索 + Role string `form:"role"` // 角色筛选 + IsActive *bool `form:"is_active"` // 状态筛选 +} + +// AdminListResponse 管理员列表响应 +type AdminListResponse struct { + Total int64 `json:"total"` // 总数 + Page int `json:"page"` // 当前页 + Size int `json:"size"` // 每页数量 + Admins []AdminInfo `json:"admins"` // 管理员列表 +} + +// AdminStatsResponse 管理员统计响应 +type AdminStatsResponse struct { + TotalAdmins int64 `json:"total_admins"` // 总管理员数 + ActiveAdmins int64 `json:"active_admins"` // 激活管理员数 + TodayLogins int64 `json:"today_logins"` // 今日登录数 + TotalOperations int64 `json:"total_operations"` // 总操作数 +} + +// AdminOperationLogRequest 操作日志请求 +type AdminOperationLogRequest struct { + Page int `form:"page" binding:"min=1"` // 页码 + PageSize int `form:"page_size" binding:"min=1,max=100"` // 每页数量 + AdminID string `form:"admin_id"` // 管理员ID + Action string `form:"action"` // 操作类型 + Resource string `form:"resource"` // 操作资源 + Status string `form:"status"` // 操作状态 + StartTime time.Time `form:"start_time"` // 开始时间 + EndTime time.Time `form:"end_time"` // 结束时间 +} + +// AdminOperationLogResponse 操作日志响应 +type AdminOperationLogResponse struct { + Total int64 `json:"total"` // 总数 + Page int `json:"page"` // 当前页 + Size int `json:"size"` // 每页数量 + Logs []AdminOperationLogInfo `json:"logs"` // 日志列表 +} + +// AdminOperationLogInfo 操作日志信息 +type AdminOperationLogInfo struct { + ID string `json:"id"` // 日志ID + AdminID string `json:"admin_id"` // 管理员ID + Username string `json:"username"` // 用户名 + Action string `json:"action"` // 操作类型 + Resource string `json:"resource"` // 操作资源 + ResourceID string `json:"resource_id"` // 资源ID + Details string `json:"details"` // 操作详情 + IP string `json:"ip"` // IP地址 + UserAgent string `json:"user_agent"` // 用户代理 + Status string `json:"status"` // 操作状态 + Message string `json:"message"` // 操作消息 + CreatedAt time.Time `json:"created_at"` // 创建时间 +} + +// AdminLoginLogRequest 登录日志请求 +type AdminLoginLogRequest struct { + Page int `form:"page" binding:"min=1"` // 页码 + PageSize int `form:"page_size" binding:"min=1,max=100"` // 每页数量 + AdminID string `form:"admin_id"` // 管理员ID + Username string `form:"username"` // 用户名 + Status string `form:"status"` // 登录状态 + StartTime time.Time `form:"start_time"` // 开始时间 + EndTime time.Time `form:"end_time"` // 结束时间 +} + +// AdminLoginLogResponse 登录日志响应 +type AdminLoginLogResponse struct { + Total int64 `json:"total"` // 总数 + Page int `json:"page"` // 当前页 + Size int `json:"size"` // 每页数量 + Logs []AdminLoginLogInfo `json:"logs"` // 日志列表 +} + +// AdminLoginLogInfo 登录日志信息 +type AdminLoginLogInfo struct { + ID string `json:"id"` // 日志ID + AdminID string `json:"admin_id"` // 管理员ID + Username string `json:"username"` // 用户名 + IP string `json:"ip"` // IP地址 + UserAgent string `json:"user_agent"` // 用户代理 + Status string `json:"status"` // 登录状态 + Message string `json:"message"` // 登录消息 + CreatedAt time.Time `json:"created_at"` // 创建时间 +} + +// PermissionInfo 权限信息 +type PermissionInfo struct { + ID string `json:"id"` // 权限ID + Name string `json:"name"` // 权限名称 + Code string `json:"code"` // 权限代码 + Description string `json:"description"` // 权限描述 + Module string `json:"module"` // 所属模块 + IsActive bool `json:"is_active"` // 是否激活 + CreatedAt time.Time `json:"created_at"` // 创建时间 +} + +// RolePermissionRequest 角色权限请求 +type RolePermissionRequest struct { + Role entities.AdminRole `json:"role" binding:"required"` // 角色 + PermissionIDs []string `json:"permission_ids" binding:"required"` // 权限ID列表 +} + +// RolePermissionResponse 角色权限响应 +type RolePermissionResponse struct { + Role entities.AdminRole `json:"role"` // 角色 + Permissions []PermissionInfo `json:"permissions"` // 权限列表 +} diff --git a/internal/domains/admin/entities/admin.go b/internal/domains/admin/entities/admin.go new file mode 100644 index 0000000..825e392 --- /dev/null +++ b/internal/domains/admin/entities/admin.go @@ -0,0 +1,147 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +// AdminRole 管理员角色枚举 +// 定义系统中不同级别的管理员角色,用于权限控制和功能分配 +type AdminRole string + +const ( + RoleSuperAdmin AdminRole = "super_admin" // 超级管理员 - 拥有所有权限 + RoleAdmin AdminRole = "admin" // 普通管理员 - 拥有大部分管理权限 + RoleReviewer AdminRole = "reviewer" // 审核员 - 仅拥有审核相关权限 +) + +// Admin 管理员实体 +// 系统管理员的核心信息,包括账户信息、权限配置、操作统计等 +// 支持多角色管理,提供完整的权限控制和操作审计功能 +type Admin struct { + // 基础标识 + ID string `gorm:"primaryKey;type:varchar(36)" comment:"管理员唯一标识"` + Username string `gorm:"type:varchar(100);not null;uniqueIndex" comment:"登录用户名"` + Password string `gorm:"type:varchar(255);not null" comment:"登录密码(加密存储)"` + Email string `gorm:"type:varchar(255);not null;uniqueIndex" comment:"邮箱地址"` + Phone string `gorm:"type:varchar(20)" comment:"手机号码"` + RealName string `gorm:"type:varchar(100);not null" comment:"真实姓名"` + Role AdminRole `gorm:"type:varchar(50);not null;default:'reviewer'" comment:"管理员角色"` + + // 状态信息 - 账户状态和登录统计 + IsActive bool `gorm:"default:true" comment:"账户是否激活"` + LastLoginAt *time.Time `comment:"最后登录时间"` + LoginCount int `gorm:"default:0" comment:"登录次数统计"` + + // 权限信息 - 细粒度权限控制 + Permissions string `gorm:"type:text" comment:"权限列表(JSON格式存储)"` + + // 审核统计 - 管理员的工作绩效统计 + ReviewCount int `gorm:"default:0" comment:"审核总数"` + ApprovedCount int `gorm:"default:0" comment:"通过数量"` + RejectedCount int `gorm:"default:0" comment:"拒绝数量"` + + // 时间戳字段 + CreatedAt time.Time `gorm:"autoCreateTime" comment:"创建时间"` + UpdatedAt time.Time `gorm:"autoUpdateTime" comment:"更新时间"` + DeletedAt gorm.DeletedAt `gorm:"index" comment:"软删除时间"` +} + +// AdminLoginLog 管理员登录日志实体 +// 记录管理员的所有登录尝试,包括成功和失败的登录记录 +// 用于安全审计和异常登录检测 +type AdminLoginLog struct { + // 基础标识 + ID string `gorm:"primaryKey;type:varchar(36)" comment:"日志记录唯一标识"` + AdminID string `gorm:"type:varchar(36);not null;index" comment:"管理员ID"` + Username string `gorm:"type:varchar(100);not null" comment:"登录用户名"` + IP string `gorm:"type:varchar(45);not null" comment:"登录IP地址"` + UserAgent string `gorm:"type:varchar(500)" comment:"客户端信息"` + Status string `gorm:"type:varchar(20);not null" comment:"登录状态(success/failed)"` + Message string `gorm:"type:varchar(500)" comment:"登录结果消息"` + + // 时间戳字段 + CreatedAt time.Time `gorm:"autoCreateTime" comment:"创建时间"` +} + +// AdminOperationLog 管理员操作日志实体 +// 记录管理员在系统中的所有重要操作,用于操作审计和问题追踪 +// 支持操作类型、资源、详情等完整信息的记录 +type AdminOperationLog struct { + // 基础标识 + ID string `gorm:"primaryKey;type:varchar(36)" comment:"操作日志唯一标识"` + AdminID string `gorm:"type:varchar(36);not null;index" comment:"操作管理员ID"` + Username string `gorm:"type:varchar(100);not null" comment:"操作管理员用户名"` + Action string `gorm:"type:varchar(100);not null" comment:"操作类型"` + Resource string `gorm:"type:varchar(100);not null" comment:"操作资源"` + ResourceID string `gorm:"type:varchar(36)" comment:"资源ID"` + Details string `gorm:"type:text" comment:"操作详情(JSON格式)"` + IP string `gorm:"type:varchar(45);not null" comment:"操作IP地址"` + UserAgent string `gorm:"type:varchar(500)" comment:"客户端信息"` + Status string `gorm:"type:varchar(20);not null" comment:"操作状态(success/failed)"` + Message string `gorm:"type:varchar(500)" comment:"操作结果消息"` + + // 时间戳字段 + CreatedAt time.Time `gorm:"autoCreateTime" comment:"创建时间"` +} + +// AdminPermission 管理员权限实体 +// 定义系统中的所有权限项,支持模块化权限管理 +// 每个权限都有唯一的代码标识,便于程序中的权限检查 +type AdminPermission struct { + // 基础标识 + ID string `gorm:"primaryKey;type:varchar(36)" comment:"权限唯一标识"` + Name string `gorm:"type:varchar(100);not null;uniqueIndex" comment:"权限名称"` + Code string `gorm:"type:varchar(100);not null;uniqueIndex" comment:"权限代码"` + Description string `gorm:"type:varchar(500)" comment:"权限描述"` + Module string `gorm:"type:varchar(50);not null" comment:"所属模块"` + IsActive bool `gorm:"default:true" comment:"权限是否启用"` + + // 时间戳字段 + CreatedAt time.Time `gorm:"autoCreateTime" comment:"创建时间"` + UpdatedAt time.Time `gorm:"autoUpdateTime" comment:"更新时间"` + DeletedAt gorm.DeletedAt `gorm:"index" comment:"软删除时间"` +} + +// AdminRolePermission 角色权限关联实体 +// 建立角色和权限之间的多对多关系,实现基于角色的权限控制(RBAC) +type AdminRolePermission struct { + // 基础标识 + ID string `gorm:"primaryKey;type:varchar(36)" comment:"关联记录唯一标识"` + Role AdminRole `gorm:"type:varchar(50);not null;index" comment:"角色"` + PermissionID string `gorm:"type:varchar(36);not null;index" comment:"权限ID"` + + // 时间戳字段 + CreatedAt time.Time `gorm:"autoCreateTime" comment:"创建时间"` +} + +// TableName 指定数据库表名 +func (Admin) TableName() string { + return "admins" +} + +// IsValid 检查管理员账户是否有效 +// 判断管理员账户是否处于可用状态,包括激活状态和软删除状态检查 +func (a *Admin) IsValid() bool { + return a.IsActive && a.DeletedAt.Time.IsZero() +} + +// UpdateLastLoginAt 更新最后登录时间 +// 在管理员成功登录后调用,记录最新的登录时间 +func (a *Admin) UpdateLastLoginAt() { + now := time.Now() + a.LastLoginAt = &now +} + +// Deactivate 停用管理员账户 +// 将管理员账户设置为非激活状态,禁止登录和操作 +func (a *Admin) Deactivate() { + a.IsActive = false +} + +// Activate 激活管理员账户 +// 重新启用管理员账户,允许正常登录和操作 +func (a *Admin) Activate() { + a.IsActive = true +} diff --git a/internal/domains/admin/handlers/admin_handler.go b/internal/domains/admin/handlers/admin_handler.go new file mode 100644 index 0000000..c0f8519 --- /dev/null +++ b/internal/domains/admin/handlers/admin_handler.go @@ -0,0 +1,313 @@ +package handlers + +import ( + "strconv" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "tyapi-server/internal/domains/admin/dto" + "tyapi-server/internal/domains/admin/services" + "tyapi-server/internal/shared/interfaces" +) + +// AdminHandler 管理员HTTP处理器 +type AdminHandler struct { + adminService *services.AdminService + responseBuilder interfaces.ResponseBuilder + logger *zap.Logger +} + +// NewAdminHandler 创建管理员HTTP处理器 +func NewAdminHandler( + adminService *services.AdminService, + responseBuilder interfaces.ResponseBuilder, + logger *zap.Logger, +) *AdminHandler { + return &AdminHandler{ + adminService: adminService, + responseBuilder: responseBuilder, + logger: logger, + } +} + +// Login 管理员登录 +// @Summary 管理员登录 +// @Description 管理员登录接口 +// @Tags 管理员认证 +// @Accept json +// @Produce json +// @Param request body dto.AdminLoginRequest true "登录请求" +// @Success 200 {object} dto.AdminLoginResponse +// @Failure 400 {object} interfaces.ErrorResponse +// @Failure 401 {object} interfaces.ErrorResponse +// @Router /admin/login [post] +func (h *AdminHandler) Login(c *gin.Context) { + var req dto.AdminLoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Warn("管理员登录参数验证失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, "请求参数错误") + return + } + + // 获取客户端信息 + clientIP := c.ClientIP() + userAgent := c.GetHeader("User-Agent") + + // 调用服务 + response, err := h.adminService.Login(c.Request.Context(), &req, clientIP, userAgent) + if err != nil { + h.logger.Error("管理员登录失败", zap.Error(err)) + h.responseBuilder.Unauthorized(c, err.Error()) + return + } + + h.responseBuilder.Success(c, response, "登录成功") +} + +// CreateAdmin 创建管理员 +// @Summary 创建管理员 +// @Description 创建新管理员账户 +// @Tags 管理员管理 +// @Accept json +// @Produce json +// @Param request body dto.AdminCreateRequest true "创建管理员请求" +// @Success 201 {object} interfaces.SuccessResponse +// @Failure 400 {object} interfaces.ErrorResponse +// @Failure 403 {object} interfaces.ErrorResponse +// @Router /admin [post] +func (h *AdminHandler) CreateAdmin(c *gin.Context) { + var req dto.AdminCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Warn("创建管理员参数验证失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, "请求参数错误") + return + } + + // 获取当前操作的管理员ID(从JWT中解析) + operatorID := h.getCurrentAdminID(c) + + // 调用服务 + err := h.adminService.CreateAdmin(c.Request.Context(), &req, operatorID) + if err != nil { + h.logger.Error("创建管理员失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, err.Error()) + return + } + + h.responseBuilder.Created(c, nil, "管理员创建成功") +} + +// UpdateAdmin 更新管理员 +// @Summary 更新管理员 +// @Description 更新管理员信息 +// @Tags 管理员管理 +// @Accept json +// @Produce json +// @Param id path string true "管理员ID" +// @Param request body dto.AdminUpdateRequest true "更新管理员请求" +// @Success 200 {object} interfaces.SuccessResponse +// @Failure 400 {object} interfaces.ErrorResponse +// @Failure 404 {object} interfaces.ErrorResponse +// @Router /admin/{id} [put] +func (h *AdminHandler) UpdateAdmin(c *gin.Context) { + adminID := c.Param("id") + if adminID == "" { + h.responseBuilder.BadRequest(c, "管理员ID不能为空") + return + } + + var req dto.AdminUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Warn("更新管理员参数验证失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, "请求参数错误") + return + } + + // 获取当前操作的管理员ID + operatorID := h.getCurrentAdminID(c) + + // 调用服务 + err := h.adminService.UpdateAdmin(c.Request.Context(), adminID, &req, operatorID) + if err != nil { + h.logger.Error("更新管理员失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, err.Error()) + return + } + + h.responseBuilder.Success(c, nil, "管理员更新成功") +} + +// ChangePassword 修改密码 +// @Summary 修改密码 +// @Description 管理员修改自己的密码 +// @Tags 管理员管理 +// @Accept json +// @Produce json +// @Param request body dto.AdminPasswordChangeRequest true "修改密码请求" +// @Success 200 {object} interfaces.SuccessResponse +// @Failure 400 {object} interfaces.ErrorResponse +// @Router /admin/change-password [post] +func (h *AdminHandler) ChangePassword(c *gin.Context) { + var req dto.AdminPasswordChangeRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Warn("修改密码参数验证失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, "请求参数错误") + return + } + + // 获取当前管理员ID + adminID := h.getCurrentAdminID(c) + + // 调用服务 + err := h.adminService.ChangePassword(c.Request.Context(), adminID, &req) + if err != nil { + h.logger.Error("修改密码失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, err.Error()) + return + } + + h.responseBuilder.Success(c, nil, "密码修改成功") +} + +// ListAdmins 获取管理员列表 +// @Summary 获取管理员列表 +// @Description 分页获取管理员列表 +// @Tags 管理员管理 +// @Accept json +// @Produce json +// @Param page query int false "页码" default(1) +// @Param page_size query int false "每页数量" default(10) +// @Param username query string false "用户名搜索" +// @Param email query string false "邮箱搜索" +// @Param role query string false "角色筛选" +// @Param is_active query bool false "状态筛选" +// @Success 200 {object} dto.AdminListResponse +// @Failure 400 {object} interfaces.ErrorResponse +// @Router /admin [get] +func (h *AdminHandler) ListAdmins(c *gin.Context) { + var req dto.AdminListRequest + + // 解析查询参数 + if page, err := strconv.Atoi(c.DefaultQuery("page", "1")); err == nil { + req.Page = page + } else { + req.Page = 1 + } + + if pageSize, err := strconv.Atoi(c.DefaultQuery("page_size", "10")); err == nil { + req.PageSize = pageSize + } else { + req.PageSize = 10 + } + + req.Username = c.Query("username") + req.Email = c.Query("email") + req.Role = c.Query("role") + + if isActiveStr := c.Query("is_active"); isActiveStr != "" { + if isActive, err := strconv.ParseBool(isActiveStr); err == nil { + req.IsActive = &isActive + } + } + + // 调用服务 + response, err := h.adminService.ListAdmins(c.Request.Context(), &req) + if err != nil { + h.logger.Error("获取管理员列表失败", zap.Error(err)) + h.responseBuilder.InternalError(c, "获取管理员列表失败") + return + } + + h.responseBuilder.Success(c, response, "获取管理员列表成功") +} + +// GetAdminByID 根据ID获取管理员 +// @Summary 获取管理员详情 +// @Description 根据ID获取管理员详细信息 +// @Tags 管理员管理 +// @Accept json +// @Produce json +// @Param id path string true "管理员ID" +// @Success 200 {object} dto.AdminInfo +// @Failure 400 {object} interfaces.ErrorResponse +// @Failure 404 {object} interfaces.ErrorResponse +// @Router /admin/{id} [get] +func (h *AdminHandler) GetAdminByID(c *gin.Context) { + adminID := c.Param("id") + if adminID == "" { + h.responseBuilder.BadRequest(c, "管理员ID不能为空") + return + } + + // 调用服务 + admin, err := h.adminService.GetAdminByID(c.Request.Context(), adminID) + if err != nil { + h.logger.Error("获取管理员详情失败", zap.Error(err)) + h.responseBuilder.NotFound(c, err.Error()) + return + } + + h.responseBuilder.Success(c, admin, "获取管理员详情成功") +} + +// DeleteAdmin 删除管理员 +// @Summary 删除管理员 +// @Description 软删除管理员账户 +// @Tags 管理员管理 +// @Accept json +// @Produce json +// @Param id path string true "管理员ID" +// @Success 200 {object} interfaces.SuccessResponse +// @Failure 400 {object} interfaces.ErrorResponse +// @Failure 404 {object} interfaces.ErrorResponse +// @Router /admin/{id} [delete] +func (h *AdminHandler) DeleteAdmin(c *gin.Context) { + adminID := c.Param("id") + if adminID == "" { + h.responseBuilder.BadRequest(c, "管理员ID不能为空") + return + } + + // 获取当前操作的管理员ID + operatorID := h.getCurrentAdminID(c) + + // 调用服务 + err := h.adminService.DeleteAdmin(c.Request.Context(), adminID, operatorID) + if err != nil { + h.logger.Error("删除管理员失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, err.Error()) + return + } + + h.responseBuilder.Success(c, nil, "管理员删除成功") +} + +// GetAdminStats 获取管理员统计信息 +// @Summary 获取管理员统计 +// @Description 获取管理员相关的统计信息 +// @Tags 管理员管理 +// @Accept json +// @Produce json +// @Success 200 {object} dto.AdminStatsResponse +// @Failure 400 {object} interfaces.ErrorResponse +// @Router /admin/stats [get] +func (h *AdminHandler) GetAdminStats(c *gin.Context) { + // 调用服务 + stats, err := h.adminService.GetAdminStats(c.Request.Context()) + if err != nil { + h.logger.Error("获取管理员统计失败", zap.Error(err)) + h.responseBuilder.InternalError(c, "获取统计信息失败") + return + } + + h.responseBuilder.Success(c, stats, "获取统计信息成功") +} + +// getCurrentAdminID 获取当前管理员ID +func (h *AdminHandler) getCurrentAdminID(c *gin.Context) string { + // 这里应该从JWT令牌中解析出管理员ID + // 为了简化,这里返回一个模拟的ID + // 实际实现中应该从中间件中获取 + return "current_admin_id" +} diff --git a/internal/domains/admin/repositories/admin_repository.go b/internal/domains/admin/repositories/admin_repository.go new file mode 100644 index 0000000..7664775 --- /dev/null +++ b/internal/domains/admin/repositories/admin_repository.go @@ -0,0 +1,72 @@ +package repositories + +import ( + "context" + + "tyapi-server/internal/domains/admin/dto" + "tyapi-server/internal/domains/admin/entities" + "tyapi-server/internal/shared/interfaces" +) + +// AdminRepository 管理员仓储接口 +type AdminRepository interface { + interfaces.Repository[entities.Admin] + + // 管理员认证 + FindByUsername(ctx context.Context, username string) (*entities.Admin, error) + FindByEmail(ctx context.Context, email string) (*entities.Admin, error) + + // 管理员管理 + ListAdmins(ctx context.Context, req *dto.AdminListRequest) (*dto.AdminListResponse, error) + GetStats(ctx context.Context) (*dto.AdminStatsResponse, error) + + // 权限管理 + GetPermissionsByRole(ctx context.Context, role entities.AdminRole) ([]entities.AdminPermission, error) + UpdatePermissions(ctx context.Context, adminID string, permissions []string) error + + // 统计信息 + UpdateLoginStats(ctx context.Context, adminID string) error + UpdateReviewStats(ctx context.Context, adminID string, approved bool) error +} + +// AdminLoginLogRepository 管理员登录日志仓储接口 +type AdminLoginLogRepository interface { + interfaces.Repository[entities.AdminLoginLog] + + // 日志查询 + ListLogs(ctx context.Context, req *dto.AdminLoginLogRequest) (*dto.AdminLoginLogResponse, error) + + // 统计查询 + GetTodayLoginCount(ctx context.Context) (int64, error) + GetLoginCountByAdmin(ctx context.Context, adminID string, days int) (int64, error) +} + +// AdminOperationLogRepository 管理员操作日志仓储接口 +type AdminOperationLogRepository interface { + interfaces.Repository[entities.AdminOperationLog] + + // 日志查询 + ListLogs(ctx context.Context, req *dto.AdminOperationLogRequest) (*dto.AdminOperationLogResponse, error) + + // 统计查询 + GetTotalOperations(ctx context.Context) (int64, error) + GetOperationsByAdmin(ctx context.Context, adminID string, days int) (int64, error) + + // 批量操作 + BatchCreate(ctx context.Context, logs []entities.AdminOperationLog) error +} + +// AdminPermissionRepository 管理员权限仓储接口 +type AdminPermissionRepository interface { + interfaces.Repository[entities.AdminPermission] + + // 权限查询 + FindByCode(ctx context.Context, code string) (*entities.AdminPermission, error) + FindByModule(ctx context.Context, module string) ([]entities.AdminPermission, error) + ListActive(ctx context.Context) ([]entities.AdminPermission, error) + + // 角色权限管理 + GetPermissionsByRole(ctx context.Context, role entities.AdminRole) ([]entities.AdminPermission, error) + AssignPermissionsToRole(ctx context.Context, role entities.AdminRole, permissionIDs []string) error + RemovePermissionsFromRole(ctx context.Context, role entities.AdminRole, permissionIDs []string) error +} diff --git a/internal/domains/admin/repositories/gorm_admin_repository.go b/internal/domains/admin/repositories/gorm_admin_repository.go new file mode 100644 index 0000000..9b82958 --- /dev/null +++ b/internal/domains/admin/repositories/gorm_admin_repository.go @@ -0,0 +1,341 @@ +package repositories + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + + "tyapi-server/internal/domains/admin/dto" + "tyapi-server/internal/domains/admin/entities" + "tyapi-server/internal/shared/interfaces" +) + +// GormAdminRepository 管理员GORM仓储实现 +type GormAdminRepository struct { + db *gorm.DB + logger *zap.Logger +} + +// NewGormAdminRepository 创建管理员GORM仓储 +func NewGormAdminRepository(db *gorm.DB, logger *zap.Logger) *GormAdminRepository { + return &GormAdminRepository{ + db: db, + logger: logger, + } +} + +// Create 创建管理员 +func (r *GormAdminRepository) Create(ctx context.Context, admin entities.Admin) error { + r.logger.Info("创建管理员", zap.String("username", admin.Username)) + return r.db.WithContext(ctx).Create(&admin).Error +} + +// GetByID 根据ID获取管理员 +func (r *GormAdminRepository) GetByID(ctx context.Context, id string) (entities.Admin, error) { + var admin entities.Admin + err := r.db.WithContext(ctx).Where("id = ?", id).First(&admin).Error + return admin, err +} + +// Update 更新管理员 +func (r *GormAdminRepository) Update(ctx context.Context, admin entities.Admin) error { + r.logger.Info("更新管理员", zap.String("id", admin.ID)) + return r.db.WithContext(ctx).Save(&admin).Error +} + +// Delete 删除管理员 +func (r *GormAdminRepository) Delete(ctx context.Context, id string) error { + r.logger.Info("删除管理员", zap.String("id", id)) + return r.db.WithContext(ctx).Delete(&entities.Admin{}, "id = ?", id).Error +} + +// SoftDelete 软删除管理员 +func (r *GormAdminRepository) SoftDelete(ctx context.Context, id string) error { + r.logger.Info("软删除管理员", zap.String("id", id)) + return r.db.WithContext(ctx).Delete(&entities.Admin{}, "id = ?", id).Error +} + +// Restore 恢复管理员 +func (r *GormAdminRepository) Restore(ctx context.Context, id string) error { + r.logger.Info("恢复管理员", zap.String("id", id)) + return r.db.WithContext(ctx).Unscoped().Model(&entities.Admin{}).Where("id = ?", id).Update("deleted_at", nil).Error +} + +// Count 统计管理员数量 +func (r *GormAdminRepository) Count(ctx context.Context, options interfaces.CountOptions) (int64, error) { + var count int64 + query := r.db.WithContext(ctx).Model(&entities.Admin{}) + + // 应用过滤条件 + if options.Filters != nil { + for key, value := range options.Filters { + query = query.Where(key+" = ?", value) + } + } + + // 应用搜索条件 + if options.Search != "" { + query = query.Where("username LIKE ? OR email LIKE ? OR real_name LIKE ?", + "%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%") + } + + return count, query.Count(&count).Error +} + +// Exists 检查管理员是否存在 +func (r *GormAdminRepository) Exists(ctx context.Context, id string) (bool, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entities.Admin{}).Where("id = ?", id).Count(&count).Error + return count > 0, err +} + +// CreateBatch 批量创建管理员 +func (r *GormAdminRepository) CreateBatch(ctx context.Context, admins []entities.Admin) error { + r.logger.Info("批量创建管理员", zap.Int("count", len(admins))) + return r.db.WithContext(ctx).Create(&admins).Error +} + +// GetByIDs 根据ID列表获取管理员 +func (r *GormAdminRepository) GetByIDs(ctx context.Context, ids []string) ([]entities.Admin, error) { + var admins []entities.Admin + err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&admins).Error + return admins, err +} + +// UpdateBatch 批量更新管理员 +func (r *GormAdminRepository) UpdateBatch(ctx context.Context, admins []entities.Admin) error { + r.logger.Info("批量更新管理员", zap.Int("count", len(admins))) + return r.db.WithContext(ctx).Save(&admins).Error +} + +// DeleteBatch 批量删除管理员 +func (r *GormAdminRepository) DeleteBatch(ctx context.Context, ids []string) error { + r.logger.Info("批量删除管理员", zap.Strings("ids", ids)) + return r.db.WithContext(ctx).Delete(&entities.Admin{}, "id IN ?", ids).Error +} + +// List 获取管理员列表 +func (r *GormAdminRepository) List(ctx context.Context, options interfaces.ListOptions) ([]entities.Admin, error) { + var admins []entities.Admin + query := r.db.WithContext(ctx).Model(&entities.Admin{}) + + // 应用过滤条件 + if options.Filters != nil { + for key, value := range options.Filters { + query = query.Where(key+" = ?", value) + } + } + + // 应用搜索条件 + if options.Search != "" { + query = query.Where("username LIKE ? OR email LIKE ? OR real_name LIKE ?", + "%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%") + } + + // 应用排序 + if options.Sort != "" { + order := "ASC" + if options.Order != "" { + order = options.Order + } + query = query.Order(options.Sort + " " + order) + } + + // 应用分页 + if options.Page > 0 && options.PageSize > 0 { + offset := (options.Page - 1) * options.PageSize + query = query.Offset(offset).Limit(options.PageSize) + } + + return admins, query.Find(&admins).Error +} + +// WithTx 使用事务 +func (r *GormAdminRepository) WithTx(tx interface{}) interfaces.Repository[entities.Admin] { + if gormTx, ok := tx.(*gorm.DB); ok { + return &GormAdminRepository{ + db: gormTx, + logger: r.logger, + } + } + return r +} + +// FindByUsername 根据用户名查找管理员 +func (r *GormAdminRepository) FindByUsername(ctx context.Context, username string) (*entities.Admin, error) { + var admin entities.Admin + err := r.db.WithContext(ctx).Where("username = ?", username).First(&admin).Error + if err != nil { + return nil, err + } + return &admin, nil +} + +// FindByEmail 根据邮箱查找管理员 +func (r *GormAdminRepository) FindByEmail(ctx context.Context, email string) (*entities.Admin, error) { + var admin entities.Admin + err := r.db.WithContext(ctx).Where("email = ?", email).First(&admin).Error + if err != nil { + return nil, err + } + return &admin, nil +} + +// ListAdmins 获取管理员列表(带分页和筛选) +func (r *GormAdminRepository) ListAdmins(ctx context.Context, req *dto.AdminListRequest) (*dto.AdminListResponse, error) { + var admins []entities.Admin + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.Admin{}) + + // 应用筛选条件 + if req.Username != "" { + query = query.Where("username LIKE ?", "%"+req.Username+"%") + } + if req.Email != "" { + query = query.Where("email LIKE ?", "%"+req.Email+"%") + } + if req.Role != "" { + query = query.Where("role = ?", req.Role) + } + if req.IsActive != nil { + query = query.Where("is_active = ?", *req.IsActive) + } + + // 统计总数 + if err := query.Count(&total).Error; err != nil { + return nil, err + } + + // 应用分页 + offset := (req.Page - 1) * req.PageSize + query = query.Offset(offset).Limit(req.PageSize) + + // 默认排序 + query = query.Order("created_at DESC") + + // 查询数据 + if err := query.Find(&admins).Error; err != nil { + return nil, err + } + + // 转换为DTO + adminInfos := make([]dto.AdminInfo, len(admins)) + for i, admin := range admins { + adminInfos[i] = r.convertToAdminInfo(admin) + } + + return &dto.AdminListResponse{ + Total: total, + Page: req.Page, + Size: req.PageSize, + Admins: adminInfos, + }, nil +} + +// GetStats 获取管理员统计信息 +func (r *GormAdminRepository) GetStats(ctx context.Context) (*dto.AdminStatsResponse, error) { + var stats dto.AdminStatsResponse + + // 总管理员数 + if err := r.db.WithContext(ctx).Model(&entities.Admin{}).Count(&stats.TotalAdmins).Error; err != nil { + return nil, err + } + + // 激活管理员数 + if err := r.db.WithContext(ctx).Model(&entities.Admin{}).Where("is_active = ?", true).Count(&stats.ActiveAdmins).Error; err != nil { + return nil, err + } + + // 今日登录数 + today := time.Now().Truncate(24 * time.Hour) + if err := r.db.WithContext(ctx).Model(&entities.AdminLoginLog{}).Where("created_at >= ?", today).Count(&stats.TodayLogins).Error; err != nil { + return nil, err + } + + // 总操作数 + if err := r.db.WithContext(ctx).Model(&entities.AdminOperationLog{}).Count(&stats.TotalOperations).Error; err != nil { + return nil, err + } + + return &stats, nil +} + +// GetPermissionsByRole 根据角色获取权限 +func (r *GormAdminRepository) GetPermissionsByRole(ctx context.Context, role entities.AdminRole) ([]entities.AdminPermission, error) { + var permissions []entities.AdminPermission + + query := r.db.WithContext(ctx). + Joins("JOIN admin_role_permissions ON admin_permissions.id = admin_role_permissions.permission_id"). + Where("admin_role_permissions.role = ? AND admin_permissions.is_active = ?", role, true) + + return permissions, query.Find(&permissions).Error +} + +// UpdatePermissions 更新管理员权限 +func (r *GormAdminRepository) UpdatePermissions(ctx context.Context, adminID string, permissions []string) error { + permissionsJSON, err := json.Marshal(permissions) + if err != nil { + return fmt.Errorf("序列化权限失败: %w", err) + } + + return r.db.WithContext(ctx). + Model(&entities.Admin{}). + Where("id = ?", adminID). + Update("permissions", string(permissionsJSON)).Error +} + +// UpdateLoginStats 更新登录统计 +func (r *GormAdminRepository) UpdateLoginStats(ctx context.Context, adminID string) error { + return r.db.WithContext(ctx). + Model(&entities.Admin{}). + Where("id = ?", adminID). + Updates(map[string]interface{}{ + "last_login_at": time.Now(), + "login_count": gorm.Expr("login_count + 1"), + }).Error +} + +// UpdateReviewStats 更新审核统计 +func (r *GormAdminRepository) UpdateReviewStats(ctx context.Context, adminID string, approved bool) error { + updates := map[string]interface{}{ + "review_count": gorm.Expr("review_count + 1"), + } + + if approved { + updates["approved_count"] = gorm.Expr("approved_count + 1") + } else { + updates["rejected_count"] = gorm.Expr("rejected_count + 1") + } + + return r.db.WithContext(ctx). + Model(&entities.Admin{}). + Where("id = ?", adminID). + Updates(updates).Error +} + +// convertToAdminInfo 转换为管理员信息DTO +func (r *GormAdminRepository) convertToAdminInfo(admin entities.Admin) dto.AdminInfo { + var permissions []string + if admin.Permissions != "" { + json.Unmarshal([]byte(admin.Permissions), &permissions) + } + + return dto.AdminInfo{ + ID: admin.ID, + Username: admin.Username, + Email: admin.Email, + Phone: admin.Phone, + RealName: admin.RealName, + Role: admin.Role, + IsActive: admin.IsActive, + LastLoginAt: admin.LastLoginAt, + LoginCount: admin.LoginCount, + Permissions: permissions, + CreatedAt: admin.CreatedAt, + } +} diff --git a/internal/domains/admin/routes/admin_routes.go b/internal/domains/admin/routes/admin_routes.go new file mode 100644 index 0000000..cf7d53e --- /dev/null +++ b/internal/domains/admin/routes/admin_routes.go @@ -0,0 +1,29 @@ +package routes + +import ( + "github.com/gin-gonic/gin" + + "tyapi-server/internal/domains/admin/handlers" +) + +// RegisterAdminRoutes 注册管理员路由 +func RegisterAdminRoutes(router *gin.Engine, adminHandler *handlers.AdminHandler) { + // 管理员路由组 + adminGroup := router.Group("/api/admin") + { + // 认证相关路由(无需认证) + authGroup := adminGroup.Group("/auth") + { + authGroup.POST("/login", adminHandler.Login) + } + + // 管理员管理路由(需要认证) + adminGroup.POST("", adminHandler.CreateAdmin) // 创建管理员 + adminGroup.GET("", adminHandler.ListAdmins) // 获取管理员列表 + adminGroup.GET("/stats", adminHandler.GetAdminStats) // 获取统计信息 + adminGroup.GET("/:id", adminHandler.GetAdminByID) // 获取管理员详情 + adminGroup.PUT("/:id", adminHandler.UpdateAdmin) // 更新管理员 + adminGroup.DELETE("/:id", adminHandler.DeleteAdmin) // 删除管理员 + adminGroup.POST("/change-password", adminHandler.ChangePassword) // 修改密码 + } +} diff --git a/internal/domains/admin/services/admin_service.go b/internal/domains/admin/services/admin_service.go new file mode 100644 index 0000000..e074b63 --- /dev/null +++ b/internal/domains/admin/services/admin_service.go @@ -0,0 +1,431 @@ +package services + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "time" + + "go.uber.org/zap" + "golang.org/x/crypto/bcrypt" + + "tyapi-server/internal/domains/admin/dto" + "tyapi-server/internal/domains/admin/entities" + "tyapi-server/internal/domains/admin/repositories" + "tyapi-server/internal/shared/interfaces" +) + +// AdminService 管理员服务 +type AdminService struct { + adminRepo repositories.AdminRepository + loginLogRepo repositories.AdminLoginLogRepository + operationLogRepo repositories.AdminOperationLogRepository + permissionRepo repositories.AdminPermissionRepository + responseBuilder interfaces.ResponseBuilder + logger *zap.Logger +} + +// NewAdminService 创建管理员服务 +func NewAdminService( + adminRepo repositories.AdminRepository, + loginLogRepo repositories.AdminLoginLogRepository, + operationLogRepo repositories.AdminOperationLogRepository, + permissionRepo repositories.AdminPermissionRepository, + responseBuilder interfaces.ResponseBuilder, + logger *zap.Logger, +) *AdminService { + return &AdminService{ + adminRepo: adminRepo, + loginLogRepo: loginLogRepo, + operationLogRepo: operationLogRepo, + permissionRepo: permissionRepo, + responseBuilder: responseBuilder, + logger: logger, + } +} + +// Login 管理员登录 +func (s *AdminService) Login(ctx context.Context, req *dto.AdminLoginRequest, clientIP, userAgent string) (*dto.AdminLoginResponse, error) { + s.logger.Info("管理员登录", zap.String("username", req.Username)) + + // 查找管理员 + admin, err := s.adminRepo.FindByUsername(ctx, req.Username) + if err != nil { + s.logger.Warn("管理员登录失败:用户不存在", zap.String("username", req.Username)) + s.recordLoginLog(ctx, req.Username, clientIP, userAgent, "failed", "用户不存在") + return nil, fmt.Errorf("用户名或密码错误") + } + + // 检查管理员状态 + if !admin.IsActive { + s.logger.Warn("管理员登录失败:账户已禁用", zap.String("username", req.Username)) + s.recordLoginLog(ctx, req.Username, clientIP, userAgent, "failed", "账户已禁用") + return nil, fmt.Errorf("账户已被禁用,请联系管理员") + } + + // 验证密码 + if err := bcrypt.CompareHashAndPassword([]byte(admin.Password), []byte(req.Password)); err != nil { + s.logger.Warn("管理员登录失败:密码错误", zap.String("username", req.Username)) + s.recordLoginLog(ctx, req.Username, clientIP, userAgent, "failed", "密码错误") + return nil, fmt.Errorf("用户名或密码错误") + } + + // 更新登录统计 + if err := s.adminRepo.UpdateLoginStats(ctx, admin.ID); err != nil { + s.logger.Error("更新登录统计失败", zap.Error(err)) + } + + // 记录登录日志 + s.recordLoginLog(ctx, req.Username, clientIP, userAgent, "success", "登录成功") + + // 生成JWT令牌 + token, expiresAt, err := s.generateJWTToken(admin) + if err != nil { + return nil, fmt.Errorf("生成令牌失败: %w", err) + } + + // 获取权限列表 + permissions, err := s.getAdminPermissions(ctx, admin) + if err != nil { + s.logger.Error("获取管理员权限失败", zap.Error(err)) + permissions = []string{} + } + + // 构建响应 + adminInfo := dto.AdminInfo{ + ID: admin.ID, + Username: admin.Username, + Email: admin.Email, + Phone: admin.Phone, + RealName: admin.RealName, + Role: admin.Role, + IsActive: admin.IsActive, + LastLoginAt: admin.LastLoginAt, + LoginCount: admin.LoginCount, + Permissions: permissions, + CreatedAt: admin.CreatedAt, + } + + s.logger.Info("管理员登录成功", zap.String("username", req.Username)) + return &dto.AdminLoginResponse{ + Token: token, + ExpiresAt: expiresAt, + Admin: adminInfo, + }, nil +} + +// CreateAdmin 创建管理员 +func (s *AdminService) CreateAdmin(ctx context.Context, req *dto.AdminCreateRequest, operatorID string) error { + s.logger.Info("创建管理员", zap.String("username", req.Username)) + + // 检查用户名是否已存在 + if _, err := s.adminRepo.FindByUsername(ctx, req.Username); err == nil { + return fmt.Errorf("用户名已存在") + } + + // 检查邮箱是否已存在 + if _, err := s.adminRepo.FindByEmail(ctx, req.Email); err == nil { + return fmt.Errorf("邮箱已存在") + } + + // 加密密码 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("密码加密失败: %w", err) + } + + // 序列化权限 + permissionsJSON := "[]" + if len(req.Permissions) > 0 { + permissionsBytes, err := json.Marshal(req.Permissions) + if err != nil { + return fmt.Errorf("权限序列化失败: %w", err) + } + permissionsJSON = string(permissionsBytes) + } + + // 创建管理员 + admin := entities.Admin{ + ID: s.generateID(), + Username: req.Username, + Password: string(hashedPassword), + Email: req.Email, + Phone: req.Phone, + RealName: req.RealName, + Role: req.Role, + IsActive: true, + Permissions: permissionsJSON, + } + + if err := s.adminRepo.Create(ctx, admin); err != nil { + return fmt.Errorf("创建管理员失败: %w", err) + } + + // 记录操作日志 + s.recordOperationLog(ctx, operatorID, "create", "admin", admin.ID, map[string]interface{}{ + "username": req.Username, + "email": req.Email, + "role": req.Role, + }, "success", "创建管理员成功") + + s.logger.Info("管理员创建成功", zap.String("username", req.Username)) + return nil +} + +// UpdateAdmin 更新管理员 +func (s *AdminService) UpdateAdmin(ctx context.Context, adminID string, req *dto.AdminUpdateRequest, operatorID string) error { + s.logger.Info("更新管理员", zap.String("admin_id", adminID)) + + // 获取管理员 + admin, err := s.adminRepo.GetByID(ctx, adminID) + if err != nil { + return fmt.Errorf("管理员不存在") + } + + // 更新字段 + if req.Email != "" { + // 检查邮箱是否被其他管理员使用 + if existingAdmin, err := s.adminRepo.FindByEmail(ctx, req.Email); err == nil && existingAdmin.ID != adminID { + return fmt.Errorf("邮箱已被其他管理员使用") + } + admin.Email = req.Email + } + + if req.Phone != "" { + admin.Phone = req.Phone + } + + if req.RealName != "" { + admin.RealName = req.RealName + } + + if req.Role != "" { + admin.Role = req.Role + } + + if req.IsActive != nil { + admin.IsActive = *req.IsActive + } + + if len(req.Permissions) > 0 { + permissionsJSON, err := json.Marshal(req.Permissions) + if err != nil { + return fmt.Errorf("权限序列化失败: %w", err) + } + admin.Permissions = string(permissionsJSON) + } + + // 保存更新 + if err := s.adminRepo.Update(ctx, admin); err != nil { + return fmt.Errorf("更新管理员失败: %w", err) + } + + // 记录操作日志 + s.recordOperationLog(ctx, operatorID, "update", "admin", adminID, map[string]interface{}{ + "email": req.Email, + "phone": req.Phone, + "real_name": req.RealName, + "role": req.Role, + "is_active": req.IsActive, + }, "success", "更新管理员成功") + + s.logger.Info("管理员更新成功", zap.String("admin_id", adminID)) + return nil +} + +// ChangePassword 修改密码 +func (s *AdminService) ChangePassword(ctx context.Context, adminID string, req *dto.AdminPasswordChangeRequest) error { + s.logger.Info("修改管理员密码", zap.String("admin_id", adminID)) + + // 获取管理员 + admin, err := s.adminRepo.GetByID(ctx, adminID) + if err != nil { + return fmt.Errorf("管理员不存在") + } + + // 验证旧密码 + if err := bcrypt.CompareHashAndPassword([]byte(admin.Password), []byte(req.OldPassword)); err != nil { + return fmt.Errorf("旧密码错误") + } + + // 加密新密码 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("密码加密失败: %w", err) + } + + // 更新密码 + admin.Password = string(hashedPassword) + if err := s.adminRepo.Update(ctx, admin); err != nil { + return fmt.Errorf("更新密码失败: %w", err) + } + + // 记录操作日志 + s.recordOperationLog(ctx, adminID, "change_password", "admin", adminID, nil, "success", "修改密码成功") + + s.logger.Info("管理员密码修改成功", zap.String("admin_id", adminID)) + return nil +} + +// ListAdmins 获取管理员列表 +func (s *AdminService) ListAdmins(ctx context.Context, req *dto.AdminListRequest) (*dto.AdminListResponse, error) { + s.logger.Info("获取管理员列表", zap.Int("page", req.Page), zap.Int("page_size", req.PageSize)) + + response, err := s.adminRepo.ListAdmins(ctx, req) + if err != nil { + return nil, fmt.Errorf("获取管理员列表失败: %w", err) + } + + return response, nil +} + +// GetAdminStats 获取管理员统计信息 +func (s *AdminService) GetAdminStats(ctx context.Context) (*dto.AdminStatsResponse, error) { + s.logger.Info("获取管理员统计信息") + + stats, err := s.adminRepo.GetStats(ctx) + if err != nil { + return nil, fmt.Errorf("获取统计信息失败: %w", err) + } + + return stats, nil +} + +// GetAdminByID 根据ID获取管理员 +func (s *AdminService) GetAdminByID(ctx context.Context, adminID string) (*dto.AdminInfo, error) { + s.logger.Info("获取管理员信息", zap.String("admin_id", adminID)) + + admin, err := s.adminRepo.GetByID(ctx, adminID) + if err != nil { + return nil, fmt.Errorf("管理员不存在") + } + + // 获取权限列表 + permissions, err := s.getAdminPermissions(ctx, &admin) + if err != nil { + s.logger.Error("获取管理员权限失败", zap.Error(err)) + permissions = []string{} + } + + adminInfo := dto.AdminInfo{ + ID: admin.ID, + Username: admin.Username, + Email: admin.Email, + Phone: admin.Phone, + RealName: admin.RealName, + Role: admin.Role, + IsActive: admin.IsActive, + LastLoginAt: admin.LastLoginAt, + LoginCount: admin.LoginCount, + Permissions: permissions, + CreatedAt: admin.CreatedAt, + } + + return &adminInfo, nil +} + +// DeleteAdmin 删除管理员 +func (s *AdminService) DeleteAdmin(ctx context.Context, adminID string, operatorID string) error { + s.logger.Info("删除管理员", zap.String("admin_id", adminID)) + + // 检查管理员是否存在 + if _, err := s.adminRepo.GetByID(ctx, adminID); err != nil { + return fmt.Errorf("管理员不存在") + } + + // 软删除管理员 + if err := s.adminRepo.SoftDelete(ctx, adminID); err != nil { + return fmt.Errorf("删除管理员失败: %w", err) + } + + // 记录操作日志 + s.recordOperationLog(ctx, operatorID, "delete", "admin", adminID, nil, "success", "删除管理员成功") + + s.logger.Info("管理员删除成功", zap.String("admin_id", adminID)) + return nil +} + +// getAdminPermissions 获取管理员权限 +func (s *AdminService) getAdminPermissions(ctx context.Context, admin *entities.Admin) ([]string, error) { + // 首先从角色获取权限 + rolePermissions, err := s.adminRepo.GetPermissionsByRole(ctx, admin.Role) + if err != nil { + return nil, err + } + + // 从角色权限中提取权限代码 + permissions := make([]string, 0, len(rolePermissions)) + for _, perm := range rolePermissions { + permissions = append(permissions, perm.Code) + } + + // 如果有自定义权限,也添加进去 + if admin.Permissions != "" { + var customPermissions []string + if err := json.Unmarshal([]byte(admin.Permissions), &customPermissions); err == nil { + permissions = append(permissions, customPermissions...) + } + } + + return permissions, nil +} + +// generateJWTToken 生成JWT令牌 +func (s *AdminService) generateJWTToken(admin *entities.Admin) (string, time.Time, error) { + // 这里应该使用JWT库生成令牌 + // 为了简化,这里返回一个模拟的令牌 + token := fmt.Sprintf("admin_token_%s_%d", admin.ID, time.Now().Unix()) + expiresAt := time.Now().Add(24 * time.Hour) + + return token, expiresAt, nil +} + +// generateID 生成ID +func (s *AdminService) generateID() string { + bytes := make([]byte, 16) + rand.Read(bytes) + return hex.EncodeToString(bytes) +} + +// recordLoginLog 记录登录日志 +func (s *AdminService) recordLoginLog(ctx context.Context, username, ip, userAgent, status, message string) { + log := entities.AdminLoginLog{ + ID: s.generateID(), + Username: username, + IP: ip, + UserAgent: userAgent, + Status: status, + Message: message, + } + + if err := s.loginLogRepo.Create(ctx, log); err != nil { + s.logger.Error("记录登录日志失败", zap.Error(err)) + } +} + +// recordOperationLog 记录操作日志 +func (s *AdminService) recordOperationLog(ctx context.Context, adminID, action, resource, resourceID string, details map[string]interface{}, status, message string) { + detailsJSON := "{}" + if details != nil { + if bytes, err := json.Marshal(details); err == nil { + detailsJSON = string(bytes) + } + } + + log := entities.AdminOperationLog{ + ID: s.generateID(), + AdminID: adminID, + Action: action, + Resource: resource, + ResourceID: resourceID, + Details: detailsJSON, + Status: status, + Message: message, + } + + if err := s.operationLogRepo.Create(ctx, log); err != nil { + s.logger.Error("记录操作日志失败", zap.Error(err)) + } +} diff --git a/internal/domains/certification/dto/certification_dto.go b/internal/domains/certification/dto/certification_dto.go new file mode 100644 index 0000000..5a41bb7 --- /dev/null +++ b/internal/domains/certification/dto/certification_dto.go @@ -0,0 +1,110 @@ +package dto + +import ( + "time" + + "tyapi-server/internal/domains/certification/enums" +) + +// CertificationCreateRequest 创建认证申请请求 +type CertificationCreateRequest struct { + UserID string `json:"user_id" binding:"required"` +} + +// CertificationCreateResponse 创建认证申请响应 +type CertificationCreateResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Status enums.CertificationStatus `json:"status"` +} + +// CertificationStatusResponse 认证状态响应 +type CertificationStatusResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Status enums.CertificationStatus `json:"status"` + StatusName string `json:"status_name"` + Progress int `json:"progress"` + IsUserActionRequired bool `json:"is_user_action_required"` + IsAdminActionRequired bool `json:"is_admin_action_required"` + + // 时间节点 + InfoSubmittedAt *time.Time `json:"info_submitted_at,omitempty"` + FaceVerifiedAt *time.Time `json:"face_verified_at,omitempty"` + ContractAppliedAt *time.Time `json:"contract_applied_at,omitempty"` + ContractApprovedAt *time.Time `json:"contract_approved_at,omitempty"` + ContractSignedAt *time.Time `json:"contract_signed_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + + // 关联信息 + Enterprise *EnterpriseInfoResponse `json:"enterprise,omitempty"` + ContractURL string `json:"contract_url,omitempty"` + SigningURL string `json:"signing_url,omitempty"` + RejectReason string `json:"reject_reason,omitempty"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SubmitEnterpriseInfoRequest 提交企业信息请求 +type SubmitEnterpriseInfoRequest struct { + CompanyName string `json:"company_name" binding:"required"` + UnifiedSocialCode string `json:"unified_social_code" binding:"required"` + LegalPersonName string `json:"legal_person_name" binding:"required"` + LegalPersonID string `json:"legal_person_id" binding:"required"` + LicenseUploadRecordID string `json:"license_upload_record_id" binding:"required"` +} + +// SubmitEnterpriseInfoResponse 提交企业信息响应 +type SubmitEnterpriseInfoResponse struct { + ID string `json:"id"` + Status enums.CertificationStatus `json:"status"` + Enterprise *EnterpriseInfoResponse `json:"enterprise"` +} + +// FaceVerifyRequest 人脸识别请求 +type FaceVerifyRequest struct { + RealName string `json:"real_name" binding:"required"` + IDCardNumber string `json:"id_card_number" binding:"required"` + ReturnURL string `json:"return_url" binding:"required"` +} + +// FaceVerifyResponse 人脸识别响应 +type FaceVerifyResponse struct { + CertifyID string `json:"certify_id"` + VerifyURL string `json:"verify_url"` + ExpiresAt time.Time `json:"expires_at"` +} + +// ApplyContractRequest 申请合同请求(无需额外参数) +type ApplyContractRequest struct{} + +// ApplyContractResponse 申请合同响应 +type ApplyContractResponse struct { + ID string `json:"id"` + Status enums.CertificationStatus `json:"status"` + ContractAppliedAt time.Time `json:"contract_applied_at"` +} + +// SignContractRequest 签署合同请求 +type SignContractRequest struct { + SignatureData string `json:"signature_data,omitempty"` +} + +// SignContractResponse 签署合同响应 +type SignContractResponse struct { + ID string `json:"id"` + Status enums.CertificationStatus `json:"status"` + ContractSignedAt time.Time `json:"contract_signed_at"` +} + +// CertificationDetailResponse 认证详情响应 +type CertificationDetailResponse struct { + *CertificationStatusResponse + + // 详细记录 + LicenseUploadRecord *LicenseUploadRecordResponse `json:"license_upload_record,omitempty"` + FaceVerifyRecords []FaceVerifyRecordResponse `json:"face_verify_records,omitempty"` + ContractRecords []ContractRecordResponse `json:"contract_records,omitempty"` + NotificationRecords []NotificationRecordResponse `json:"notification_records,omitempty"` +} diff --git a/internal/domains/certification/dto/enterprise_dto.go b/internal/domains/certification/dto/enterprise_dto.go new file mode 100644 index 0000000..5e87ed5 --- /dev/null +++ b/internal/domains/certification/dto/enterprise_dto.go @@ -0,0 +1,108 @@ +package dto + +import "time" + +// EnterpriseInfoResponse 企业信息响应 +type EnterpriseInfoResponse struct { + ID string `json:"id"` + CertificationID string `json:"certification_id"` + CompanyName string `json:"company_name"` + UnifiedSocialCode string `json:"unified_social_code"` + LegalPersonName string `json:"legal_person_name"` + LegalPersonID string `json:"legal_person_id"` + LicenseUploadRecordID string `json:"license_upload_record_id"` + OCRRawData string `json:"ocr_raw_data,omitempty"` + OCRConfidence float64 `json:"ocr_confidence,omitempty"` + IsOCRVerified bool `json:"is_ocr_verified"` + IsFaceVerified bool `json:"is_face_verified"` + VerificationData string `json:"verification_data,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// LicenseUploadRecordResponse 营业执照上传记录响应 +type LicenseUploadRecordResponse struct { + ID string `json:"id"` + CertificationID *string `json:"certification_id,omitempty"` + UserID string `json:"user_id"` + OriginalFileName string `json:"original_file_name"` + FileSize int64 `json:"file_size"` + FileType string `json:"file_type"` + FileURL string `json:"file_url"` + QiNiuKey string `json:"qiniu_key"` + OCRProcessed bool `json:"ocr_processed"` + OCRSuccess bool `json:"ocr_success"` + OCRConfidence float64 `json:"ocr_confidence,omitempty"` + OCRRawData string `json:"ocr_raw_data,omitempty"` + OCRErrorMessage string `json:"ocr_error_message,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// FaceVerifyRecordResponse 人脸识别记录响应 +type FaceVerifyRecordResponse struct { + ID string `json:"id"` + CertificationID string `json:"certification_id"` + UserID string `json:"user_id"` + CertifyID string `json:"certify_id"` + VerifyURL string `json:"verify_url,omitempty"` + ReturnURL string `json:"return_url,omitempty"` + RealName string `json:"real_name"` + IDCardNumber string `json:"id_card_number"` + Status string `json:"status"` + StatusName string `json:"status_name"` + ResultCode string `json:"result_code,omitempty"` + ResultMessage string `json:"result_message,omitempty"` + VerifyScore float64 `json:"verify_score,omitempty"` + InitiatedAt time.Time `json:"initiated_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ContractRecordResponse 合同记录响应 +type ContractRecordResponse struct { + ID string `json:"id"` + CertificationID string `json:"certification_id"` + UserID string `json:"user_id"` + AdminID *string `json:"admin_id,omitempty"` + ContractType string `json:"contract_type"` + ContractURL string `json:"contract_url,omitempty"` + SigningURL string `json:"signing_url,omitempty"` + SignatureData string `json:"signature_data,omitempty"` + SignedAt *time.Time `json:"signed_at,omitempty"` + ClientIP string `json:"client_ip,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + Status string `json:"status"` + StatusName string `json:"status_name"` + ApprovalNotes string `json:"approval_notes,omitempty"` + RejectReason string `json:"reject_reason,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// NotificationRecordResponse 通知记录响应 +type NotificationRecordResponse struct { + ID string `json:"id"` + CertificationID *string `json:"certification_id,omitempty"` + UserID *string `json:"user_id,omitempty"` + NotificationType string `json:"notification_type"` + NotificationTypeName string `json:"notification_type_name"` + NotificationScene string `json:"notification_scene"` + NotificationSceneName string `json:"notification_scene_name"` + Recipient string `json:"recipient"` + Title string `json:"title,omitempty"` + Content string `json:"content"` + TemplateID string `json:"template_id,omitempty"` + TemplateParams string `json:"template_params,omitempty"` + Status string `json:"status"` + StatusName string `json:"status_name"` + ErrorMessage string `json:"error_message,omitempty"` + SentAt *time.Time `json:"sent_at,omitempty"` + RetryCount int `json:"retry_count"` + MaxRetryCount int `json:"max_retry_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/domains/certification/dto/ocr_dto.go b/internal/domains/certification/dto/ocr_dto.go new file mode 100644 index 0000000..174b030 --- /dev/null +++ b/internal/domains/certification/dto/ocr_dto.go @@ -0,0 +1,77 @@ +package dto + +// BusinessLicenseResult 营业执照识别结果 +type BusinessLicenseResult struct { + CompanyName string `json:"company_name"` // 公司名称 + LegalRepresentative string `json:"legal_representative"` // 法定代表人 + RegisteredCapital string `json:"registered_capital"` // 注册资本 + RegisteredAddress string `json:"registered_address"` // 注册地址 + RegistrationNumber string `json:"registration_number"` // 统一社会信用代码 + BusinessScope string `json:"business_scope"` // 经营范围 + RegistrationDate string `json:"registration_date"` // 成立日期 + ValidDate string `json:"valid_date"` // 营业期限 + Confidence float64 `json:"confidence"` // 识别置信度 + Words []string `json:"words"` // 识别的所有文字 +} + +// IDCardResult 身份证识别结果 +type IDCardResult struct { + Side string `json:"side"` // 身份证面(front/back) + Name string `json:"name"` // 姓名(正面) + Sex string `json:"sex"` // 性别(正面) + Nation string `json:"nation"` // 民族(正面) + BirthDate string `json:"birth_date"` // 出生日期(正面) + Address string `json:"address"` // 住址(正面) + IDNumber string `json:"id_number"` // 身份证号码(正面) + IssuingAuthority string `json:"issuing_authority"` // 签发机关(背面) + ValidDate string `json:"valid_date"` // 有效期限(背面) + Confidence float64 `json:"confidence"` // 识别置信度 + Words []string `json:"words"` // 识别的所有文字 +} + +// GeneralTextResult 通用文字识别结果 +type GeneralTextResult struct { + Words []string `json:"words"` // 识别的文字列表 + Confidence float64 `json:"confidence"` // 识别置信度 +} + +// OCREnterpriseInfo OCR识别的企业信息 +type OCREnterpriseInfo struct { + CompanyName string `json:"company_name"` // 企业名称 + UnifiedSocialCode string `json:"unified_social_code"` // 统一社会信用代码 + LegalPersonName string `json:"legal_person_name"` // 法人姓名 + LegalPersonID string `json:"legal_person_id"` // 法人身份证号 + Confidence float64 `json:"confidence"` // 识别置信度 +} + +// LicenseProcessResult 营业执照处理结果 +type LicenseProcessResult struct { + LicenseURL string `json:"license_url"` // 营业执照文件URL + EnterpriseInfo *OCREnterpriseInfo `json:"enterprise_info"` // OCR识别的企业信息 + OCRSuccess bool `json:"ocr_success"` // OCR是否成功 + OCRError string `json:"ocr_error,omitempty"` // OCR错误信息 +} + +// UploadLicenseRequest 上传营业执照请求 +type UploadLicenseRequest struct { + // 文件通过multipart/form-data上传,这里定义验证规则 +} + +// UploadLicenseResponse 上传营业执照响应 +type UploadLicenseResponse struct { + UploadRecordID string `json:"upload_record_id"` // 上传记录ID + FileURL string `json:"file_url"` // 文件URL + OCRProcessed bool `json:"ocr_processed"` // OCR是否已处理 + OCRSuccess bool `json:"ocr_success"` // OCR是否成功 + EnterpriseInfo *OCREnterpriseInfo `json:"enterprise_info"` // OCR识别的企业信息(如果成功) + OCRErrorMessage string `json:"ocr_error_message,omitempty"` // OCR错误信息(如果失败) +} + +// UploadResult 上传结果 +type UploadResult struct { + Key string `json:"key"` // 文件key + URL string `json:"url"` // 文件访问URL + MimeType string `json:"mime_type"` // MIME类型 + Size int64 `json:"size"` // 文件大小 + Hash string `json:"hash"` // 文件哈希值 +} diff --git a/internal/domains/certification/entities/certification.go b/internal/domains/certification/entities/certification.go new file mode 100644 index 0000000..439edb0 --- /dev/null +++ b/internal/domains/certification/entities/certification.go @@ -0,0 +1,179 @@ +package entities + +import ( + "time" + + "tyapi-server/internal/domains/certification/enums" + + "gorm.io/gorm" +) + +// Certification 认证申请实体 +// 这是企业认证流程的核心实体,负责管理整个认证申请的生命周期 +// 包含认证状态、时间节点、审核信息、合同信息等核心数据 +type Certification struct { + // 基础信息 + ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"认证申请唯一标识"` + UserID string `gorm:"type:varchar(36);not null;index" json:"user_id" comment:"申请用户ID"` + EnterpriseID *string `gorm:"type:varchar(36);index" json:"enterprise_id" comment:"关联的企业信息ID"` + Status enums.CertificationStatus `gorm:"type:varchar(50);not null;index" json:"status" comment:"当前认证状态"` + + // 流程节点时间戳 - 记录每个关键步骤的完成时间 + InfoSubmittedAt *time.Time `json:"info_submitted_at,omitempty" comment:"企业信息提交时间"` + FaceVerifiedAt *time.Time `json:"face_verified_at,omitempty" comment:"人脸识别完成时间"` + ContractAppliedAt *time.Time `json:"contract_applied_at,omitempty" comment:"合同申请时间"` + ContractApprovedAt *time.Time `json:"contract_approved_at,omitempty" comment:"合同审核通过时间"` + ContractSignedAt *time.Time `json:"contract_signed_at,omitempty" comment:"合同签署完成时间"` + CompletedAt *time.Time `json:"completed_at,omitempty" comment:"认证完成时间"` + + // 审核信息 - 管理员审核相关数据 + AdminID *string `gorm:"type:varchar(36)" json:"admin_id,omitempty" comment:"审核管理员ID"` + ApprovalNotes string `gorm:"type:text" json:"approval_notes,omitempty" comment:"审核备注信息"` + RejectReason string `gorm:"type:text" json:"reject_reason,omitempty" comment:"拒绝原因说明"` + + // 合同信息 - 电子合同相关链接 + ContractURL string `gorm:"type:varchar(500)" json:"contract_url,omitempty" comment:"合同文件访问链接"` + SigningURL string `gorm:"type:varchar(500)" json:"signing_url,omitempty" comment:"电子签署链接"` + + // OCR识别信息 - 营业执照OCR识别结果 + OCRRequestID string `gorm:"type:varchar(100)" json:"ocr_request_id,omitempty" comment:"OCR识别请求ID"` + OCRConfidence float64 `gorm:"type:decimal(5,2)" json:"ocr_confidence,omitempty" comment:"OCR识别置信度(0-1)"` + + // 时间戳字段 + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" comment:"创建时间"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" comment:"更新时间"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-" comment:"软删除时间"` + + // 关联关系 - 与其他实体的关联 + Enterprise *Enterprise `gorm:"foreignKey:EnterpriseID" json:"enterprise,omitempty" comment:"关联的企业信息"` + LicenseUploadRecord *LicenseUploadRecord `gorm:"foreignKey:CertificationID" json:"license_upload_record,omitempty" comment:"关联的营业执照上传记录"` + FaceVerifyRecords []FaceVerifyRecord `gorm:"foreignKey:CertificationID" json:"face_verify_records,omitempty" comment:"关联的人脸识别记录列表"` + ContractRecords []ContractRecord `gorm:"foreignKey:CertificationID" json:"contract_records,omitempty" comment:"关联的合同记录列表"` + NotificationRecords []NotificationRecord `gorm:"foreignKey:CertificationID" json:"notification_records,omitempty" comment:"关联的通知记录列表"` +} + +// TableName 指定数据库表名 +func (Certification) TableName() string { + return "certifications" +} + +// IsStatusChangeable 检查状态是否可以变更 +// 只有非最终状态(完成/拒绝)的认证申请才能进行状态变更 +func (c *Certification) IsStatusChangeable() bool { + return !enums.IsFinalStatus(c.Status) +} + +// CanRetryFaceVerify 检查是否可以重试人脸识别 +// 只有人脸识别失败状态的申请才能重试 +func (c *Certification) CanRetryFaceVerify() bool { + return c.Status == enums.StatusFaceFailed +} + +// CanRetrySign 检查是否可以重试签署 +// 只有签署失败状态的申请才能重试 +func (c *Certification) CanRetrySign() bool { + return c.Status == enums.StatusSignFailed +} + +// CanRestart 检查是否可以重新开始流程 +// 只有被拒绝的申请才能重新开始认证流程 +func (c *Certification) CanRestart() bool { + return c.Status == enums.StatusRejected +} + +// GetNextValidStatuses 获取当前状态可以转换到的下一个状态列表 +// 根据状态机规则,返回所有合法的下一个状态 +func (c *Certification) GetNextValidStatuses() []enums.CertificationStatus { + switch c.Status { + case enums.StatusPending: + return []enums.CertificationStatus{enums.StatusInfoSubmitted} + case enums.StatusInfoSubmitted: + return []enums.CertificationStatus{enums.StatusFaceVerified, enums.StatusFaceFailed} + case enums.StatusFaceVerified: + return []enums.CertificationStatus{enums.StatusContractApplied} + case enums.StatusContractApplied: + return []enums.CertificationStatus{enums.StatusContractPending} + case enums.StatusContractPending: + return []enums.CertificationStatus{enums.StatusContractApproved, enums.StatusRejected} + case enums.StatusContractApproved: + return []enums.CertificationStatus{enums.StatusContractSigned, enums.StatusSignFailed} + case enums.StatusContractSigned: + return []enums.CertificationStatus{enums.StatusCompleted} + case enums.StatusFaceFailed: + return []enums.CertificationStatus{enums.StatusFaceVerified} + case enums.StatusSignFailed: + return []enums.CertificationStatus{enums.StatusContractSigned} + case enums.StatusRejected: + return []enums.CertificationStatus{enums.StatusInfoSubmitted} + default: + return []enums.CertificationStatus{} + } +} + +// CanTransitionTo 检查是否可以转换到指定状态 +// 验证状态转换的合法性,确保状态机规则得到遵守 +func (c *Certification) CanTransitionTo(targetStatus enums.CertificationStatus) bool { + validStatuses := c.GetNextValidStatuses() + for _, status := range validStatuses { + if status == targetStatus { + return true + } + } + return false +} + +// GetProgressPercentage 获取认证进度百分比 +// 根据当前状态计算认证流程的完成进度,用于前端进度条显示 +func (c *Certification) GetProgressPercentage() int { + switch c.Status { + case enums.StatusPending: + return 0 + case enums.StatusInfoSubmitted: + return 12 + case enums.StatusFaceVerified: + return 25 + case enums.StatusContractApplied: + return 37 + case enums.StatusContractPending: + return 50 + case enums.StatusContractApproved: + return 75 + case enums.StatusContractSigned: + return 87 + case enums.StatusCompleted: + return 100 + case enums.StatusFaceFailed, enums.StatusSignFailed: + return c.GetProgressPercentage() // 失败状态保持原进度 + case enums.StatusRejected: + return 0 + default: + return 0 + } +} + +// IsUserActionRequired 检查是否需要用户操作 +// 判断当前状态是否需要用户进行下一步操作,用于前端提示 +func (c *Certification) IsUserActionRequired() bool { + userActionStatuses := []enums.CertificationStatus{ + enums.StatusPending, + enums.StatusInfoSubmitted, + enums.StatusFaceVerified, + enums.StatusContractApproved, + enums.StatusFaceFailed, + enums.StatusSignFailed, + enums.StatusRejected, + } + + for _, status := range userActionStatuses { + if c.Status == status { + return true + } + } + return false +} + +// IsAdminActionRequired 检查是否需要管理员操作 +// 判断当前状态是否需要管理员审核,用于后台管理界面 +func (c *Certification) IsAdminActionRequired() bool { + return c.Status == enums.StatusContractPending +} diff --git a/internal/domains/certification/entities/contract_record.go b/internal/domains/certification/entities/contract_record.go new file mode 100644 index 0000000..6d77b23 --- /dev/null +++ b/internal/domains/certification/entities/contract_record.go @@ -0,0 +1,98 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +// ContractRecord 合同记录实体 +// 记录电子合同的详细信息,包括合同生成、审核、签署的完整流程 +// 支持合同状态跟踪、签署信息记录、审核流程管理等功能 +type ContractRecord struct { + // 基础标识 + ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"合同记录唯一标识"` + CertificationID string `gorm:"type:varchar(36);not null;index" json:"certification_id" comment:"关联的认证申请ID"` + UserID string `gorm:"type:varchar(36);not null;index" json:"user_id" comment:"合同申请人ID"` + AdminID *string `gorm:"type:varchar(36);index" json:"admin_id,omitempty" comment:"审核管理员ID"` + + // 合同信息 - 电子合同的基本信息 + ContractType string `gorm:"type:varchar(50);not null" json:"contract_type" comment:"合同类型(ENTERPRISE_CERTIFICATION)"` + ContractURL string `gorm:"type:varchar(500)" json:"contract_url,omitempty" comment:"合同文件访问链接"` + SigningURL string `gorm:"type:varchar(500)" json:"signing_url,omitempty" comment:"电子签署链接"` + + // 签署信息 - 记录用户签署的详细信息 + SignatureData string `gorm:"type:text" json:"signature_data,omitempty" comment:"签署数据(JSON格式)"` + SignedAt *time.Time `json:"signed_at,omitempty" comment:"签署完成时间"` + ClientIP string `gorm:"type:varchar(50)" json:"client_ip,omitempty" comment:"签署客户端IP"` + UserAgent string `gorm:"type:varchar(500)" json:"user_agent,omitempty" comment:"签署客户端信息"` + + // 状态信息 - 合同的生命周期状态 + Status string `gorm:"type:varchar(50);not null;index" json:"status" comment:"合同状态(PENDING/APPROVED/SIGNED/EXPIRED)"` + ApprovalNotes string `gorm:"type:text" json:"approval_notes,omitempty" comment:"审核备注信息"` + RejectReason string `gorm:"type:text" json:"reject_reason,omitempty" comment:"拒绝原因说明"` + ExpiresAt *time.Time `json:"expires_at,omitempty" comment:"合同过期时间"` + + // 时间戳字段 + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" comment:"创建时间"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" comment:"更新时间"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-" comment:"软删除时间"` + + // 关联关系 + Certification *Certification `gorm:"foreignKey:CertificationID" json:"certification,omitempty" comment:"关联的认证申请"` +} + +// TableName 指定数据库表名 +func (ContractRecord) TableName() string { + return "contract_records" +} + +// IsPending 检查合同是否待审核 +// 判断合同是否处于等待管理员审核的状态 +func (c *ContractRecord) IsPending() bool { + return c.Status == "PENDING" +} + +// IsApproved 检查合同是否已审核通过 +// 判断合同是否已通过管理员审核,可以进入签署阶段 +func (c *ContractRecord) IsApproved() bool { + return c.Status == "APPROVED" +} + +// IsSigned 检查合同是否已签署 +// 判断合同是否已完成电子签署,认证流程即将完成 +func (c *ContractRecord) IsSigned() bool { + return c.Status == "SIGNED" +} + +// IsExpired 检查合同是否已过期 +// 判断合同是否已超过有效期,过期后需要重新申请 +func (c *ContractRecord) IsExpired() bool { + if c.ExpiresAt == nil { + return false + } + return time.Now().After(*c.ExpiresAt) +} + +// HasSigningURL 检查是否有签署链接 +// 判断是否已生成电子签署链接,用于前端判断是否显示签署按钮 +func (c *ContractRecord) HasSigningURL() bool { + return c.SigningURL != "" +} + +// GetStatusName 获取状态的中文名称 +// 将英文状态码转换为中文显示名称,用于前端展示和用户理解 +func (c *ContractRecord) GetStatusName() string { + statusNames := map[string]string{ + "PENDING": "待审核", + "APPROVED": "已审核", + "SIGNED": "已签署", + "EXPIRED": "已过期", + "REJECTED": "已拒绝", + } + + if name, exists := statusNames[c.Status]; exists { + return name + } + return c.Status +} diff --git a/internal/domains/certification/entities/enterprise.go b/internal/domains/certification/entities/enterprise.go new file mode 100644 index 0000000..bf79afe --- /dev/null +++ b/internal/domains/certification/entities/enterprise.go @@ -0,0 +1,66 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +// Enterprise 企业信息实体 +// 存储企业认证的核心信息,包括企业四要素和验证状态 +// 与认证申请是一对一关系,每个认证申请对应一个企业信息 +type Enterprise struct { + // 基础标识 + ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"企业信息唯一标识"` + CertificationID string `gorm:"type:varchar(36);not null;index" json:"certification_id" comment:"关联的认证申请ID"` + + // 企业四要素 - 企业认证的核心信息 + CompanyName string `gorm:"type:varchar(255);not null" json:"company_name" comment:"企业名称"` + UnifiedSocialCode string `gorm:"type:varchar(50);not null;index" json:"unified_social_code" comment:"统一社会信用代码"` + LegalPersonName string `gorm:"type:varchar(100);not null" json:"legal_person_name" comment:"法定代表人姓名"` + LegalPersonID string `gorm:"type:varchar(50);not null" json:"legal_person_id" comment:"法定代表人身份证号"` + + // 关联的营业执照上传记录 + LicenseUploadRecordID string `gorm:"type:varchar(36);not null;index" json:"license_upload_record_id" comment:"关联的营业执照上传记录ID"` + + // OCR识别结果 - 从营业执照中自动识别的信息 + OCRRawData string `gorm:"type:text" json:"ocr_raw_data,omitempty" comment:"OCR原始返回数据(JSON格式)"` + OCRConfidence float64 `gorm:"type:decimal(5,2)" json:"ocr_confidence,omitempty" comment:"OCR识别置信度(0-1)"` + + // 验证状态 - 各环节的验证结果 + IsOCRVerified bool `gorm:"default:false" json:"is_ocr_verified" comment:"OCR验证是否通过"` + IsFaceVerified bool `gorm:"default:false" json:"is_face_verified" comment:"人脸识别是否通过"` + VerificationData string `gorm:"type:text" json:"verification_data,omitempty" comment:"验证数据(JSON格式)"` + + // 时间戳字段 + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" comment:"创建时间"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" comment:"更新时间"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-" comment:"软删除时间"` + + // 关联关系 + Certification *Certification `gorm:"foreignKey:CertificationID" json:"certification,omitempty" comment:"关联的认证申请"` + LicenseUploadRecord *LicenseUploadRecord `gorm:"foreignKey:LicenseUploadRecordID" json:"license_upload_record,omitempty" comment:"关联的营业执照上传记录"` +} + +// TableName 指定数据库表名 +func (Enterprise) TableName() string { + return "enterprises" +} + +// IsComplete 检查企业四要素是否完整 +// 验证企业名称、统一社会信用代码、法定代表人姓名、身份证号是否都已填写 +func (e *Enterprise) IsComplete() bool { + return e.CompanyName != "" && + e.UnifiedSocialCode != "" && + e.LegalPersonName != "" && + e.LegalPersonID != "" +} + +// Validate 验证企业信息是否有效 +// 这里可以添加企业信息的业务验证逻辑 +// 比如统一社会信用代码格式验证、身份证号格式验证等 +func (e *Enterprise) Validate() error { + // 这里可以添加企业信息的业务验证逻辑 + // 比如统一社会信用代码格式验证、身份证号格式验证等 + return nil +} diff --git a/internal/domains/certification/entities/face_verify_record.go b/internal/domains/certification/entities/face_verify_record.go new file mode 100644 index 0000000..70711fe --- /dev/null +++ b/internal/domains/certification/entities/face_verify_record.go @@ -0,0 +1,89 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +// FaceVerifyRecord 人脸识别记录实体 +// 记录用户进行人脸识别验证的详细信息,包括验证状态、结果和身份信息 +// 支持多次验证尝试,每次验证都会生成独立的记录,便于追踪和重试 +type FaceVerifyRecord struct { + // 基础标识 + ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"人脸识别记录唯一标识"` + CertificationID string `gorm:"type:varchar(36);not null;index" json:"certification_id" comment:"关联的认证申请ID"` + UserID string `gorm:"type:varchar(36);not null;index" json:"user_id" comment:"进行验证的用户ID"` + + // 阿里云人脸识别信息 - 第三方服务的相关数据 + CertifyID string `gorm:"type:varchar(100);not null;index" json:"certify_id" comment:"阿里云人脸识别任务ID"` + VerifyURL string `gorm:"type:varchar(500)" json:"verify_url,omitempty" comment:"人脸识别验证页面URL"` + ReturnURL string `gorm:"type:varchar(500)" json:"return_url,omitempty" comment:"验证完成后的回调URL"` + + // 身份信息 - 用于人脸识别的身份验证数据 + RealName string `gorm:"type:varchar(100);not null" json:"real_name" comment:"真实姓名"` + IDCardNumber string `gorm:"type:varchar(50);not null" json:"id_card_number" comment:"身份证号码"` + + // 验证结果 - 记录验证的详细结果信息 + Status string `gorm:"type:varchar(50);not null;index" json:"status" comment:"验证状态(PROCESSING/SUCCESS/FAIL)"` + ResultCode string `gorm:"type:varchar(50)" json:"result_code,omitempty" comment:"结果代码"` + ResultMessage string `gorm:"type:varchar(500)" json:"result_message,omitempty" comment:"结果描述信息"` + VerifyScore float64 `gorm:"type:decimal(5,2)" json:"verify_score,omitempty" comment:"验证分数(0-1)"` + + // 时间信息 - 验证流程的时间节点 + InitiatedAt time.Time `gorm:"autoCreateTime" json:"initiated_at" comment:"验证发起时间"` + CompletedAt *time.Time `json:"completed_at,omitempty" comment:"验证完成时间"` + ExpiresAt time.Time `gorm:"not null" json:"expires_at" comment:"验证链接过期时间"` + + // 时间戳字段 + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" comment:"创建时间"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" comment:"更新时间"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-" comment:"软删除时间"` + + // 关联关系 + Certification *Certification `gorm:"foreignKey:CertificationID" json:"certification,omitempty" comment:"关联的认证申请"` +} + +// TableName 指定数据库表名 +func (FaceVerifyRecord) TableName() string { + return "face_verify_records" +} + +// IsSuccess 检查人脸识别是否成功 +// 判断验证状态是否为成功状态 +func (f *FaceVerifyRecord) IsSuccess() bool { + return f.Status == "SUCCESS" +} + +// IsProcessing 检查是否正在处理中 +// 判断验证是否正在进行中,等待用户完成验证 +func (f *FaceVerifyRecord) IsProcessing() bool { + return f.Status == "PROCESSING" +} + +// IsFailed 检查是否失败 +// 判断验证是否失败,包括超时、验证不通过等情况 +func (f *FaceVerifyRecord) IsFailed() bool { + return f.Status == "FAIL" +} + +// IsExpired 检查是否已过期 +// 判断验证链接是否已超过有效期,过期后需要重新发起验证 +func (f *FaceVerifyRecord) IsExpired() bool { + return time.Now().After(f.ExpiresAt) +} + +// GetStatusName 获取状态的中文名称 +// 将英文状态码转换为中文显示名称,用于前端展示 +func (f *FaceVerifyRecord) GetStatusName() string { + statusNames := map[string]string{ + "PROCESSING": "处理中", + "SUCCESS": "成功", + "FAIL": "失败", + } + + if name, exists := statusNames[f.Status]; exists { + return name + } + return f.Status +} diff --git a/internal/domains/certification/entities/license_upload_record.go b/internal/domains/certification/entities/license_upload_record.go new file mode 100644 index 0000000..f42dd8a --- /dev/null +++ b/internal/domains/certification/entities/license_upload_record.go @@ -0,0 +1,70 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +// LicenseUploadRecord 营业执照上传记录实体 +// 记录用户上传营业执照文件的详细信息,包括文件元数据和OCR处理结果 +// 支持多种文件格式,自动进行OCR识别,为后续企业信息验证提供数据支持 +type LicenseUploadRecord struct { + // 基础标识 + ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"上传记录唯一标识"` + CertificationID *string `gorm:"type:varchar(36);index" json:"certification_id,omitempty" comment:"关联的认证申请ID(可为空,表示独立上传)"` + UserID string `gorm:"type:varchar(36);not null;index" json:"user_id" comment:"上传用户ID"` + + // 文件信息 - 存储文件的元数据信息 + OriginalFileName string `gorm:"type:varchar(255);not null" json:"original_file_name" comment:"原始文件名"` + FileSize int64 `gorm:"not null" json:"file_size" comment:"文件大小(字节)"` + FileType string `gorm:"type:varchar(50);not null" json:"file_type" comment:"文件MIME类型"` + FileURL string `gorm:"type:varchar(500);not null" json:"file_url" comment:"文件访问URL"` + QiNiuKey string `gorm:"type:varchar(255);not null;index" json:"qiniu_key" comment:"七牛云存储的Key"` + + // OCR处理结果 - 记录OCR识别的详细结果 + OCRProcessed bool `gorm:"default:false" json:"ocr_processed" comment:"是否已进行OCR处理"` + OCRSuccess bool `gorm:"default:false" json:"ocr_success" comment:"OCR识别是否成功"` + OCRConfidence float64 `gorm:"type:decimal(5,2)" json:"ocr_confidence,omitempty" comment:"OCR识别置信度(0-1)"` + OCRRawData string `gorm:"type:text" json:"ocr_raw_data,omitempty" comment:"OCR原始返回数据(JSON格式)"` + OCRErrorMessage string `gorm:"type:varchar(500)" json:"ocr_error_message,omitempty" comment:"OCR处理错误信息"` + + // 时间戳字段 + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" comment:"创建时间"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" comment:"更新时间"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-" comment:"软删除时间"` + + // 关联关系 + Certification *Certification `gorm:"foreignKey:CertificationID" json:"certification,omitempty" comment:"关联的认证申请"` +} + +// TableName 指定数据库表名 +func (LicenseUploadRecord) TableName() string { + return "license_upload_records" +} + +// IsOCRSuccess 检查OCR是否成功 +// 判断OCR处理已完成且识别成功 +func (l *LicenseUploadRecord) IsOCRSuccess() bool { + return l.OCRProcessed && l.OCRSuccess +} + +// GetFileExtension 获取文件扩展名 +// 从原始文件名中提取文件扩展名,用于文件类型判断 +func (l *LicenseUploadRecord) GetFileExtension() string { + // 从OriginalFileName提取扩展名的逻辑 + // 这里简化处理,实际使用时可以用path.Ext() + return l.FileType +} + +// IsValidForOCR 检查文件是否适合OCR处理 +// 验证文件类型是否支持OCR识别,目前支持JPEG、PNG格式 +func (l *LicenseUploadRecord) IsValidForOCR() bool { + validTypes := []string{"image/jpeg", "image/png", "image/jpg"} + for _, validType := range validTypes { + if l.FileType == validType { + return true + } + } + return false +} diff --git a/internal/domains/certification/entities/notification_record.go b/internal/domains/certification/entities/notification_record.go new file mode 100644 index 0000000..9e6988f --- /dev/null +++ b/internal/domains/certification/entities/notification_record.go @@ -0,0 +1,127 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +// NotificationRecord 通知记录实体 +// 记录系统发送的所有通知信息,包括短信、企业微信、邮件等多种通知渠道 +// 支持通知状态跟踪、重试机制、模板化消息等功能,确保通知的可靠送达 +type NotificationRecord struct { + // 基础标识 + ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"通知记录唯一标识"` + CertificationID *string `gorm:"type:varchar(36);index" json:"certification_id,omitempty" comment:"关联的认证申请ID(可为空)"` + UserID *string `gorm:"type:varchar(36);index" json:"user_id,omitempty" comment:"接收用户ID(可为空)"` + + // 通知类型和渠道 - 定义通知的发送方式和业务场景 + NotificationType string `gorm:"type:varchar(50);not null;index" json:"notification_type" comment:"通知类型(SMS/WECHAT_WORK/EMAIL)"` + NotificationScene string `gorm:"type:varchar(50);not null;index" json:"notification_scene" comment:"通知场景(ADMIN_NEW_APPLICATION/USER_CONTRACT_READY等)"` + + // 接收方信息 - 通知的目标接收者 + Recipient string `gorm:"type:varchar(255);not null" json:"recipient" comment:"接收方标识(手机号/邮箱/用户ID)"` + + // 消息内容 - 通知的具体内容信息 + Title string `gorm:"type:varchar(255)" json:"title,omitempty" comment:"通知标题"` + Content string `gorm:"type:text;not null" json:"content" comment:"通知内容"` + TemplateID string `gorm:"type:varchar(100)" json:"template_id,omitempty" comment:"消息模板ID"` + TemplateParams string `gorm:"type:text" json:"template_params,omitempty" comment:"模板参数(JSON格式)"` + + // 发送状态 - 记录通知的发送过程和结果 + Status string `gorm:"type:varchar(50);not null;index" json:"status" comment:"发送状态(PENDING/SENT/FAILED)"` + ErrorMessage string `gorm:"type:varchar(500)" json:"error_message,omitempty" comment:"发送失败的错误信息"` + SentAt *time.Time `json:"sent_at,omitempty" comment:"发送成功时间"` + RetryCount int `gorm:"default:0" json:"retry_count" comment:"当前重试次数"` + MaxRetryCount int `gorm:"default:3" json:"max_retry_count" comment:"最大重试次数"` + + // 时间戳字段 + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" comment:"创建时间"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" comment:"更新时间"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-" comment:"软删除时间"` + + // 关联关系 + Certification *Certification `gorm:"foreignKey:CertificationID" json:"certification,omitempty" comment:"关联的认证申请"` +} + +// TableName 指定数据库表名 +func (NotificationRecord) TableName() string { + return "notification_records" +} + +// IsPending 检查通知是否待发送 +// 判断通知是否处于等待发送的状态 +func (n *NotificationRecord) IsPending() bool { + return n.Status == "PENDING" +} + +// IsSent 检查通知是否已发送 +// 判断通知是否已成功发送到接收方 +func (n *NotificationRecord) IsSent() bool { + return n.Status == "SENT" +} + +// IsFailed 检查通知是否发送失败 +// 判断通知是否发送失败,包括网络错误、接收方无效等情况 +func (n *NotificationRecord) IsFailed() bool { + return n.Status == "FAILED" +} + +// CanRetry 检查是否可以重试 +// 判断失败的通知是否还可以进行重试发送 +func (n *NotificationRecord) CanRetry() bool { + return n.IsFailed() && n.RetryCount < n.MaxRetryCount +} + +// IncrementRetryCount 增加重试次数 +// 在重试发送时增加重试计数器 +func (n *NotificationRecord) IncrementRetryCount() { + n.RetryCount++ +} + +// GetStatusName 获取状态的中文名称 +// 将英文状态码转换为中文显示名称,用于前端展示 +func (n *NotificationRecord) GetStatusName() string { + statusNames := map[string]string{ + "PENDING": "待发送", + "SENT": "已发送", + "FAILED": "发送失败", + } + + if name, exists := statusNames[n.Status]; exists { + return name + } + return n.Status +} + +// GetNotificationTypeName 获取通知类型的中文名称 +// 将通知类型转换为中文显示名称,便于用户理解 +func (n *NotificationRecord) GetNotificationTypeName() string { + typeNames := map[string]string{ + "SMS": "短信", + "WECHAT_WORK": "企业微信", + "EMAIL": "邮件", + } + + if name, exists := typeNames[n.NotificationType]; exists { + return name + } + return n.NotificationType +} + +// GetNotificationSceneName 获取通知场景的中文名称 +// 将通知场景转换为中文显示名称,便于业务人员理解通知的触发原因 +func (n *NotificationRecord) GetNotificationSceneName() string { + sceneNames := map[string]string{ + "ADMIN_NEW_APPLICATION": "管理员新申请通知", + "USER_CONTRACT_READY": "用户合同就绪通知", + "USER_CERTIFICATION_COMPLETED": "用户认证完成通知", + "USER_FACE_VERIFY_FAILED": "用户人脸识别失败通知", + "USER_CONTRACT_REJECTED": "用户合同被拒绝通知", + } + + if name, exists := sceneNames[n.NotificationScene]; exists { + return name + } + return n.NotificationScene +} diff --git a/internal/domains/certification/enums/certification_status.go b/internal/domains/certification/enums/certification_status.go new file mode 100644 index 0000000..f0e8174 --- /dev/null +++ b/internal/domains/certification/enums/certification_status.go @@ -0,0 +1,88 @@ +package enums + +// CertificationStatus 认证状态枚举 +type CertificationStatus string + +const ( + // 主流程状态 + StatusPending CertificationStatus = "pending" // 待开始 + StatusInfoSubmitted CertificationStatus = "info_submitted" // 企业信息已提交 + StatusFaceVerified CertificationStatus = "face_verified" // 人脸识别完成 + StatusContractApplied CertificationStatus = "contract_applied" // 已申请合同 + StatusContractPending CertificationStatus = "contract_pending" // 合同待审核 + StatusContractApproved CertificationStatus = "contract_approved" // 合同已审核(有链接) + StatusContractSigned CertificationStatus = "contract_signed" // 合同已签署 + StatusCompleted CertificationStatus = "completed" // 认证完成 + + // 失败和重试状态 + StatusFaceFailed CertificationStatus = "face_failed" // 人脸识别失败 + StatusSignFailed CertificationStatus = "sign_failed" // 签署失败 + StatusRejected CertificationStatus = "rejected" // 已拒绝 +) + +// IsValidStatus 检查状态是否有效 +func IsValidStatus(status CertificationStatus) bool { + validStatuses := []CertificationStatus{ + StatusPending, StatusInfoSubmitted, StatusFaceVerified, + StatusContractApplied, StatusContractPending, StatusContractApproved, + StatusContractSigned, StatusCompleted, StatusFaceFailed, + StatusSignFailed, StatusRejected, + } + + for _, validStatus := range validStatuses { + if status == validStatus { + return true + } + } + return false +} + +// GetStatusName 获取状态的中文名称 +func GetStatusName(status CertificationStatus) string { + statusNames := map[CertificationStatus]string{ + StatusPending: "待开始", + StatusInfoSubmitted: "企业信息已提交", + StatusFaceVerified: "人脸识别完成", + StatusContractApplied: "已申请合同", + StatusContractPending: "合同待审核", + StatusContractApproved: "合同已审核", + StatusContractSigned: "合同已签署", + StatusCompleted: "认证完成", + StatusFaceFailed: "人脸识别失败", + StatusSignFailed: "签署失败", + StatusRejected: "已拒绝", + } + + if name, exists := statusNames[status]; exists { + return name + } + return string(status) +} + +// IsFinalStatus 判断是否为最终状态 +func IsFinalStatus(status CertificationStatus) bool { + finalStatuses := []CertificationStatus{ + StatusCompleted, StatusRejected, + } + + for _, finalStatus := range finalStatuses { + if status == finalStatus { + return true + } + } + return false +} + +// IsFailedStatus 判断是否为失败状态 +func IsFailedStatus(status CertificationStatus) bool { + failedStatuses := []CertificationStatus{ + StatusFaceFailed, StatusSignFailed, StatusRejected, + } + + for _, failedStatus := range failedStatuses { + if status == failedStatus { + return true + } + } + return false +} diff --git a/internal/domains/certification/events/certification_events.go b/internal/domains/certification/events/certification_events.go new file mode 100644 index 0000000..55f6d58 --- /dev/null +++ b/internal/domains/certification/events/certification_events.go @@ -0,0 +1,526 @@ +package events + +import ( + "encoding/json" + "time" + + "tyapi-server/internal/domains/certification/entities" +) + +// 认证事件类型常量 +const ( + EventTypeCertificationCreated = "certification.created" + EventTypeCertificationSubmitted = "certification.submitted" + EventTypeLicenseUploaded = "certification.license.uploaded" + EventTypeOCRCompleted = "certification.ocr.completed" + EventTypeEnterpriseInfoConfirmed = "certification.enterprise.confirmed" + EventTypeFaceVerifyInitiated = "certification.face_verify.initiated" + EventTypeFaceVerifyCompleted = "certification.face_verify.completed" + EventTypeContractRequested = "certification.contract.requested" + EventTypeContractGenerated = "certification.contract.generated" + EventTypeContractSigned = "certification.contract.signed" + EventTypeCertificationApproved = "certification.approved" + EventTypeCertificationRejected = "certification.rejected" + EventTypeWalletCreated = "certification.wallet.created" + EventTypeCertificationCompleted = "certification.completed" + EventTypeCertificationFailed = "certification.failed" +) + +// BaseCertificationEvent 认证事件基础结构 +type BaseCertificationEvent struct { + ID string `json:"id"` + Type string `json:"type"` + Version string `json:"version"` + Timestamp time.Time `json:"timestamp"` + Source string `json:"source"` + AggregateID string `json:"aggregate_id"` + AggregateType string `json:"aggregate_type"` + Metadata map[string]interface{} `json:"metadata"` + Payload interface{} `json:"payload"` +} + +// 实现 Event 接口 +func (e *BaseCertificationEvent) GetID() string { return e.ID } +func (e *BaseCertificationEvent) GetType() string { return e.Type } +func (e *BaseCertificationEvent) GetVersion() string { return e.Version } +func (e *BaseCertificationEvent) GetTimestamp() time.Time { return e.Timestamp } +func (e *BaseCertificationEvent) GetSource() string { return e.Source } +func (e *BaseCertificationEvent) GetAggregateID() string { return e.AggregateID } +func (e *BaseCertificationEvent) GetAggregateType() string { return e.AggregateType } +func (e *BaseCertificationEvent) GetPayload() interface{} { return e.Payload } +func (e *BaseCertificationEvent) GetMetadata() map[string]interface{} { return e.Metadata } +func (e *BaseCertificationEvent) Marshal() ([]byte, error) { return json.Marshal(e) } +func (e *BaseCertificationEvent) Unmarshal(data []byte) error { return json.Unmarshal(data, e) } +func (e *BaseCertificationEvent) GetDomainVersion() string { return e.Version } +func (e *BaseCertificationEvent) GetCausationID() string { return e.ID } +func (e *BaseCertificationEvent) GetCorrelationID() string { return e.ID } + +// NewBaseCertificationEvent 创建基础认证事件 +func NewBaseCertificationEvent(eventType, aggregateID string, payload interface{}) *BaseCertificationEvent { + return &BaseCertificationEvent{ + ID: generateEventID(), + Type: eventType, + Version: "1.0", + Timestamp: time.Now(), + Source: "certification-domain", + AggregateID: aggregateID, + AggregateType: "certification", + Metadata: make(map[string]interface{}), + Payload: payload, + } +} + +// CertificationCreatedEvent 认证创建事件 +type CertificationCreatedEvent struct { + *BaseCertificationEvent + Data struct { + CertificationID string `json:"certification_id"` + UserID string `json:"user_id"` + Status string `json:"status"` + } `json:"data"` +} + +// NewCertificationCreatedEvent 创建认证创建事件 +func NewCertificationCreatedEvent(certification *entities.Certification) *CertificationCreatedEvent { + event := &CertificationCreatedEvent{ + BaseCertificationEvent: NewBaseCertificationEvent( + EventTypeCertificationCreated, + certification.ID, + nil, + ), + } + event.Data.CertificationID = certification.ID + event.Data.UserID = certification.UserID + event.Data.Status = string(certification.Status) + event.Payload = event.Data + return event +} + +// CertificationSubmittedEvent 认证提交事件 +type CertificationSubmittedEvent struct { + *BaseCertificationEvent + Data struct { + CertificationID string `json:"certification_id"` + UserID string `json:"user_id"` + Status string `json:"status"` + } `json:"data"` +} + +// NewCertificationSubmittedEvent 创建认证提交事件 +func NewCertificationSubmittedEvent(certification *entities.Certification) *CertificationSubmittedEvent { + event := &CertificationSubmittedEvent{ + BaseCertificationEvent: NewBaseCertificationEvent( + EventTypeCertificationSubmitted, + certification.ID, + nil, + ), + } + event.Data.CertificationID = certification.ID + event.Data.UserID = certification.UserID + event.Data.Status = string(certification.Status) + event.Payload = event.Data + return event +} + +// LicenseUploadedEvent 营业执照上传事件 +type LicenseUploadedEvent struct { + *BaseCertificationEvent + Data struct { + CertificationID string `json:"certification_id"` + UserID string `json:"user_id"` + FileURL string `json:"file_url"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + Status string `json:"status"` + } `json:"data"` +} + +// NewLicenseUploadedEvent 创建营业执照上传事件 +func NewLicenseUploadedEvent(certification *entities.Certification, record *entities.LicenseUploadRecord) *LicenseUploadedEvent { + event := &LicenseUploadedEvent{ + BaseCertificationEvent: NewBaseCertificationEvent( + EventTypeLicenseUploaded, + certification.ID, + nil, + ), + } + event.Data.CertificationID = certification.ID + event.Data.UserID = certification.UserID + event.Data.FileURL = record.FileURL + event.Data.FileName = record.OriginalFileName + event.Data.FileSize = record.FileSize + event.Data.Status = string(certification.Status) + event.Payload = event.Data + return event +} + +// OCRCompletedEvent OCR识别完成事件 +type OCRCompletedEvent struct { + *BaseCertificationEvent + Data struct { + CertificationID string `json:"certification_id"` + UserID string `json:"user_id"` + OCRResult map[string]interface{} `json:"ocr_result"` + Confidence float64 `json:"confidence"` + Status string `json:"status"` + } `json:"data"` +} + +// NewOCRCompletedEvent 创建OCR识别完成事件 +func NewOCRCompletedEvent(certification *entities.Certification, ocrResult map[string]interface{}, confidence float64) *OCRCompletedEvent { + event := &OCRCompletedEvent{ + BaseCertificationEvent: NewBaseCertificationEvent( + EventTypeOCRCompleted, + certification.ID, + nil, + ), + } + event.Data.CertificationID = certification.ID + event.Data.UserID = certification.UserID + event.Data.OCRResult = ocrResult + event.Data.Confidence = confidence + event.Data.Status = string(certification.Status) + event.Payload = event.Data + return event +} + +// EnterpriseInfoConfirmedEvent 企业信息确认事件 +type EnterpriseInfoConfirmedEvent struct { + *BaseCertificationEvent + Data struct { + CertificationID string `json:"certification_id"` + UserID string `json:"user_id"` + EnterpriseInfo map[string]interface{} `json:"enterprise_info"` + Status string `json:"status"` + } `json:"data"` +} + +// NewEnterpriseInfoConfirmedEvent 创建企业信息确认事件 +func NewEnterpriseInfoConfirmedEvent(certification *entities.Certification, enterpriseInfo map[string]interface{}) *EnterpriseInfoConfirmedEvent { + event := &EnterpriseInfoConfirmedEvent{ + BaseCertificationEvent: NewBaseCertificationEvent( + EventTypeEnterpriseInfoConfirmed, + certification.ID, + nil, + ), + } + event.Data.CertificationID = certification.ID + event.Data.UserID = certification.UserID + event.Data.EnterpriseInfo = enterpriseInfo + event.Data.Status = string(certification.Status) + event.Payload = event.Data + return event +} + +// FaceVerifyInitiatedEvent 人脸识别初始化事件 +type FaceVerifyInitiatedEvent struct { + *BaseCertificationEvent + Data struct { + CertificationID string `json:"certification_id"` + UserID string `json:"user_id"` + VerifyToken string `json:"verify_token"` + Status string `json:"status"` + } `json:"data"` +} + +// NewFaceVerifyInitiatedEvent 创建人脸识别初始化事件 +func NewFaceVerifyInitiatedEvent(certification *entities.Certification, verifyToken string) *FaceVerifyInitiatedEvent { + event := &FaceVerifyInitiatedEvent{ + BaseCertificationEvent: NewBaseCertificationEvent( + EventTypeFaceVerifyInitiated, + certification.ID, + nil, + ), + } + event.Data.CertificationID = certification.ID + event.Data.UserID = certification.UserID + event.Data.VerifyToken = verifyToken + event.Data.Status = string(certification.Status) + event.Payload = event.Data + return event +} + +// FaceVerifyCompletedEvent 人脸识别完成事件 +type FaceVerifyCompletedEvent struct { + *BaseCertificationEvent + Data struct { + CertificationID string `json:"certification_id"` + UserID string `json:"user_id"` + VerifyToken string `json:"verify_token"` + Success bool `json:"success"` + Score float64 `json:"score"` + Status string `json:"status"` + } `json:"data"` +} + +// NewFaceVerifyCompletedEvent 创建人脸识别完成事件 +func NewFaceVerifyCompletedEvent(certification *entities.Certification, record *entities.FaceVerifyRecord) *FaceVerifyCompletedEvent { + event := &FaceVerifyCompletedEvent{ + BaseCertificationEvent: NewBaseCertificationEvent( + EventTypeFaceVerifyCompleted, + certification.ID, + nil, + ), + } + event.Data.CertificationID = certification.ID + event.Data.UserID = certification.UserID + event.Data.VerifyToken = record.CertifyID + event.Data.Success = record.IsSuccess() + event.Data.Score = record.VerifyScore + event.Data.Status = string(certification.Status) + event.Payload = event.Data + return event +} + +// ContractRequestedEvent 合同申请事件 +type ContractRequestedEvent struct { + *BaseCertificationEvent + Data struct { + CertificationID string `json:"certification_id"` + UserID string `json:"user_id"` + Status string `json:"status"` + } `json:"data"` +} + +// NewContractRequestedEvent 创建合同申请事件 +func NewContractRequestedEvent(certification *entities.Certification) *ContractRequestedEvent { + event := &ContractRequestedEvent{ + BaseCertificationEvent: NewBaseCertificationEvent( + EventTypeContractRequested, + certification.ID, + nil, + ), + } + event.Data.CertificationID = certification.ID + event.Data.UserID = certification.UserID + event.Data.Status = string(certification.Status) + event.Payload = event.Data + return event +} + +// ContractGeneratedEvent 合同生成事件 +type ContractGeneratedEvent struct { + *BaseCertificationEvent + Data struct { + CertificationID string `json:"certification_id"` + UserID string `json:"user_id"` + ContractURL string `json:"contract_url"` + ContractID string `json:"contract_id"` + Status string `json:"status"` + } `json:"data"` +} + +// NewContractGeneratedEvent 创建合同生成事件 +func NewContractGeneratedEvent(certification *entities.Certification, record *entities.ContractRecord) *ContractGeneratedEvent { + event := &ContractGeneratedEvent{ + BaseCertificationEvent: NewBaseCertificationEvent( + EventTypeContractGenerated, + certification.ID, + nil, + ), + } + event.Data.CertificationID = certification.ID + event.Data.UserID = certification.UserID + event.Data.ContractURL = record.ContractURL + event.Data.ContractID = record.ID + event.Data.Status = string(certification.Status) + event.Payload = event.Data + return event +} + +// ContractSignedEvent 合同签署事件 +type ContractSignedEvent struct { + *BaseCertificationEvent + Data struct { + CertificationID string `json:"certification_id"` + UserID string `json:"user_id"` + ContractID string `json:"contract_id"` + SignedAt string `json:"signed_at"` + Status string `json:"status"` + } `json:"data"` +} + +// NewContractSignedEvent 创建合同签署事件 +func NewContractSignedEvent(certification *entities.Certification, record *entities.ContractRecord) *ContractSignedEvent { + event := &ContractSignedEvent{ + BaseCertificationEvent: NewBaseCertificationEvent( + EventTypeContractSigned, + certification.ID, + nil, + ), + } + event.Data.CertificationID = certification.ID + event.Data.UserID = certification.UserID + event.Data.ContractID = record.ID + event.Data.SignedAt = record.SignedAt.Format(time.RFC3339) + event.Data.Status = string(certification.Status) + event.Payload = event.Data + return event +} + +// CertificationApprovedEvent 认证审核通过事件 +type CertificationApprovedEvent struct { + *BaseCertificationEvent + Data struct { + CertificationID string `json:"certification_id"` + UserID string `json:"user_id"` + AdminID string `json:"admin_id"` + ApprovedAt string `json:"approved_at"` + Status string `json:"status"` + } `json:"data"` +} + +// NewCertificationApprovedEvent 创建认证审核通过事件 +func NewCertificationApprovedEvent(certification *entities.Certification, adminID string) *CertificationApprovedEvent { + event := &CertificationApprovedEvent{ + BaseCertificationEvent: NewBaseCertificationEvent( + EventTypeCertificationApproved, + certification.ID, + nil, + ), + } + event.Data.CertificationID = certification.ID + event.Data.UserID = certification.UserID + event.Data.AdminID = adminID + event.Data.ApprovedAt = time.Now().Format(time.RFC3339) + event.Data.Status = string(certification.Status) + event.Payload = event.Data + return event +} + +// CertificationRejectedEvent 认证审核拒绝事件 +type CertificationRejectedEvent struct { + *BaseCertificationEvent + Data struct { + CertificationID string `json:"certification_id"` + UserID string `json:"user_id"` + AdminID string `json:"admin_id"` + RejectReason string `json:"reject_reason"` + RejectedAt string `json:"rejected_at"` + Status string `json:"status"` + } `json:"data"` +} + +// NewCertificationRejectedEvent 创建认证审核拒绝事件 +func NewCertificationRejectedEvent(certification *entities.Certification, adminID, rejectReason string) *CertificationRejectedEvent { + event := &CertificationRejectedEvent{ + BaseCertificationEvent: NewBaseCertificationEvent( + EventTypeCertificationRejected, + certification.ID, + nil, + ), + } + event.Data.CertificationID = certification.ID + event.Data.UserID = certification.UserID + event.Data.AdminID = adminID + event.Data.RejectReason = rejectReason + event.Data.RejectedAt = time.Now().Format(time.RFC3339) + event.Data.Status = string(certification.Status) + event.Payload = event.Data + return event +} + +// WalletCreatedEvent 钱包创建事件 +type WalletCreatedEvent struct { + *BaseCertificationEvent + Data struct { + CertificationID string `json:"certification_id"` + UserID string `json:"user_id"` + WalletID string `json:"wallet_id"` + AccessID string `json:"access_id"` + Status string `json:"status"` + } `json:"data"` +} + +// NewWalletCreatedEvent 创建钱包创建事件 +func NewWalletCreatedEvent(certification *entities.Certification, walletID, accessID string) *WalletCreatedEvent { + event := &WalletCreatedEvent{ + BaseCertificationEvent: NewBaseCertificationEvent( + EventTypeWalletCreated, + certification.ID, + nil, + ), + } + event.Data.CertificationID = certification.ID + event.Data.UserID = certification.UserID + event.Data.WalletID = walletID + event.Data.AccessID = accessID + event.Data.Status = string(certification.Status) + event.Payload = event.Data + return event +} + +// CertificationCompletedEvent 认证完成事件 +type CertificationCompletedEvent struct { + *BaseCertificationEvent + Data struct { + CertificationID string `json:"certification_id"` + UserID string `json:"user_id"` + WalletID string `json:"wallet_id"` + CompletedAt string `json:"completed_at"` + Status string `json:"status"` + } `json:"data"` +} + +// NewCertificationCompletedEvent 创建认证完成事件 +func NewCertificationCompletedEvent(certification *entities.Certification, walletID string) *CertificationCompletedEvent { + event := &CertificationCompletedEvent{ + BaseCertificationEvent: NewBaseCertificationEvent( + EventTypeCertificationCompleted, + certification.ID, + nil, + ), + } + event.Data.CertificationID = certification.ID + event.Data.UserID = certification.UserID + event.Data.WalletID = walletID + event.Data.CompletedAt = time.Now().Format(time.RFC3339) + event.Data.Status = string(certification.Status) + event.Payload = event.Data + return event +} + +// CertificationFailedEvent 认证失败事件 +type CertificationFailedEvent struct { + *BaseCertificationEvent + Data struct { + CertificationID string `json:"certification_id"` + UserID string `json:"user_id"` + FailedAt string `json:"failed_at"` + FailureReason string `json:"failure_reason"` + Status string `json:"status"` + } `json:"data"` +} + +// NewCertificationFailedEvent 创建认证失败事件 +func NewCertificationFailedEvent(certification *entities.Certification, failureReason string) *CertificationFailedEvent { + event := &CertificationFailedEvent{ + BaseCertificationEvent: NewBaseCertificationEvent( + EventTypeCertificationFailed, + certification.ID, + nil, + ), + } + event.Data.CertificationID = certification.ID + event.Data.UserID = certification.UserID + event.Data.FailedAt = time.Now().Format(time.RFC3339) + event.Data.FailureReason = failureReason + event.Data.Status = string(certification.Status) + event.Payload = event.Data + return event +} + +// generateEventID 生成事件ID +func generateEventID() string { + return time.Now().Format("20060102150405") + "-" + generateRandomString(8) +} + +// generateRandomString 生成随机字符串 +func generateRandomString(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, length) + for i := range b { + b[i] = charset[time.Now().UnixNano()%int64(len(charset))] + } + return string(b) +} diff --git a/internal/domains/certification/events/event_handlers.go b/internal/domains/certification/events/event_handlers.go new file mode 100644 index 0000000..e1b42ed --- /dev/null +++ b/internal/domains/certification/events/event_handlers.go @@ -0,0 +1,489 @@ +package events + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "go.uber.org/zap" + + "tyapi-server/internal/shared/interfaces" + "tyapi-server/internal/shared/notification" +) + +// CertificationEventHandler 认证事件处理器 +type CertificationEventHandler struct { + logger *zap.Logger + notification notification.WeChatWorkService + name string + eventTypes []string + isAsync bool +} + +// NewCertificationEventHandler 创建认证事件处理器 +func NewCertificationEventHandler(logger *zap.Logger, notification notification.WeChatWorkService) *CertificationEventHandler { + return &CertificationEventHandler{ + logger: logger, + notification: notification, + name: "certification-event-handler", + eventTypes: []string{ + EventTypeCertificationCreated, + EventTypeCertificationSubmitted, + EventTypeLicenseUploaded, + EventTypeOCRCompleted, + EventTypeEnterpriseInfoConfirmed, + EventTypeFaceVerifyInitiated, + EventTypeFaceVerifyCompleted, + EventTypeContractRequested, + EventTypeContractGenerated, + EventTypeContractSigned, + EventTypeCertificationApproved, + EventTypeCertificationRejected, + EventTypeWalletCreated, + EventTypeCertificationCompleted, + EventTypeCertificationFailed, + }, + isAsync: true, + } +} + +// GetName 获取处理器名称 +func (h *CertificationEventHandler) GetName() string { + return h.name +} + +// GetEventTypes 获取支持的事件类型 +func (h *CertificationEventHandler) GetEventTypes() []string { + return h.eventTypes +} + +// IsAsync 是否为异步处理器 +func (h *CertificationEventHandler) IsAsync() bool { + return h.isAsync +} + +// GetRetryConfig 获取重试配置 +func (h *CertificationEventHandler) GetRetryConfig() interfaces.RetryConfig { + return interfaces.RetryConfig{ + MaxRetries: 3, + RetryDelay: 5 * time.Second, + BackoffFactor: 2.0, + MaxDelay: 30 * time.Second, + } +} + +// Handle 处理事件 +func (h *CertificationEventHandler) Handle(ctx context.Context, event interfaces.Event) error { + h.logger.Info("处理认证事件", + zap.String("event_type", event.GetType()), + zap.String("event_id", event.GetID()), + zap.String("aggregate_id", event.GetAggregateID()), + ) + + switch event.GetType() { + case EventTypeCertificationCreated: + return h.handleCertificationCreated(ctx, event) + case EventTypeCertificationSubmitted: + return h.handleCertificationSubmitted(ctx, event) + case EventTypeLicenseUploaded: + return h.handleLicenseUploaded(ctx, event) + case EventTypeOCRCompleted: + return h.handleOCRCompleted(ctx, event) + case EventTypeEnterpriseInfoConfirmed: + return h.handleEnterpriseInfoConfirmed(ctx, event) + case EventTypeFaceVerifyInitiated: + return h.handleFaceVerifyInitiated(ctx, event) + case EventTypeFaceVerifyCompleted: + return h.handleFaceVerifyCompleted(ctx, event) + case EventTypeContractRequested: + return h.handleContractRequested(ctx, event) + case EventTypeContractGenerated: + return h.handleContractGenerated(ctx, event) + case EventTypeContractSigned: + return h.handleContractSigned(ctx, event) + case EventTypeCertificationApproved: + return h.handleCertificationApproved(ctx, event) + case EventTypeCertificationRejected: + return h.handleCertificationRejected(ctx, event) + case EventTypeWalletCreated: + return h.handleWalletCreated(ctx, event) + case EventTypeCertificationCompleted: + return h.handleCertificationCompleted(ctx, event) + case EventTypeCertificationFailed: + return h.handleCertificationFailed(ctx, event) + default: + h.logger.Warn("未知的事件类型", zap.String("event_type", event.GetType())) + return nil + } +} + +// handleCertificationCreated 处理认证创建事件 +func (h *CertificationEventHandler) handleCertificationCreated(ctx context.Context, event interfaces.Event) error { + h.logger.Info("认证申请已创建", + zap.String("certification_id", event.GetAggregateID()), + zap.String("user_id", h.extractUserID(event)), + ) + + // 发送通知给用户 + message := fmt.Sprintf("🎉 您的企业认证申请已创建成功!\n\n认证ID: %s\n创建时间: %s\n\n请按照指引完成后续认证步骤。", + event.GetAggregateID(), + event.GetTimestamp().Format("2006-01-02 15:04:05")) + + return h.sendUserNotification(ctx, event, "认证申请创建成功", message) +} + +// handleCertificationSubmitted 处理认证提交事件 +func (h *CertificationEventHandler) handleCertificationSubmitted(ctx context.Context, event interfaces.Event) error { + h.logger.Info("认证申请已提交", + zap.String("certification_id", event.GetAggregateID()), + zap.String("user_id", h.extractUserID(event)), + ) + + // 发送通知给管理员 + adminMessage := fmt.Sprintf("📋 新的企业认证申请待审核\n\n认证ID: %s\n用户ID: %s\n提交时间: %s\n\n请及时处理审核。", + event.GetAggregateID(), + h.extractUserID(event), + event.GetTimestamp().Format("2006-01-02 15:04:05")) + + return h.sendAdminNotification(ctx, event, "新认证申请待审核", adminMessage) +} + +// handleLicenseUploaded 处理营业执照上传事件 +func (h *CertificationEventHandler) handleLicenseUploaded(ctx context.Context, event interfaces.Event) error { + h.logger.Info("营业执照已上传", + zap.String("certification_id", event.GetAggregateID()), + zap.String("user_id", h.extractUserID(event)), + ) + + // 发送通知给用户 + message := fmt.Sprintf("📄 营业执照上传成功!\n\n认证ID: %s\n上传时间: %s\n\n系统正在识别营业执照信息,请稍候...", + event.GetAggregateID(), + event.GetTimestamp().Format("2006-01-02 15:04:05")) + + return h.sendUserNotification(ctx, event, "营业执照上传成功", message) +} + +// handleOCRCompleted 处理OCR识别完成事件 +func (h *CertificationEventHandler) handleOCRCompleted(ctx context.Context, event interfaces.Event) error { + h.logger.Info("OCR识别已完成", + zap.String("certification_id", event.GetAggregateID()), + zap.String("user_id", h.extractUserID(event)), + ) + + // 发送通知给用户 + message := fmt.Sprintf("✅ OCR识别完成!\n\n认证ID: %s\n识别时间: %s\n\n请确认企业信息是否正确,如有问题请及时联系客服。", + event.GetAggregateID(), + event.GetTimestamp().Format("2006-01-02 15:04:05")) + + return h.sendUserNotification(ctx, event, "OCR识别完成", message) +} + +// handleEnterpriseInfoConfirmed 处理企业信息确认事件 +func (h *CertificationEventHandler) handleEnterpriseInfoConfirmed(ctx context.Context, event interfaces.Event) error { + h.logger.Info("企业信息已确认", + zap.String("certification_id", event.GetAggregateID()), + zap.String("user_id", h.extractUserID(event)), + ) + + // 发送通知给用户 + message := fmt.Sprintf("✅ 企业信息确认成功!\n\n认证ID: %s\n确认时间: %s\n\n下一步:请完成人脸识别验证。", + event.GetAggregateID(), + event.GetTimestamp().Format("2006-01-02 15:04:05")) + + return h.sendUserNotification(ctx, event, "企业信息确认成功", message) +} + +// handleFaceVerifyInitiated 处理人脸识别初始化事件 +func (h *CertificationEventHandler) handleFaceVerifyInitiated(ctx context.Context, event interfaces.Event) error { + h.logger.Info("人脸识别已初始化", + zap.String("certification_id", event.GetAggregateID()), + zap.String("user_id", h.extractUserID(event)), + ) + + // 发送通知给用户 + message := fmt.Sprintf("👤 人脸识别验证已开始!\n\n认证ID: %s\n开始时间: %s\n\n请按照指引完成人脸识别验证。", + event.GetAggregateID(), + event.GetTimestamp().Format("2006-01-02 15:04:05")) + + return h.sendUserNotification(ctx, event, "人脸识别验证开始", message) +} + +// handleFaceVerifyCompleted 处理人脸识别完成事件 +func (h *CertificationEventHandler) handleFaceVerifyCompleted(ctx context.Context, event interfaces.Event) error { + h.logger.Info("人脸识别已完成", + zap.String("certification_id", event.GetAggregateID()), + zap.String("user_id", h.extractUserID(event)), + ) + + // 发送通知给用户 + message := fmt.Sprintf("✅ 人脸识别验证完成!\n\n认证ID: %s\n完成时间: %s\n\n下一步:系统将为您申请电子合同。", + event.GetAggregateID(), + event.GetTimestamp().Format("2006-01-02 15:04:05")) + + return h.sendUserNotification(ctx, event, "人脸识别验证完成", message) +} + +// handleContractRequested 处理合同申请事件 +func (h *CertificationEventHandler) handleContractRequested(ctx context.Context, event interfaces.Event) error { + h.logger.Info("电子合同申请已提交", + zap.String("certification_id", event.GetAggregateID()), + zap.String("user_id", h.extractUserID(event)), + ) + + // 发送通知给管理员 + adminMessage := fmt.Sprintf("📋 新的电子合同申请待审核\n\n认证ID: %s\n用户ID: %s\n申请时间: %s\n\n请及时处理合同审核。", + event.GetAggregateID(), + h.extractUserID(event), + event.GetTimestamp().Format("2006-01-02 15:04:05")) + + return h.sendAdminNotification(ctx, event, "新合同申请待审核", adminMessage) +} + +// handleContractGenerated 处理合同生成事件 +func (h *CertificationEventHandler) handleContractGenerated(ctx context.Context, event interfaces.Event) error { + h.logger.Info("电子合同已生成", + zap.String("certification_id", event.GetAggregateID()), + zap.String("user_id", h.extractUserID(event)), + ) + + // 发送通知给用户 + message := fmt.Sprintf("📄 电子合同已生成!\n\n认证ID: %s\n生成时间: %s\n\n请及时签署电子合同以完成认证流程。", + event.GetAggregateID(), + event.GetTimestamp().Format("2006-01-02 15:04:05")) + + return h.sendUserNotification(ctx, event, "电子合同已生成", message) +} + +// handleContractSigned 处理合同签署事件 +func (h *CertificationEventHandler) handleContractSigned(ctx context.Context, event interfaces.Event) error { + h.logger.Info("电子合同已签署", + zap.String("certification_id", event.GetAggregateID()), + zap.String("user_id", h.extractUserID(event)), + ) + + // 发送通知给用户 + message := fmt.Sprintf("✅ 电子合同签署成功!\n\n认证ID: %s\n签署时间: %s\n\n您的企业认证申请已进入最终审核阶段。", + event.GetAggregateID(), + event.GetTimestamp().Format("2006-01-02 15:04:05")) + + return h.sendUserNotification(ctx, event, "电子合同签署成功", message) +} + +// handleCertificationApproved 处理认证审核通过事件 +func (h *CertificationEventHandler) handleCertificationApproved(ctx context.Context, event interfaces.Event) error { + h.logger.Info("认证申请已审核通过", + zap.String("certification_id", event.GetAggregateID()), + zap.String("user_id", h.extractUserID(event)), + ) + + // 发送通知给用户 + message := fmt.Sprintf("🎉 恭喜!您的企业认证申请已审核通过!\n\n认证ID: %s\n审核时间: %s\n\n系统正在为您创建钱包和访问密钥...", + event.GetAggregateID(), + event.GetTimestamp().Format("2006-01-02 15:04:05")) + + return h.sendUserNotification(ctx, event, "认证申请审核通过", message) +} + +// handleCertificationRejected 处理认证审核拒绝事件 +func (h *CertificationEventHandler) handleCertificationRejected(ctx context.Context, event interfaces.Event) error { + h.logger.Info("认证申请已被拒绝", + zap.String("certification_id", event.GetAggregateID()), + zap.String("user_id", h.extractUserID(event)), + ) + + // 发送通知给用户 + message := fmt.Sprintf("❌ 很抱歉,您的企业认证申请未通过审核\n\n认证ID: %s\n拒绝时间: %s\n\n请根据拒绝原因修改后重新提交申请。", + event.GetAggregateID(), + event.GetTimestamp().Format("2006-01-02 15:04:05")) + + return h.sendUserNotification(ctx, event, "认证申请审核未通过", message) +} + +// handleWalletCreated 处理钱包创建事件 +func (h *CertificationEventHandler) handleWalletCreated(ctx context.Context, event interfaces.Event) error { + h.logger.Info("钱包已创建", + zap.String("certification_id", event.GetAggregateID()), + zap.String("user_id", h.extractUserID(event)), + ) + + // 发送通知给用户 + message := fmt.Sprintf("💰 钱包创建成功!\n\n认证ID: %s\n创建时间: %s\n\n您的企业钱包已激活,可以开始使用相关服务。", + event.GetAggregateID(), + event.GetTimestamp().Format("2006-01-02 15:04:05")) + + return h.sendUserNotification(ctx, event, "钱包创建成功", message) +} + +// handleCertificationCompleted 处理认证完成事件 +func (h *CertificationEventHandler) handleCertificationCompleted(ctx context.Context, event interfaces.Event) error { + h.logger.Info("企业认证已完成", + zap.String("certification_id", event.GetAggregateID()), + zap.String("user_id", h.extractUserID(event)), + ) + + // 发送通知给用户 + message := fmt.Sprintf("🎉 恭喜!您的企业认证已全部完成!\n\n认证ID: %s\n完成时间: %s\n\n您现在可以享受完整的企业级服务功能。", + event.GetAggregateID(), + event.GetTimestamp().Format("2006-01-02 15:04:05")) + + return h.sendUserNotification(ctx, event, "企业认证完成", message) +} + +// handleCertificationFailed 处理认证失败事件 +func (h *CertificationEventHandler) handleCertificationFailed(ctx context.Context, event interfaces.Event) error { + h.logger.Error("企业认证失败", + zap.String("certification_id", event.GetAggregateID()), + zap.String("user_id", h.extractUserID(event)), + ) + + // 发送通知给用户 + message := fmt.Sprintf("❌ 企业认证流程遇到问题\n\n认证ID: %s\n失败时间: %s\n\n请联系客服获取帮助。", + event.GetAggregateID(), + event.GetTimestamp().Format("2006-01-02 15:04:05")) + + return h.sendUserNotification(ctx, event, "企业认证失败", message) +} + +// sendUserNotification 发送用户通知 +func (h *CertificationEventHandler) sendUserNotification(ctx context.Context, event interfaces.Event, title, message string) error { + url := fmt.Sprintf("https://example.com/certification/%s", event.GetAggregateID()) + btnText := "查看详情" + if err := h.notification.SendCardMessage(ctx, title, message, url, btnText); err != nil { + h.logger.Error("发送用户通知失败", + zap.String("event_type", event.GetType()), + zap.String("event_id", event.GetID()), + zap.Error(err), + ) + return err + } + + h.logger.Info("用户通知发送成功", + zap.String("event_type", event.GetType()), + zap.String("event_id", event.GetID()), + ) + + return nil +} + +// sendAdminNotification 发送管理员通知 +func (h *CertificationEventHandler) sendAdminNotification(ctx context.Context, event interfaces.Event, title, message string) error { + url := fmt.Sprintf("https://admin.example.com/certification/%s", event.GetAggregateID()) + btnText := "立即处理" + if err := h.notification.SendCardMessage(ctx, title, message, url, btnText); err != nil { + h.logger.Error("发送管理员通知失败", + zap.String("event_type", event.GetType()), + zap.String("event_id", event.GetID()), + zap.Error(err), + ) + return err + } + + h.logger.Info("管理员通知发送成功", + zap.String("event_type", event.GetType()), + zap.String("event_id", event.GetID()), + ) + + return nil +} + +// extractUserID 从事件中提取用户ID +func (h *CertificationEventHandler) extractUserID(event interfaces.Event) string { + if payload, ok := event.GetPayload().(map[string]interface{}); ok { + if userID, exists := payload["user_id"]; exists { + if id, ok := userID.(string); ok { + return id + } + } + } + + // 尝试从事件数据中提取 + if eventData, ok := event.(*BaseCertificationEvent); ok { + if data, ok := eventData.Payload.(map[string]interface{}); ok { + if userID, exists := data["user_id"]; exists { + if id, ok := userID.(string); ok { + return id + } + } + } + } + + return "unknown" +} + +// LoggingEventHandler 日志记录事件处理器 +type LoggingEventHandler struct { + logger *zap.Logger + name string + eventTypes []string + isAsync bool +} + +// NewLoggingEventHandler 创建日志记录事件处理器 +func NewLoggingEventHandler(logger *zap.Logger) *LoggingEventHandler { + return &LoggingEventHandler{ + logger: logger, + name: "logging-event-handler", + eventTypes: []string{ + EventTypeCertificationCreated, + EventTypeCertificationSubmitted, + EventTypeLicenseUploaded, + EventTypeOCRCompleted, + EventTypeEnterpriseInfoConfirmed, + EventTypeFaceVerifyInitiated, + EventTypeFaceVerifyCompleted, + EventTypeContractRequested, + EventTypeContractGenerated, + EventTypeContractSigned, + EventTypeCertificationApproved, + EventTypeCertificationRejected, + EventTypeWalletCreated, + EventTypeCertificationCompleted, + EventTypeCertificationFailed, + }, + isAsync: false, // 同步处理,确保日志及时记录 + } +} + +// GetName 获取处理器名称 +func (l *LoggingEventHandler) GetName() string { + return l.name +} + +// GetEventTypes 获取支持的事件类型 +func (l *LoggingEventHandler) GetEventTypes() []string { + return l.eventTypes +} + +// IsAsync 是否为异步处理器 +func (l *LoggingEventHandler) IsAsync() bool { + return l.isAsync +} + +// GetRetryConfig 获取重试配置 +func (l *LoggingEventHandler) GetRetryConfig() interfaces.RetryConfig { + return interfaces.RetryConfig{ + MaxRetries: 1, + RetryDelay: 1 * time.Second, + BackoffFactor: 1.0, + MaxDelay: 1 * time.Second, + } +} + +// Handle 处理事件 +func (l *LoggingEventHandler) Handle(ctx context.Context, event interfaces.Event) error { + // 记录结构化日志 + eventData, _ := json.Marshal(event.GetPayload()) + + l.logger.Info("认证事件记录", + zap.String("event_id", event.GetID()), + zap.String("event_type", event.GetType()), + zap.String("aggregate_id", event.GetAggregateID()), + zap.String("aggregate_type", event.GetAggregateType()), + zap.Time("timestamp", event.GetTimestamp()), + zap.String("source", event.GetSource()), + zap.String("payload", string(eventData)), + ) + + return nil +} diff --git a/internal/domains/certification/handlers/certification_handler.go b/internal/domains/certification/handlers/certification_handler.go new file mode 100644 index 0000000..c340fdf --- /dev/null +++ b/internal/domains/certification/handlers/certification_handler.go @@ -0,0 +1,536 @@ +package handlers + +import ( + "io" + "path/filepath" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "tyapi-server/internal/domains/certification/dto" + "tyapi-server/internal/domains/certification/services" + "tyapi-server/internal/shared/interfaces" +) + +// CertificationHandler 认证处理器 +type CertificationHandler struct { + certificationService *services.CertificationService + response interfaces.ResponseBuilder + logger *zap.Logger +} + +// NewCertificationHandler 创建认证处理器 +func NewCertificationHandler( + certificationService *services.CertificationService, + response interfaces.ResponseBuilder, + logger *zap.Logger, +) *CertificationHandler { + return &CertificationHandler{ + certificationService: certificationService, + response: response, + logger: logger, + } +} + +// CreateCertification 创建认证申请 +// @Summary 创建认证申请 +// @Description 用户创建企业认证申请 +// @Tags 认证 +// @Accept json +// @Produce json +// @Success 200 {object} dto.CertificationCreateResponse +// @Failure 400 {object} interfaces.APIResponse +// @Failure 500 {object} interfaces.APIResponse +// @Router /api/v1/certification/create [post] +func (h *CertificationHandler) CreateCertification(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + h.response.Unauthorized(c, "用户未认证") + return + } + + result, err := h.certificationService.CreateCertification(c.Request.Context(), userID) + if err != nil { + h.logger.Error("创建认证申请失败", + zap.String("user_id", userID), + zap.Error(err), + ) + h.response.InternalError(c, "创建认证申请失败") + return + } + + h.response.Success(c, result, "认证申请创建成功") +} + +// UploadLicense 上传营业执照 +// @Summary 上传营业执照 +// @Description 上传营业执照文件并进行OCR识别 +// @Tags 认证 +// @Accept multipart/form-data +// @Produce json +// @Param file formData file true "营业执照文件" +// @Success 200 {object} dto.UploadLicenseResponse +// @Failure 400 {object} interfaces.APIResponse +// @Failure 500 {object} interfaces.APIResponse +// @Router /api/v1/certification/upload-license [post] +func (h *CertificationHandler) UploadLicense(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + h.response.Unauthorized(c, "用户未认证") + return + } + + // 获取上传的文件 + file, header, err := c.Request.FormFile("file") + if err != nil { + h.logger.Error("获取上传文件失败", zap.Error(err)) + h.response.BadRequest(c, "请选择要上传的文件") + return + } + defer file.Close() + + // 检查文件类型 + fileName := header.Filename + ext := strings.ToLower(filepath.Ext(fileName)) + allowedExts := []string{".jpg", ".jpeg", ".png", ".pdf"} + + isAllowed := false + for _, allowedExt := range allowedExts { + if ext == allowedExt { + isAllowed = true + break + } + } + + if !isAllowed { + h.response.BadRequest(c, "文件格式不支持,仅支持 JPG、PNG、PDF 格式") + return + } + + // 检查文件大小(限制为10MB) + const maxFileSize = 10 * 1024 * 1024 // 10MB + if header.Size > maxFileSize { + h.response.BadRequest(c, "文件大小不能超过10MB") + return + } + + // 读取文件内容 + fileBytes, err := io.ReadAll(file) + if err != nil { + h.logger.Error("读取文件内容失败", zap.Error(err)) + h.response.InternalError(c, "文件读取失败") + return + } + + // 调用服务上传文件 + result, err := h.certificationService.UploadLicense(c.Request.Context(), userID, fileBytes, fileName) + if err != nil { + h.logger.Error("上传营业执照失败", + zap.String("user_id", userID), + zap.String("file_name", fileName), + zap.Error(err), + ) + h.response.InternalError(c, "上传失败,请稍后重试") + return + } + + h.response.Success(c, result, "营业执照上传成功") +} + +// SubmitEnterpriseInfo 提交企业信息 +// @Summary 提交企业信息 +// @Description 确认并提交企业四要素信息 +// @Tags 认证 +// @Accept json +// @Produce json +// @Param id path string true "认证申请ID" +// @Param request body dto.SubmitEnterpriseInfoRequest true "企业信息" +// @Success 200 {object} dto.SubmitEnterpriseInfoResponse +// @Failure 400 {object} interfaces.APIResponse +// @Failure 500 {object} interfaces.APIResponse +// @Router /api/v1/certification/{id}/submit-info [put] +func (h *CertificationHandler) SubmitEnterpriseInfo(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + h.response.Unauthorized(c, "用户未认证") + return + } + + certificationID := c.Param("id") + if certificationID == "" { + h.response.BadRequest(c, "认证申请ID不能为空") + return + } + + var req dto.SubmitEnterpriseInfoRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Error("参数绑定失败", zap.Error(err)) + h.response.BadRequest(c, "请求参数格式错误") + return + } + + // 验证企业信息 + if req.CompanyName == "" { + h.response.BadRequest(c, "企业名称不能为空") + return + } + if req.UnifiedSocialCode == "" { + h.response.BadRequest(c, "统一社会信用代码不能为空") + return + } + if req.LegalPersonName == "" { + h.response.BadRequest(c, "法定代表人姓名不能为空") + return + } + if req.LegalPersonID == "" { + h.response.BadRequest(c, "法定代表人身份证号不能为空") + return + } + if req.LicenseUploadRecordID == "" { + h.response.BadRequest(c, "营业执照上传记录ID不能为空") + return + } + + result, err := h.certificationService.SubmitEnterpriseInfo(c.Request.Context(), certificationID, &req) + if err != nil { + h.logger.Error("提交企业信息失败", + zap.String("certification_id", certificationID), + zap.String("user_id", userID), + zap.Error(err), + ) + if strings.Contains(err.Error(), "已被使用") || strings.Contains(err.Error(), "不允许") { + h.response.BadRequest(c, err.Error()) + } else { + h.response.InternalError(c, "提交失败,请稍后重试") + } + return + } + + h.response.Success(c, result, "企业信息提交成功") +} + +// InitiateFaceVerify 初始化人脸识别 +// @Summary 初始化人脸识别 +// @Description 开始人脸识别认证流程 +// @Tags 认证 +// @Accept json +// @Produce json +// @Param id path string true "认证申请ID" +// @Param request body dto.FaceVerifyRequest true "人脸识别请求" +// @Success 200 {object} dto.FaceVerifyResponse +// @Failure 400 {object} interfaces.APIResponse +// @Failure 500 {object} interfaces.APIResponse +// @Router /api/v1/certification/{id}/face-verify [post] +func (h *CertificationHandler) InitiateFaceVerify(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + h.response.Unauthorized(c, "用户未认证") + return + } + + certificationID := c.Param("id") + if certificationID == "" { + h.response.BadRequest(c, "认证申请ID不能为空") + return + } + + var req dto.FaceVerifyRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Error("参数绑定失败", zap.Error(err)) + h.response.BadRequest(c, "请求参数格式错误") + return + } + + // 验证请求参数 + if req.RealName == "" { + h.response.BadRequest(c, "真实姓名不能为空") + return + } + if req.IDCardNumber == "" { + h.response.BadRequest(c, "身份证号不能为空") + return + } + + result, err := h.certificationService.InitiateFaceVerify(c.Request.Context(), certificationID, &req) + if err != nil { + h.logger.Error("初始化人脸识别失败", + zap.String("certification_id", certificationID), + zap.String("user_id", userID), + zap.Error(err), + ) + if strings.Contains(err.Error(), "不允许") { + h.response.BadRequest(c, err.Error()) + } else { + h.response.InternalError(c, "初始化失败,请稍后重试") + } + return + } + + h.response.Success(c, result, "人脸识别初始化成功") +} + +// ApplyContract 申请电子合同 +// @Summary 申请电子合同 +// @Description 申请生成企业认证电子合同 +// @Tags 认证 +// @Accept json +// @Produce json +// @Param id path string true "认证申请ID" +// @Success 200 {object} dto.ApplyContractResponse +// @Failure 400 {object} interfaces.APIResponse +// @Failure 500 {object} interfaces.APIResponse +// @Router /api/v1/certification/{id}/apply-contract [post] +func (h *CertificationHandler) ApplyContract(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + h.response.Unauthorized(c, "用户未认证") + return + } + + certificationID := c.Param("id") + if certificationID == "" { + h.response.BadRequest(c, "认证申请ID不能为空") + return + } + + result, err := h.certificationService.ApplyContract(c.Request.Context(), certificationID) + if err != nil { + h.logger.Error("申请电子合同失败", + zap.String("certification_id", certificationID), + zap.String("user_id", userID), + zap.Error(err), + ) + if strings.Contains(err.Error(), "不允许") { + h.response.BadRequest(c, err.Error()) + } else { + h.response.InternalError(c, "申请失败,请稍后重试") + } + return + } + + h.response.Success(c, result, "合同申请提交成功,请等待管理员审核") +} + +// GetCertificationStatus 获取认证状态 +// @Summary 获取认证状态 +// @Description 查询当前用户的认证申请状态和进度 +// @Tags 认证 +// @Accept json +// @Produce json +// @Success 200 {object} dto.CertificationStatusResponse +// @Failure 400 {object} interfaces.APIResponse +// @Failure 500 {object} interfaces.APIResponse +// @Router /api/v1/certification/status [get] +func (h *CertificationHandler) GetCertificationStatus(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + h.response.Unauthorized(c, "用户未认证") + return + } + + result, err := h.certificationService.GetCertificationStatus(c.Request.Context(), userID) + if err != nil { + h.logger.Error("获取认证状态失败", + zap.String("user_id", userID), + zap.Error(err), + ) + if strings.Contains(err.Error(), "不存在") { + h.response.NotFound(c, "未找到认证申请记录") + } else { + h.response.InternalError(c, "查询失败,请稍后重试") + } + return + } + + h.response.Success(c, result, "查询成功") +} + +// GetCertificationDetails 获取认证详情 +// @Summary 获取认证详情 +// @Description 获取指定认证申请的详细信息 +// @Tags 认证 +// @Accept json +// @Produce json +// @Param id path string true "认证申请ID" +// @Success 200 {object} dto.CertificationStatusResponse +// @Failure 400 {object} interfaces.APIResponse +// @Failure 500 {object} interfaces.APIResponse +// @Router /api/v1/certification/{id} [get] +func (h *CertificationHandler) GetCertificationDetails(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + h.response.Unauthorized(c, "用户未认证") + return + } + + certificationID := c.Param("id") + if certificationID == "" { + h.response.BadRequest(c, "认证申请ID不能为空") + return + } + + // 通过用户ID获取状态来确保用户只能查看自己的认证记录 + result, err := h.certificationService.GetCertificationStatus(c.Request.Context(), userID) + if err != nil { + h.logger.Error("获取认证详情失败", + zap.String("certification_id", certificationID), + zap.String("user_id", userID), + zap.Error(err), + ) + if strings.Contains(err.Error(), "不存在") { + h.response.NotFound(c, "未找到认证申请记录") + } else { + h.response.InternalError(c, "查询失败,请稍后重试") + } + return + } + + // 检查是否是用户自己的认证记录 + if result.ID != certificationID { + h.response.Forbidden(c, "无权访问此认证记录") + return + } + + h.response.Success(c, result, "查询成功") +} + +// RetryStep 重试认证步骤 +// @Summary 重试认证步骤 +// @Description 重试失败的认证步骤(如人脸识别失败、签署失败等) +// @Tags 认证 +// @Accept json +// @Produce json +// @Param id path string true "认证申请ID" +// @Param step query string true "重试步骤(face_verify, sign_contract)" +// @Success 200 {object} interfaces.APIResponse +// @Failure 400 {object} interfaces.APIResponse +// @Failure 500 {object} interfaces.APIResponse +// @Router /api/v1/certification/{id}/retry [post] +func (h *CertificationHandler) RetryStep(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + h.response.Unauthorized(c, "用户未认证") + return + } + + certificationID := c.Param("id") + if certificationID == "" { + h.response.BadRequest(c, "认证申请ID不能为空") + return + } + + step := c.Query("step") + if step == "" { + h.response.BadRequest(c, "重试步骤不能为空") + return + } + + // TODO: 实现重试逻辑 + // 这里需要根据不同的步骤调用状态机进行状态重置 + + h.logger.Info("重试认证步骤", + zap.String("certification_id", certificationID), + zap.String("user_id", userID), + zap.String("step", step), + ) + + h.response.Success(c, gin.H{ + "certification_id": certificationID, + "step": step, + "message": "重试操作已提交", + }, "重试操作成功") +} + +// GetProgressStats 获取进度统计 +// @Summary 获取进度统计 +// @Description 获取用户认证申请的进度统计信息 +// @Tags 认证 +// @Accept json +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} interfaces.APIResponse +// @Failure 500 {object} interfaces.APIResponse +// @Router /api/v1/certification/progress [get] +func (h *CertificationHandler) GetProgressStats(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + h.response.Unauthorized(c, "用户未认证") + return + } + + // 获取认证状态 + status, err := h.certificationService.GetCertificationStatus(c.Request.Context(), userID) + if err != nil { + if strings.Contains(err.Error(), "不存在") { + h.response.Success(c, gin.H{ + "has_certification": false, + "progress": 0, + "status": "", + "next_steps": []string{"开始企业认证"}, + }, "查询成功") + return + } + h.response.InternalError(c, "查询失败") + return + } + + // 构建进度统计 + nextSteps := []string{} + if status.IsUserActionRequired { + switch status.Status { + case "pending": + nextSteps = append(nextSteps, "上传营业执照") + case "info_submitted": + nextSteps = append(nextSteps, "进行人脸识别") + case "face_verified": + nextSteps = append(nextSteps, "申请电子合同") + case "contract_approved": + nextSteps = append(nextSteps, "签署电子合同") + case "face_failed": + nextSteps = append(nextSteps, "重新进行人脸识别") + case "sign_failed": + nextSteps = append(nextSteps, "重新签署合同") + } + } else if status.IsAdminActionRequired { + nextSteps = append(nextSteps, "等待管理员审核") + } else { + nextSteps = append(nextSteps, "认证流程已完成") + } + + result := gin.H{ + "has_certification": true, + "certification_id": status.ID, + "progress": status.Progress, + "status": status.Status, + "status_name": status.StatusName, + "is_user_action_required": status.IsUserActionRequired, + "is_admin_action_required": status.IsAdminActionRequired, + "next_steps": nextSteps, + "created_at": status.CreatedAt, + "updated_at": status.UpdatedAt, + } + + h.response.Success(c, result, "查询成功") +} + +// parsePageParams 解析分页参数 +func (h *CertificationHandler) parsePageParams(c *gin.Context) (int, int) { + page := 1 + pageSize := 20 + + if pageStr := c.Query("page"); pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { + page = p + } + } + + if sizeStr := c.Query("page_size"); sizeStr != "" { + if s, err := strconv.Atoi(sizeStr); err == nil && s > 0 && s <= 100 { + pageSize = s + } + } + + return page, pageSize +} diff --git a/internal/domains/certification/repositories/gorm_certification_repository.go b/internal/domains/certification/repositories/gorm_certification_repository.go new file mode 100644 index 0000000..08b8fb2 --- /dev/null +++ b/internal/domains/certification/repositories/gorm_certification_repository.go @@ -0,0 +1,223 @@ +package repositories + +import ( + "context" + "fmt" + + "go.uber.org/zap" + "gorm.io/gorm" + + "tyapi-server/internal/domains/certification/entities" + "tyapi-server/internal/domains/certification/enums" +) + +// GormCertificationRepository GORM认证仓储实现 +type GormCertificationRepository struct { + db *gorm.DB + logger *zap.Logger +} + +// NewGormCertificationRepository 创建GORM认证仓储 +func NewGormCertificationRepository(db *gorm.DB, logger *zap.Logger) CertificationRepository { + return &GormCertificationRepository{ + db: db, + logger: logger, + } +} + +// Create 创建认证记录 +func (r *GormCertificationRepository) Create(ctx context.Context, cert *entities.Certification) error { + if err := r.db.WithContext(ctx).Create(cert).Error; err != nil { + r.logger.Error("创建认证记录失败", + zap.String("user_id", cert.UserID), + zap.Error(err), + ) + return fmt.Errorf("创建认证记录失败: %w", err) + } + + r.logger.Info("认证记录创建成功", + zap.String("id", cert.ID), + zap.String("user_id", cert.UserID), + zap.String("status", string(cert.Status)), + ) + + return nil +} + +// GetByID 根据ID获取认证记录 +func (r *GormCertificationRepository) GetByID(ctx context.Context, id string) (*entities.Certification, error) { + var cert entities.Certification + + if err := r.db.WithContext(ctx).First(&cert, "id = ?", id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("认证记录不存在") + } + r.logger.Error("获取认证记录失败", + zap.String("id", id), + zap.Error(err), + ) + return nil, fmt.Errorf("获取认证记录失败: %w", err) + } + + return &cert, nil +} + +// GetByUserID 根据用户ID获取认证记录 +func (r *GormCertificationRepository) GetByUserID(ctx context.Context, userID string) (*entities.Certification, error) { + var cert entities.Certification + + if err := r.db.WithContext(ctx).First(&cert, "user_id = ?", userID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("用户认证记录不存在") + } + r.logger.Error("获取用户认证记录失败", + zap.String("user_id", userID), + zap.Error(err), + ) + return nil, fmt.Errorf("获取用户认证记录失败: %w", err) + } + + return &cert, nil +} + +// Update 更新认证记录 +func (r *GormCertificationRepository) Update(ctx context.Context, cert *entities.Certification) error { + if err := r.db.WithContext(ctx).Save(cert).Error; err != nil { + r.logger.Error("更新认证记录失败", + zap.String("id", cert.ID), + zap.Error(err), + ) + return fmt.Errorf("更新认证记录失败: %w", err) + } + + r.logger.Info("认证记录更新成功", + zap.String("id", cert.ID), + zap.String("status", string(cert.Status)), + ) + + return nil +} + +// Delete 删除认证记录(软删除) +func (r *GormCertificationRepository) Delete(ctx context.Context, id string) error { + if err := r.db.WithContext(ctx).Delete(&entities.Certification{}, "id = ?", id).Error; err != nil { + r.logger.Error("删除认证记录失败", + zap.String("id", id), + zap.Error(err), + ) + return fmt.Errorf("删除认证记录失败: %w", err) + } + + r.logger.Info("认证记录删除成功", zap.String("id", id)) + return nil +} + +// List 获取认证记录列表 +func (r *GormCertificationRepository) List(ctx context.Context, page, pageSize int, status enums.CertificationStatus) ([]*entities.Certification, int, error) { + var certs []*entities.Certification + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.Certification{}) + + // 如果指定了状态,添加状态过滤 + if status != "" { + query = query.Where("status = ?", status) + } + + // 获取总数 + if err := query.Count(&total).Error; err != nil { + r.logger.Error("获取认证记录总数失败", zap.Error(err)) + return nil, 0, fmt.Errorf("获取认证记录总数失败: %w", err) + } + + // 分页查询 + offset := (page - 1) * pageSize + if err := query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&certs).Error; err != nil { + r.logger.Error("获取认证记录列表失败", zap.Error(err)) + return nil, 0, fmt.Errorf("获取认证记录列表失败: %w", err) + } + + return certs, int(total), nil +} + +// GetByStatus 根据状态获取认证记录 +func (r *GormCertificationRepository) GetByStatus(ctx context.Context, status enums.CertificationStatus, page, pageSize int) ([]*entities.Certification, int, error) { + return r.List(ctx, page, pageSize, status) +} + +// GetPendingApprovals 获取待审核的认证申请 +func (r *GormCertificationRepository) GetPendingApprovals(ctx context.Context, page, pageSize int) ([]*entities.Certification, int, error) { + return r.GetByStatus(ctx, enums.StatusContractPending, page, pageSize) +} + +// GetWithEnterprise 获取包含企业信息的认证记录 +func (r *GormCertificationRepository) GetWithEnterprise(ctx context.Context, id string) (*entities.Certification, error) { + var cert entities.Certification + + if err := r.db.WithContext(ctx).Preload("Enterprise").First(&cert, "id = ?", id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("认证记录不存在") + } + r.logger.Error("获取认证记录(含企业信息)失败", + zap.String("id", id), + zap.Error(err), + ) + return nil, fmt.Errorf("获取认证记录失败: %w", err) + } + + return &cert, nil +} + +// GetWithAllRelations 获取包含所有关联关系的认证记录 +func (r *GormCertificationRepository) GetWithAllRelations(ctx context.Context, id string) (*entities.Certification, error) { + var cert entities.Certification + + if err := r.db.WithContext(ctx). + Preload("Enterprise"). + Preload("LicenseUploadRecord"). + Preload("FaceVerifyRecords"). + Preload("ContractRecords"). + Preload("NotificationRecords"). + First(&cert, "id = ?", id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("认证记录不存在") + } + r.logger.Error("获取认证记录(含所有关联)失败", + zap.String("id", id), + zap.Error(err), + ) + return nil, fmt.Errorf("获取认证记录失败: %w", err) + } + + return &cert, nil +} + +// CountByStatus 根据状态统计认证记录数量 +func (r *GormCertificationRepository) CountByStatus(ctx context.Context, status enums.CertificationStatus) (int64, error) { + var count int64 + + if err := r.db.WithContext(ctx).Model(&entities.Certification{}).Where("status = ?", status).Count(&count).Error; err != nil { + r.logger.Error("统计认证记录数量失败", + zap.String("status", string(status)), + zap.Error(err), + ) + return 0, fmt.Errorf("统计认证记录数量失败: %w", err) + } + + return count, nil +} + +// CountByUserID 根据用户ID统计认证记录数量 +func (r *GormCertificationRepository) CountByUserID(ctx context.Context, userID string) (int64, error) { + var count int64 + + if err := r.db.WithContext(ctx).Model(&entities.Certification{}).Where("user_id = ?", userID).Count(&count).Error; err != nil { + r.logger.Error("统计用户认证记录数量失败", + zap.String("user_id", userID), + zap.Error(err), + ) + return 0, fmt.Errorf("统计用户认证记录数量失败: %w", err) + } + + return count, nil +} diff --git a/internal/domains/certification/repositories/gorm_contract_record_repository.go b/internal/domains/certification/repositories/gorm_contract_record_repository.go new file mode 100644 index 0000000..5b5440a --- /dev/null +++ b/internal/domains/certification/repositories/gorm_contract_record_repository.go @@ -0,0 +1,175 @@ +package repositories + +import ( + "context" + "fmt" + + "go.uber.org/zap" + "gorm.io/gorm" + + "tyapi-server/internal/domains/certification/entities" +) + +// GormContractRecordRepository GORM合同记录仓储实现 +type GormContractRecordRepository struct { + db *gorm.DB + logger *zap.Logger +} + +// NewGormContractRecordRepository 创建GORM合同记录仓储 +func NewGormContractRecordRepository(db *gorm.DB, logger *zap.Logger) ContractRecordRepository { + return &GormContractRecordRepository{ + db: db, + logger: logger, + } +} + +// Create 创建合同记录 +func (r *GormContractRecordRepository) Create(ctx context.Context, record *entities.ContractRecord) error { + if err := r.db.WithContext(ctx).Create(record).Error; err != nil { + r.logger.Error("创建合同记录失败", + zap.String("certification_id", record.CertificationID), + zap.String("contract_type", record.ContractType), + zap.Error(err), + ) + return fmt.Errorf("创建合同记录失败: %w", err) + } + + r.logger.Info("合同记录创建成功", + zap.String("id", record.ID), + zap.String("contract_type", record.ContractType), + ) + + return nil +} + +// GetByID 根据ID获取合同记录 +func (r *GormContractRecordRepository) GetByID(ctx context.Context, id string) (*entities.ContractRecord, error) { + var record entities.ContractRecord + + if err := r.db.WithContext(ctx).First(&record, "id = ?", id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("合同记录不存在") + } + r.logger.Error("获取合同记录失败", + zap.String("id", id), + zap.Error(err), + ) + return nil, fmt.Errorf("获取合同记录失败: %w", err) + } + + return &record, nil +} + +// GetByCertificationID 根据认证申请ID获取合同记录列表 +func (r *GormContractRecordRepository) GetByCertificationID(ctx context.Context, certificationID string) ([]*entities.ContractRecord, error) { + var records []*entities.ContractRecord + + if err := r.db.WithContext(ctx).Where("certification_id = ?", certificationID).Order("created_at DESC").Find(&records).Error; err != nil { + r.logger.Error("根据认证申请ID获取合同记录失败", + zap.String("certification_id", certificationID), + zap.Error(err), + ) + return nil, fmt.Errorf("获取合同记录失败: %w", err) + } + + return records, nil +} + +// Update 更新合同记录 +func (r *GormContractRecordRepository) Update(ctx context.Context, record *entities.ContractRecord) error { + if err := r.db.WithContext(ctx).Save(record).Error; err != nil { + r.logger.Error("更新合同记录失败", + zap.String("id", record.ID), + zap.Error(err), + ) + return fmt.Errorf("更新合同记录失败: %w", err) + } + + return nil +} + +// Delete 删除合同记录 +func (r *GormContractRecordRepository) Delete(ctx context.Context, id string) error { + if err := r.db.WithContext(ctx).Delete(&entities.ContractRecord{}, "id = ?", id).Error; err != nil { + r.logger.Error("删除合同记录失败", + zap.String("id", id), + zap.Error(err), + ) + return fmt.Errorf("删除合同记录失败: %w", err) + } + + return nil +} + +// GetByUserID 根据用户ID获取合同记录列表 +func (r *GormContractRecordRepository) GetByUserID(ctx context.Context, userID string, page, pageSize int) ([]*entities.ContractRecord, int, error) { + var records []*entities.ContractRecord + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.ContractRecord{}).Where("user_id = ?", userID) + + // 获取总数 + if err := query.Count(&total).Error; err != nil { + r.logger.Error("获取用户合同记录总数失败", zap.Error(err)) + return nil, 0, fmt.Errorf("获取合同记录总数失败: %w", err) + } + + // 分页查询 + offset := (page - 1) * pageSize + if err := query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&records).Error; err != nil { + r.logger.Error("获取用户合同记录列表失败", zap.Error(err)) + return nil, 0, fmt.Errorf("获取合同记录列表失败: %w", err) + } + + return records, int(total), nil +} + +// GetByStatus 根据状态获取合同记录列表 +func (r *GormContractRecordRepository) GetByStatus(ctx context.Context, status string, page, pageSize int) ([]*entities.ContractRecord, int, error) { + var records []*entities.ContractRecord + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.ContractRecord{}).Where("status = ?", status) + + // 获取总数 + if err := query.Count(&total).Error; err != nil { + r.logger.Error("根据状态获取合同记录总数失败", zap.Error(err)) + return nil, 0, fmt.Errorf("获取合同记录总数失败: %w", err) + } + + // 分页查询 + offset := (page - 1) * pageSize + if err := query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&records).Error; err != nil { + r.logger.Error("根据状态获取合同记录列表失败", zap.Error(err)) + return nil, 0, fmt.Errorf("获取合同记录列表失败: %w", err) + } + + return records, int(total), nil +} + +// GetPendingContracts 获取待审核的合同记录 +func (r *GormContractRecordRepository) GetPendingContracts(ctx context.Context, page, pageSize int) ([]*entities.ContractRecord, int, error) { + return r.GetByStatus(ctx, "PENDING", page, pageSize) +} + +// GetExpiredSigningContracts 获取签署链接已过期的合同记录 +func (r *GormContractRecordRepository) GetExpiredSigningContracts(ctx context.Context, limit int) ([]*entities.ContractRecord, error) { + var records []*entities.ContractRecord + + if err := r.db.WithContext(ctx). + Where("expires_at < NOW() AND status = ?", "APPROVED"). + Limit(limit). + Order("expires_at ASC"). + Find(&records).Error; err != nil { + r.logger.Error("获取过期签署合同记录失败", zap.Error(err)) + return nil, fmt.Errorf("获取过期签署合同记录失败: %w", err) + } + + return records, nil +} + +// GetExpiredContracts 获取已过期的合同记录(通用方法) +func (r *GormContractRecordRepository) GetExpiredContracts(ctx context.Context, limit int) ([]*entities.ContractRecord, error) { + return r.GetExpiredSigningContracts(ctx, limit) +} diff --git a/internal/domains/certification/repositories/gorm_enterprise_repository.go b/internal/domains/certification/repositories/gorm_enterprise_repository.go new file mode 100644 index 0000000..70e66be --- /dev/null +++ b/internal/domains/certification/repositories/gorm_enterprise_repository.go @@ -0,0 +1,148 @@ +package repositories + +import ( + "context" + "fmt" + + "go.uber.org/zap" + "gorm.io/gorm" + + "tyapi-server/internal/domains/certification/entities" +) + +// GormEnterpriseRepository GORM企业信息仓储实现 +type GormEnterpriseRepository struct { + db *gorm.DB + logger *zap.Logger +} + +// NewGormEnterpriseRepository 创建GORM企业信息仓储 +func NewGormEnterpriseRepository(db *gorm.DB, logger *zap.Logger) EnterpriseRepository { + return &GormEnterpriseRepository{ + db: db, + logger: logger, + } +} + +// Create 创建企业信息 +func (r *GormEnterpriseRepository) Create(ctx context.Context, enterprise *entities.Enterprise) error { + if err := r.db.WithContext(ctx).Create(enterprise).Error; err != nil { + r.logger.Error("创建企业信息失败", + zap.String("certification_id", enterprise.CertificationID), + zap.String("company_name", enterprise.CompanyName), + zap.Error(err), + ) + return fmt.Errorf("创建企业信息失败: %w", err) + } + + r.logger.Info("企业信息创建成功", + zap.String("id", enterprise.ID), + zap.String("company_name", enterprise.CompanyName), + zap.String("unified_social_code", enterprise.UnifiedSocialCode), + ) + + return nil +} + +// GetByID 根据ID获取企业信息 +func (r *GormEnterpriseRepository) GetByID(ctx context.Context, id string) (*entities.Enterprise, error) { + var enterprise entities.Enterprise + + if err := r.db.WithContext(ctx).First(&enterprise, "id = ?", id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("企业信息不存在") + } + r.logger.Error("获取企业信息失败", + zap.String("id", id), + zap.Error(err), + ) + return nil, fmt.Errorf("获取企业信息失败: %w", err) + } + + return &enterprise, nil +} + +// GetByCertificationID 根据认证ID获取企业信息 +func (r *GormEnterpriseRepository) GetByCertificationID(ctx context.Context, certificationID string) (*entities.Enterprise, error) { + var enterprise entities.Enterprise + + if err := r.db.WithContext(ctx).First(&enterprise, "certification_id = ?", certificationID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("企业信息不存在") + } + r.logger.Error("根据认证ID获取企业信息失败", + zap.String("certification_id", certificationID), + zap.Error(err), + ) + return nil, fmt.Errorf("获取企业信息失败: %w", err) + } + + return &enterprise, nil +} + +// Update 更新企业信息 +func (r *GormEnterpriseRepository) Update(ctx context.Context, enterprise *entities.Enterprise) error { + if err := r.db.WithContext(ctx).Save(enterprise).Error; err != nil { + r.logger.Error("更新企业信息失败", + zap.String("id", enterprise.ID), + zap.String("company_name", enterprise.CompanyName), + zap.Error(err), + ) + return fmt.Errorf("更新企业信息失败: %w", err) + } + + r.logger.Info("企业信息更新成功", + zap.String("id", enterprise.ID), + zap.String("company_name", enterprise.CompanyName), + ) + + return nil +} + +// Delete 删除企业信息(软删除) +func (r *GormEnterpriseRepository) Delete(ctx context.Context, id string) error { + if err := r.db.WithContext(ctx).Delete(&entities.Enterprise{}, "id = ?", id).Error; err != nil { + r.logger.Error("删除企业信息失败", + zap.String("id", id), + zap.Error(err), + ) + return fmt.Errorf("删除企业信息失败: %w", err) + } + + r.logger.Info("企业信息删除成功", zap.String("id", id)) + return nil +} + +// GetByUnifiedSocialCode 根据统一社会信用代码获取企业信息 +func (r *GormEnterpriseRepository) GetByUnifiedSocialCode(ctx context.Context, code string) (*entities.Enterprise, error) { + var enterprise entities.Enterprise + + if err := r.db.WithContext(ctx).First(&enterprise, "unified_social_code = ?", code).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("企业信息不存在") + } + r.logger.Error("根据统一社会信用代码获取企业信息失败", + zap.String("unified_social_code", code), + zap.Error(err), + ) + return nil, fmt.Errorf("获取企业信息失败: %w", err) + } + + return &enterprise, nil +} + +// ExistsByUnifiedSocialCode 检查统一社会信用代码是否已存在 +func (r *GormEnterpriseRepository) ExistsByUnifiedSocialCode(ctx context.Context, code string) (bool, error) { + var count int64 + + if err := r.db.WithContext(ctx).Model(&entities.Enterprise{}). + Where("unified_social_code = ?", code).Count(&count).Error; err != nil { + r.logger.Error("检查统一社会信用代码是否存在失败", + zap.String("unified_social_code", code), + zap.Error(err), + ) + return false, fmt.Errorf("检查统一社会信用代码失败: %w", err) + } + + return count > 0, nil +} diff --git a/internal/domains/certification/repositories/gorm_face_verify_record_repository.go b/internal/domains/certification/repositories/gorm_face_verify_record_repository.go new file mode 100644 index 0000000..08f7627 --- /dev/null +++ b/internal/domains/certification/repositories/gorm_face_verify_record_repository.go @@ -0,0 +1,160 @@ +package repositories + +import ( + "context" + "fmt" + + "go.uber.org/zap" + "gorm.io/gorm" + + "tyapi-server/internal/domains/certification/entities" +) + +// GormFaceVerifyRecordRepository GORM人脸识别记录仓储实现 +type GormFaceVerifyRecordRepository struct { + db *gorm.DB + logger *zap.Logger +} + +// NewGormFaceVerifyRecordRepository 创建GORM人脸识别记录仓储 +func NewGormFaceVerifyRecordRepository(db *gorm.DB, logger *zap.Logger) FaceVerifyRecordRepository { + return &GormFaceVerifyRecordRepository{ + db: db, + logger: logger, + } +} + +// Create 创建人脸识别记录 +func (r *GormFaceVerifyRecordRepository) Create(ctx context.Context, record *entities.FaceVerifyRecord) error { + if err := r.db.WithContext(ctx).Create(record).Error; err != nil { + r.logger.Error("创建人脸识别记录失败", + zap.String("certification_id", record.CertificationID), + zap.String("certify_id", record.CertifyID), + zap.Error(err), + ) + return fmt.Errorf("创建人脸识别记录失败: %w", err) + } + + r.logger.Info("人脸识别记录创建成功", + zap.String("id", record.ID), + zap.String("certify_id", record.CertifyID), + ) + + return nil +} + +// GetByID 根据ID获取人脸识别记录 +func (r *GormFaceVerifyRecordRepository) GetByID(ctx context.Context, id string) (*entities.FaceVerifyRecord, error) { + var record entities.FaceVerifyRecord + + if err := r.db.WithContext(ctx).First(&record, "id = ?", id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("人脸识别记录不存在") + } + r.logger.Error("获取人脸识别记录失败", + zap.String("id", id), + zap.Error(err), + ) + return nil, fmt.Errorf("获取人脸识别记录失败: %w", err) + } + + return &record, nil +} + +// GetByCertifyID 根据认证ID获取人脸识别记录 +func (r *GormFaceVerifyRecordRepository) GetByCertifyID(ctx context.Context, certifyID string) (*entities.FaceVerifyRecord, error) { + var record entities.FaceVerifyRecord + + if err := r.db.WithContext(ctx).First(&record, "certify_id = ?", certifyID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("人脸识别记录不存在") + } + r.logger.Error("根据认证ID获取人脸识别记录失败", + zap.String("certify_id", certifyID), + zap.Error(err), + ) + return nil, fmt.Errorf("获取人脸识别记录失败: %w", err) + } + + return &record, nil +} + +// GetByCertificationID 根据认证申请ID获取人脸识别记录列表 +func (r *GormFaceVerifyRecordRepository) GetByCertificationID(ctx context.Context, certificationID string) ([]*entities.FaceVerifyRecord, error) { + var records []*entities.FaceVerifyRecord + + if err := r.db.WithContext(ctx).Where("certification_id = ?", certificationID).Order("created_at DESC").Find(&records).Error; err != nil { + r.logger.Error("根据认证申请ID获取人脸识别记录失败", + zap.String("certification_id", certificationID), + zap.Error(err), + ) + return nil, fmt.Errorf("获取人脸识别记录失败: %w", err) + } + + return records, nil +} + +// Update 更新人脸识别记录 +func (r *GormFaceVerifyRecordRepository) Update(ctx context.Context, record *entities.FaceVerifyRecord) error { + if err := r.db.WithContext(ctx).Save(record).Error; err != nil { + r.logger.Error("更新人脸识别记录失败", + zap.String("id", record.ID), + zap.Error(err), + ) + return fmt.Errorf("更新人脸识别记录失败: %w", err) + } + + return nil +} + +// Delete 删除人脸识别记录 +func (r *GormFaceVerifyRecordRepository) Delete(ctx context.Context, id string) error { + if err := r.db.WithContext(ctx).Delete(&entities.FaceVerifyRecord{}, "id = ?", id).Error; err != nil { + r.logger.Error("删除人脸识别记录失败", + zap.String("id", id), + zap.Error(err), + ) + return fmt.Errorf("删除人脸识别记录失败: %w", err) + } + + return nil +} + +// GetByUserID 根据用户ID获取人脸识别记录列表 +func (r *GormFaceVerifyRecordRepository) GetByUserID(ctx context.Context, userID string, page, pageSize int) ([]*entities.FaceVerifyRecord, int, error) { + var records []*entities.FaceVerifyRecord + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.FaceVerifyRecord{}).Where("user_id = ?", userID) + + // 获取总数 + if err := query.Count(&total).Error; err != nil { + r.logger.Error("获取用户人脸识别记录总数失败", zap.Error(err)) + return nil, 0, fmt.Errorf("获取人脸识别记录总数失败: %w", err) + } + + // 分页查询 + offset := (page - 1) * pageSize + if err := query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&records).Error; err != nil { + r.logger.Error("获取用户人脸识别记录列表失败", zap.Error(err)) + return nil, 0, fmt.Errorf("获取人脸识别记录列表失败: %w", err) + } + + return records, int(total), nil +} + +// GetExpiredRecords 获取已过期的人脸识别记录 +func (r *GormFaceVerifyRecordRepository) GetExpiredRecords(ctx context.Context, limit int) ([]*entities.FaceVerifyRecord, error) { + var records []*entities.FaceVerifyRecord + + if err := r.db.WithContext(ctx). + Where("expires_at < NOW() AND status = ?", "PROCESSING"). + Limit(limit). + Order("expires_at ASC"). + Find(&records).Error; err != nil { + r.logger.Error("获取过期人脸识别记录失败", zap.Error(err)) + return nil, fmt.Errorf("获取过期人脸识别记录失败: %w", err) + } + + return records, nil +} diff --git a/internal/domains/certification/repositories/gorm_license_upload_record_repository.go b/internal/domains/certification/repositories/gorm_license_upload_record_repository.go new file mode 100644 index 0000000..5b0bebd --- /dev/null +++ b/internal/domains/certification/repositories/gorm_license_upload_record_repository.go @@ -0,0 +1,163 @@ +package repositories + +import ( + "context" + "fmt" + + "go.uber.org/zap" + "gorm.io/gorm" + + "tyapi-server/internal/domains/certification/entities" +) + +// GormLicenseUploadRecordRepository GORM营业执照上传记录仓储实现 +type GormLicenseUploadRecordRepository struct { + db *gorm.DB + logger *zap.Logger +} + +// NewGormLicenseUploadRecordRepository 创建GORM营业执照上传记录仓储 +func NewGormLicenseUploadRecordRepository(db *gorm.DB, logger *zap.Logger) LicenseUploadRecordRepository { + return &GormLicenseUploadRecordRepository{ + db: db, + logger: logger, + } +} + +// Create 创建上传记录 +func (r *GormLicenseUploadRecordRepository) Create(ctx context.Context, record *entities.LicenseUploadRecord) error { + if err := r.db.WithContext(ctx).Create(record).Error; err != nil { + r.logger.Error("创建上传记录失败", + zap.String("user_id", record.UserID), + zap.String("file_name", record.OriginalFileName), + zap.Error(err), + ) + return fmt.Errorf("创建上传记录失败: %w", err) + } + + r.logger.Info("上传记录创建成功", + zap.String("id", record.ID), + zap.String("file_name", record.OriginalFileName), + ) + + return nil +} + +// GetByID 根据ID获取上传记录 +func (r *GormLicenseUploadRecordRepository) GetByID(ctx context.Context, id string) (*entities.LicenseUploadRecord, error) { + var record entities.LicenseUploadRecord + + if err := r.db.WithContext(ctx).First(&record, "id = ?", id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("上传记录不存在") + } + r.logger.Error("获取上传记录失败", + zap.String("id", id), + zap.Error(err), + ) + return nil, fmt.Errorf("获取上传记录失败: %w", err) + } + + return &record, nil +} + +// GetByUserID 根据用户ID获取上传记录列表 +func (r *GormLicenseUploadRecordRepository) GetByUserID(ctx context.Context, userID string, page, pageSize int) ([]*entities.LicenseUploadRecord, int, error) { + var records []*entities.LicenseUploadRecord + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.LicenseUploadRecord{}).Where("user_id = ?", userID) + + // 获取总数 + if err := query.Count(&total).Error; err != nil { + r.logger.Error("获取用户上传记录总数失败", zap.Error(err)) + return nil, 0, fmt.Errorf("获取上传记录总数失败: %w", err) + } + + // 分页查询 + offset := (page - 1) * pageSize + if err := query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&records).Error; err != nil { + r.logger.Error("获取用户上传记录列表失败", zap.Error(err)) + return nil, 0, fmt.Errorf("获取上传记录列表失败: %w", err) + } + + return records, int(total), nil +} + +// GetByCertificationID 根据认证ID获取上传记录 +func (r *GormLicenseUploadRecordRepository) GetByCertificationID(ctx context.Context, certificationID string) (*entities.LicenseUploadRecord, error) { + var record entities.LicenseUploadRecord + + if err := r.db.WithContext(ctx).First(&record, "certification_id = ?", certificationID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("上传记录不存在") + } + r.logger.Error("根据认证ID获取上传记录失败", + zap.String("certification_id", certificationID), + zap.Error(err), + ) + return nil, fmt.Errorf("获取上传记录失败: %w", err) + } + + return &record, nil +} + +// Update 更新上传记录 +func (r *GormLicenseUploadRecordRepository) Update(ctx context.Context, record *entities.LicenseUploadRecord) error { + if err := r.db.WithContext(ctx).Save(record).Error; err != nil { + r.logger.Error("更新上传记录失败", + zap.String("id", record.ID), + zap.Error(err), + ) + return fmt.Errorf("更新上传记录失败: %w", err) + } + + return nil +} + +// Delete 删除上传记录 +func (r *GormLicenseUploadRecordRepository) Delete(ctx context.Context, id string) error { + if err := r.db.WithContext(ctx).Delete(&entities.LicenseUploadRecord{}, "id = ?", id).Error; err != nil { + r.logger.Error("删除上传记录失败", + zap.String("id", id), + zap.Error(err), + ) + return fmt.Errorf("删除上传记录失败: %w", err) + } + + return nil +} + +// GetByQiNiuKey 根据七牛云Key获取上传记录 +func (r *GormLicenseUploadRecordRepository) GetByQiNiuKey(ctx context.Context, key string) (*entities.LicenseUploadRecord, error) { + var record entities.LicenseUploadRecord + + if err := r.db.WithContext(ctx).First(&record, "qiniu_key = ?", key).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("上传记录不存在") + } + r.logger.Error("根据七牛云Key获取上传记录失败", + zap.String("qiniu_key", key), + zap.Error(err), + ) + return nil, fmt.Errorf("获取上传记录失败: %w", err) + } + + return &record, nil +} + +// GetPendingOCR 获取待OCR处理的上传记录 +func (r *GormLicenseUploadRecordRepository) GetPendingOCR(ctx context.Context, limit int) ([]*entities.LicenseUploadRecord, error) { + var records []*entities.LicenseUploadRecord + + if err := r.db.WithContext(ctx). + Where("ocr_processed = ? OR (ocr_processed = ? AND ocr_success = ?)", false, true, false). + Limit(limit). + Order("created_at ASC"). + Find(&records).Error; err != nil { + r.logger.Error("获取待OCR处理记录失败", zap.Error(err)) + return nil, fmt.Errorf("获取待OCR处理记录失败: %w", err) + } + + return records, nil +} diff --git a/internal/domains/certification/repositories/impl.go b/internal/domains/certification/repositories/impl.go new file mode 100644 index 0000000..e45eaf5 --- /dev/null +++ b/internal/domains/certification/repositories/impl.go @@ -0,0 +1,105 @@ +package repositories + +import ( + "context" + + "tyapi-server/internal/domains/certification/entities" + "tyapi-server/internal/domains/certification/enums" +) + +// CertificationRepository 认证仓储接口 +type CertificationRepository interface { + // 基础CRUD操作 + Create(ctx context.Context, cert *entities.Certification) error + GetByID(ctx context.Context, id string) (*entities.Certification, error) + GetByUserID(ctx context.Context, userID string) (*entities.Certification, error) + Update(ctx context.Context, cert *entities.Certification) error + Delete(ctx context.Context, id string) error + + // 查询操作 + List(ctx context.Context, page, pageSize int, status enums.CertificationStatus) ([]*entities.Certification, int, error) + GetByStatus(ctx context.Context, status enums.CertificationStatus, page, pageSize int) ([]*entities.Certification, int, error) + GetPendingApprovals(ctx context.Context, page, pageSize int) ([]*entities.Certification, int, error) + + // 关联查询 + GetWithEnterprise(ctx context.Context, id string) (*entities.Certification, error) + GetWithAllRelations(ctx context.Context, id string) (*entities.Certification, error) + + // 统计操作 + CountByStatus(ctx context.Context, status enums.CertificationStatus) (int64, error) + CountByUserID(ctx context.Context, userID string) (int64, error) +} + +// EnterpriseRepository 企业信息仓储接口 +type EnterpriseRepository interface { + // 基础CRUD操作 + Create(ctx context.Context, enterprise *entities.Enterprise) error + GetByID(ctx context.Context, id string) (*entities.Enterprise, error) + GetByCertificationID(ctx context.Context, certificationID string) (*entities.Enterprise, error) + Update(ctx context.Context, enterprise *entities.Enterprise) error + Delete(ctx context.Context, id string) error + + // 查询操作 + GetByUnifiedSocialCode(ctx context.Context, code string) (*entities.Enterprise, error) + ExistsByUnifiedSocialCode(ctx context.Context, code string) (bool, error) +} + +// LicenseUploadRecordRepository 营业执照上传记录仓储接口 +type LicenseUploadRecordRepository interface { + // 基础CRUD操作 + Create(ctx context.Context, record *entities.LicenseUploadRecord) error + GetByID(ctx context.Context, id string) (*entities.LicenseUploadRecord, error) + GetByUserID(ctx context.Context, userID string, page, pageSize int) ([]*entities.LicenseUploadRecord, int, error) + GetByCertificationID(ctx context.Context, certificationID string) (*entities.LicenseUploadRecord, error) + Update(ctx context.Context, record *entities.LicenseUploadRecord) error + Delete(ctx context.Context, id string) error + + // 查询操作 + GetByQiNiuKey(ctx context.Context, key string) (*entities.LicenseUploadRecord, error) + GetPendingOCR(ctx context.Context, limit int) ([]*entities.LicenseUploadRecord, error) +} + +// FaceVerifyRecordRepository 人脸识别记录仓储接口 +type FaceVerifyRecordRepository interface { + // 基础CRUD操作 + Create(ctx context.Context, record *entities.FaceVerifyRecord) error + GetByID(ctx context.Context, id string) (*entities.FaceVerifyRecord, error) + GetByCertifyID(ctx context.Context, certifyID string) (*entities.FaceVerifyRecord, error) + GetByCertificationID(ctx context.Context, certificationID string) ([]*entities.FaceVerifyRecord, error) + Update(ctx context.Context, record *entities.FaceVerifyRecord) error + Delete(ctx context.Context, id string) error + + // 查询操作 + GetByUserID(ctx context.Context, userID string, page, pageSize int) ([]*entities.FaceVerifyRecord, int, error) + GetExpiredRecords(ctx context.Context, limit int) ([]*entities.FaceVerifyRecord, error) +} + +// ContractRecordRepository 合同记录仓储接口 +type ContractRecordRepository interface { + // 基础CRUD操作 + Create(ctx context.Context, record *entities.ContractRecord) error + GetByID(ctx context.Context, id string) (*entities.ContractRecord, error) + GetByCertificationID(ctx context.Context, certificationID string) ([]*entities.ContractRecord, error) + Update(ctx context.Context, record *entities.ContractRecord) error + Delete(ctx context.Context, id string) error + + // 查询操作 + GetByUserID(ctx context.Context, userID string, page, pageSize int) ([]*entities.ContractRecord, int, error) + GetByStatus(ctx context.Context, status string, page, pageSize int) ([]*entities.ContractRecord, int, error) + GetExpiredContracts(ctx context.Context, limit int) ([]*entities.ContractRecord, error) +} + +// NotificationRecordRepository 通知记录仓储接口 +type NotificationRecordRepository interface { + // 基础CRUD操作 + Create(ctx context.Context, record *entities.NotificationRecord) error + GetByID(ctx context.Context, id string) (*entities.NotificationRecord, error) + GetByCertificationID(ctx context.Context, certificationID string) ([]*entities.NotificationRecord, error) + Update(ctx context.Context, record *entities.NotificationRecord) error + Delete(ctx context.Context, id string) error + + // 查询操作 + GetByUserID(ctx context.Context, userID string, page, pageSize int) ([]*entities.NotificationRecord, int, error) + GetPendingNotifications(ctx context.Context, limit int) ([]*entities.NotificationRecord, error) + GetFailedNotifications(ctx context.Context, limit int) ([]*entities.NotificationRecord, error) +} diff --git a/internal/domains/certification/routes/certification_routes.go b/internal/domains/certification/routes/certification_routes.go new file mode 100644 index 0000000..a22d92a --- /dev/null +++ b/internal/domains/certification/routes/certification_routes.go @@ -0,0 +1,62 @@ +package routes + +import ( + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "tyapi-server/internal/domains/certification/handlers" + "tyapi-server/internal/shared/middleware" +) + +// CertificationRoutes 认证路由组 +type CertificationRoutes struct { + certificationHandler *handlers.CertificationHandler + authMiddleware *middleware.JWTAuthMiddleware + logger *zap.Logger +} + +// NewCertificationRoutes 创建认证路由 +func NewCertificationRoutes( + certificationHandler *handlers.CertificationHandler, + authMiddleware *middleware.JWTAuthMiddleware, + logger *zap.Logger, +) *CertificationRoutes { + return &CertificationRoutes{ + certificationHandler: certificationHandler, + authMiddleware: authMiddleware, + logger: logger, + } +} + +// RegisterRoutes 注册认证相关路由 +func (r *CertificationRoutes) RegisterRoutes(router *gin.Engine) { + // 认证相关路由组,需要用户认证 + certificationGroup := router.Group("/api/v1/certification") + certificationGroup.Use(r.authMiddleware.Handle()) + { + // 创建认证申请 + certificationGroup.POST("/create", r.certificationHandler.CreateCertification) + + // 上传营业执照 + certificationGroup.POST("/upload-license", r.certificationHandler.UploadLicense) + + // 获取认证状态 + certificationGroup.GET("/status", r.certificationHandler.GetCertificationStatus) + + // 获取进度统计 + certificationGroup.GET("/progress", r.certificationHandler.GetProgressStats) + + // 提交企业信息 + certificationGroup.PUT("/:id/submit-info", r.certificationHandler.SubmitEnterpriseInfo) + // 发起人脸识别验证 + certificationGroup.POST("/:id/face-verify", r.certificationHandler.InitiateFaceVerify) + // 申请合同签署 + certificationGroup.POST("/:id/apply-contract", r.certificationHandler.ApplyContract) + // 获取认证详情 + certificationGroup.GET("/:id", r.certificationHandler.GetCertificationDetails) + // 重试认证步骤 + certificationGroup.POST("/:id/retry", r.certificationHandler.RetryStep) + } + + r.logger.Info("认证路由注册完成") +} diff --git a/internal/domains/certification/services/certification_service.go b/internal/domains/certification/services/certification_service.go new file mode 100644 index 0000000..fbfa8c1 --- /dev/null +++ b/internal/domains/certification/services/certification_service.go @@ -0,0 +1,404 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + "tyapi-server/internal/domains/certification/dto" + "tyapi-server/internal/domains/certification/entities" + "tyapi-server/internal/domains/certification/enums" + "tyapi-server/internal/domains/certification/repositories" + "tyapi-server/internal/shared/ocr" + "tyapi-server/internal/shared/storage" +) + +// CertificationService 认证服务 +type CertificationService struct { + certRepo repositories.CertificationRepository + enterpriseRepo repositories.EnterpriseRepository + licenseRepo repositories.LicenseUploadRecordRepository + faceVerifyRepo repositories.FaceVerifyRecordRepository + contractRepo repositories.ContractRecordRepository + stateMachine *CertificationStateMachine + storageService storage.StorageService + ocrService ocr.OCRService + logger *zap.Logger +} + +// NewCertificationService 创建认证服务 +func NewCertificationService( + certRepo repositories.CertificationRepository, + enterpriseRepo repositories.EnterpriseRepository, + licenseRepo repositories.LicenseUploadRecordRepository, + faceVerifyRepo repositories.FaceVerifyRecordRepository, + contractRepo repositories.ContractRecordRepository, + stateMachine *CertificationStateMachine, + storageService storage.StorageService, + ocrService ocr.OCRService, + logger *zap.Logger, +) *CertificationService { + return &CertificationService{ + certRepo: certRepo, + enterpriseRepo: enterpriseRepo, + licenseRepo: licenseRepo, + faceVerifyRepo: faceVerifyRepo, + contractRepo: contractRepo, + stateMachine: stateMachine, + storageService: storageService, + ocrService: ocrService, + logger: logger, + } +} + +// CreateCertification 创建认证申请 +func (s *CertificationService) CreateCertification(ctx context.Context, userID string) (*dto.CertificationCreateResponse, error) { + s.logger.Info("创建认证申请", zap.String("user_id", userID)) + + // 检查用户是否已有认证申请 + existingCert, err := s.certRepo.GetByUserID(ctx, userID) + if err == nil && existingCert != nil { + // 如果已存在且不是最终状态,返回现有申请 + if !enums.IsFinalStatus(existingCert.Status) { + return &dto.CertificationCreateResponse{ + ID: existingCert.ID, + UserID: existingCert.UserID, + Status: existingCert.Status, + }, nil + } + } + + // 创建新的认证申请 + certification := &entities.Certification{ + ID: uuid.New().String(), + UserID: userID, + Status: enums.StatusPending, + } + + if err := s.certRepo.Create(ctx, certification); err != nil { + return nil, fmt.Errorf("创建认证申请失败: %w", err) + } + + s.logger.Info("认证申请创建成功", + zap.String("certification_id", certification.ID), + zap.String("user_id", userID), + ) + + return &dto.CertificationCreateResponse{ + ID: certification.ID, + UserID: certification.UserID, + Status: certification.Status, + }, nil +} + +// UploadLicense 上传营业执照并进行OCR识别 +func (s *CertificationService) UploadLicense(ctx context.Context, userID string, fileBytes []byte, fileName string) (*dto.UploadLicenseResponse, error) { + s.logger.Info("上传营业执照", + zap.String("user_id", userID), + zap.String("file_name", fileName), + zap.Int("file_size", len(fileBytes)), + ) + + // 1. 上传文件到存储服务 + uploadResult, err := s.storageService.UploadFile(ctx, fileBytes, fileName) + if err != nil { + s.logger.Error("文件上传失败", zap.Error(err)) + return nil, fmt.Errorf("文件上传失败: %w", err) + } + + // 2. 创建上传记录 + uploadRecord := &entities.LicenseUploadRecord{ + ID: uuid.New().String(), + UserID: userID, + OriginalFileName: fileName, + FileSize: int64(len(fileBytes)), + FileType: uploadResult.MimeType, + FileURL: uploadResult.URL, + QiNiuKey: uploadResult.Key, + OCRProcessed: false, + OCRSuccess: false, + } + + if err := s.licenseRepo.Create(ctx, uploadRecord); err != nil { + s.logger.Error("创建上传记录失败", zap.Error(err)) + return nil, fmt.Errorf("创建上传记录失败: %w", err) + } + + // 3. 尝试OCR识别 + var enterpriseInfo *dto.OCREnterpriseInfo + var ocrError string + + ocrResult, err := s.ocrService.RecognizeBusinessLicense(ctx, uploadResult.URL) + if err != nil { + s.logger.Warn("OCR识别失败", zap.Error(err)) + ocrError = err.Error() + uploadRecord.OCRProcessed = true + uploadRecord.OCRSuccess = false + uploadRecord.OCRErrorMessage = ocrError + } else { + s.logger.Info("OCR识别成功", + zap.String("company_name", ocrResult.CompanyName), + zap.Float64("confidence", ocrResult.Confidence), + ) + enterpriseInfo = ocrResult + uploadRecord.OCRProcessed = true + uploadRecord.OCRSuccess = true + uploadRecord.OCRConfidence = ocrResult.Confidence + // 存储OCR原始数据 + if rawData, err := s.serializeOCRResult(ocrResult); err == nil { + uploadRecord.OCRRawData = rawData + } + } + + // 更新上传记录 + if err := s.licenseRepo.Update(ctx, uploadRecord); err != nil { + s.logger.Warn("更新上传记录失败", zap.Error(err)) + } + + return &dto.UploadLicenseResponse{ + UploadRecordID: uploadRecord.ID, + FileURL: uploadResult.URL, + OCRProcessed: uploadRecord.OCRProcessed, + OCRSuccess: uploadRecord.OCRSuccess, + EnterpriseInfo: enterpriseInfo, + OCRErrorMessage: ocrError, + }, nil +} + +// SubmitEnterpriseInfo 提交企业信息 +func (s *CertificationService) SubmitEnterpriseInfo(ctx context.Context, certificationID string, req *dto.SubmitEnterpriseInfoRequest) (*dto.SubmitEnterpriseInfoResponse, error) { + s.logger.Info("提交企业信息", + zap.String("certification_id", certificationID), + zap.String("company_name", req.CompanyName), + ) + + // 1. 获取认证记录 + cert, err := s.certRepo.GetByID(ctx, certificationID) + if err != nil { + return nil, fmt.Errorf("获取认证记录失败: %w", err) + } + + // 2. 检查状态是否允许提交企业信息 + if !cert.CanTransitionTo(enums.StatusInfoSubmitted) { + return nil, fmt.Errorf("当前状态不允许提交企业信息,当前状态: %s", enums.GetStatusName(cert.Status)) + } + + // 3. 检查统一社会信用代码是否已存在 + exists, err := s.enterpriseRepo.ExistsByUnifiedSocialCode(ctx, req.UnifiedSocialCode) + if err != nil { + return nil, fmt.Errorf("检查企业信息失败: %w", err) + } + if exists { + return nil, fmt.Errorf("该统一社会信用代码已被使用") + } + + // 4. 创建企业信息 + enterprise := &entities.Enterprise{ + ID: uuid.New().String(), + CertificationID: certificationID, + CompanyName: req.CompanyName, + UnifiedSocialCode: req.UnifiedSocialCode, + LegalPersonName: req.LegalPersonName, + LegalPersonID: req.LegalPersonID, + LicenseUploadRecordID: req.LicenseUploadRecordID, + IsOCRVerified: false, + IsFaceVerified: false, + } + + if err := s.enterpriseRepo.Create(ctx, enterprise); err != nil { + return nil, fmt.Errorf("创建企业信息失败: %w", err) + } + + // 5. 更新认证记录状态 + cert.EnterpriseID = &enterprise.ID + if err := s.stateMachine.TransitionTo(ctx, certificationID, enums.StatusInfoSubmitted, true, false, nil); err != nil { + return nil, fmt.Errorf("更新认证状态失败: %w", err) + } + + s.logger.Info("企业信息提交成功", + zap.String("certification_id", certificationID), + zap.String("enterprise_id", enterprise.ID), + ) + + return &dto.SubmitEnterpriseInfoResponse{ + ID: certificationID, + Status: enums.StatusInfoSubmitted, + Enterprise: &dto.EnterpriseInfoResponse{ + ID: enterprise.ID, + CertificationID: enterprise.CertificationID, + CompanyName: enterprise.CompanyName, + UnifiedSocialCode: enterprise.UnifiedSocialCode, + LegalPersonName: enterprise.LegalPersonName, + LegalPersonID: enterprise.LegalPersonID, + LicenseUploadRecordID: enterprise.LicenseUploadRecordID, + IsOCRVerified: enterprise.IsOCRVerified, + IsFaceVerified: enterprise.IsFaceVerified, + CreatedAt: enterprise.CreatedAt, + UpdatedAt: enterprise.UpdatedAt, + }, + }, nil +} + +// GetCertificationStatus 获取认证状态 +func (s *CertificationService) GetCertificationStatus(ctx context.Context, userID string) (*dto.CertificationStatusResponse, error) { + s.logger.Info("获取认证状态", zap.String("user_id", userID)) + + // 获取用户的认证记录 + cert, err := s.certRepo.GetByUserID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("获取认证记录失败: %w", err) + } + + // 获取企业信息 + var enterprise *dto.EnterpriseInfoResponse + if cert.EnterpriseID != nil { + ent, err := s.enterpriseRepo.GetByID(ctx, *cert.EnterpriseID) + if err == nil { + enterprise = &dto.EnterpriseInfoResponse{ + ID: ent.ID, + CertificationID: ent.CertificationID, + CompanyName: ent.CompanyName, + UnifiedSocialCode: ent.UnifiedSocialCode, + LegalPersonName: ent.LegalPersonName, + LegalPersonID: ent.LegalPersonID, + LicenseUploadRecordID: ent.LicenseUploadRecordID, + IsOCRVerified: ent.IsOCRVerified, + IsFaceVerified: ent.IsFaceVerified, + CreatedAt: ent.CreatedAt, + UpdatedAt: ent.UpdatedAt, + } + } + } + + return &dto.CertificationStatusResponse{ + ID: cert.ID, + UserID: cert.UserID, + Status: cert.Status, + StatusName: enums.GetStatusName(cert.Status), + Progress: cert.GetProgressPercentage(), + IsUserActionRequired: cert.IsUserActionRequired(), + IsAdminActionRequired: cert.IsAdminActionRequired(), + InfoSubmittedAt: cert.InfoSubmittedAt, + FaceVerifiedAt: cert.FaceVerifiedAt, + ContractAppliedAt: cert.ContractAppliedAt, + ContractApprovedAt: cert.ContractApprovedAt, + ContractSignedAt: cert.ContractSignedAt, + CompletedAt: cert.CompletedAt, + Enterprise: enterprise, + ContractURL: cert.ContractURL, + SigningURL: cert.SigningURL, + RejectReason: cert.RejectReason, + CreatedAt: cert.CreatedAt, + UpdatedAt: cert.UpdatedAt, + }, nil +} + +// InitiateFaceVerify 初始化人脸识别 +func (s *CertificationService) InitiateFaceVerify(ctx context.Context, certificationID string, req *dto.FaceVerifyRequest) (*dto.FaceVerifyResponse, error) { + s.logger.Info("初始化人脸识别", + zap.String("certification_id", certificationID), + zap.String("real_name", req.RealName), + ) + + // 1. 获取认证记录 + cert, err := s.certRepo.GetByID(ctx, certificationID) + if err != nil { + return nil, fmt.Errorf("获取认证记录失败: %w", err) + } + + // 2. 检查状态 + if cert.Status != enums.StatusInfoSubmitted && cert.Status != enums.StatusFaceFailed { + return nil, fmt.Errorf("当前状态不允许进行人脸识别,当前状态: %s", enums.GetStatusName(cert.Status)) + } + + // 3. 创建人脸识别记录 + verifyRecord := &entities.FaceVerifyRecord{ + ID: uuid.New().String(), + CertificationID: certificationID, + UserID: cert.UserID, + CertifyID: fmt.Sprintf("cert_%s_%d", certificationID, time.Now().Unix()), + RealName: req.RealName, + IDCardNumber: req.IDCardNumber, + ReturnURL: req.ReturnURL, + Status: "PROCESSING", + ExpiresAt: time.Now().Add(24 * time.Hour), // 24小时过期 + } + + // TODO: 实际调用阿里云人脸识别API + // 这里是模拟实现 + verifyRecord.VerifyURL = fmt.Sprintf("https://face-verify.aliyun.com/verify?certifyId=%s", verifyRecord.CertifyID) + + if err := s.faceVerifyRepo.Create(ctx, verifyRecord); err != nil { + return nil, fmt.Errorf("创建人脸识别记录失败: %w", err) + } + + s.logger.Info("人脸识别初始化成功", + zap.String("certification_id", certificationID), + zap.String("certify_id", verifyRecord.CertifyID), + ) + + return &dto.FaceVerifyResponse{ + CertifyID: verifyRecord.CertifyID, + VerifyURL: verifyRecord.VerifyURL, + ExpiresAt: verifyRecord.ExpiresAt, + }, nil +} + +// ApplyContract 申请电子合同 +func (s *CertificationService) ApplyContract(ctx context.Context, certificationID string) (*dto.ApplyContractResponse, error) { + s.logger.Info("申请电子合同", zap.String("certification_id", certificationID)) + + // 1. 获取认证记录 + cert, err := s.certRepo.GetByID(ctx, certificationID) + if err != nil { + return nil, fmt.Errorf("获取认证记录失败: %w", err) + } + + // 2. 检查状态 + if !cert.CanTransitionTo(enums.StatusContractApplied) { + return nil, fmt.Errorf("当前状态不允许申请合同,当前状态: %s", enums.GetStatusName(cert.Status)) + } + + // 3. 转换状态 + if err := s.stateMachine.TransitionTo(ctx, certificationID, enums.StatusContractApplied, true, false, nil); err != nil { + return nil, fmt.Errorf("更新认证状态失败: %w", err) + } + + // 4. 自动转换到待审核状态 + if err := s.stateMachine.TransitionTo(ctx, certificationID, enums.StatusContractPending, false, false, nil); err != nil { + s.logger.Warn("自动转换到待审核状态失败", zap.Error(err)) + } + + // 5. 创建合同记录 + contractRecord := &entities.ContractRecord{ + ID: uuid.New().String(), + CertificationID: certificationID, + UserID: cert.UserID, + ContractType: "ENTERPRISE_CERTIFICATION", + Status: "PENDING", + } + + if err := s.contractRepo.Create(ctx, contractRecord); err != nil { + s.logger.Warn("创建合同记录失败", zap.Error(err)) + } + + // TODO: 发送通知给管理员 + + s.logger.Info("合同申请成功", zap.String("certification_id", certificationID)) + + return &dto.ApplyContractResponse{ + ID: certificationID, + Status: enums.StatusContractPending, + ContractAppliedAt: time.Now(), + }, nil +} + +// serializeOCRResult 序列化OCR结果 +func (s *CertificationService) serializeOCRResult(result *dto.OCREnterpriseInfo) (string, error) { + // 简单的JSON序列化 + return fmt.Sprintf(`{"company_name":"%s","unified_social_code":"%s","legal_person_name":"%s","legal_person_id":"%s","confidence":%f}`, + result.CompanyName, result.UnifiedSocialCode, result.LegalPersonName, result.LegalPersonID, result.Confidence), nil +} diff --git a/internal/domains/certification/services/state_machine.go b/internal/domains/certification/services/state_machine.go new file mode 100644 index 0000000..9e1ad73 --- /dev/null +++ b/internal/domains/certification/services/state_machine.go @@ -0,0 +1,287 @@ +package services + +import ( + "context" + "fmt" + "time" + + "tyapi-server/internal/domains/certification/entities" + "tyapi-server/internal/domains/certification/enums" + "tyapi-server/internal/domains/certification/repositories" + + "go.uber.org/zap" +) + +// StateTransition 状态转换规则 +type StateTransition struct { + From enums.CertificationStatus + To enums.CertificationStatus + Action string + AllowUser bool // 是否允许用户操作 + AllowAdmin bool // 是否允许管理员操作 + RequiresValidation bool // 是否需要额外验证 +} + +// CertificationStateMachine 认证状态机 +type CertificationStateMachine struct { + transitions map[enums.CertificationStatus][]StateTransition + certRepo repositories.CertificationRepository + logger *zap.Logger +} + +// NewCertificationStateMachine 创建认证状态机 +func NewCertificationStateMachine( + certRepo repositories.CertificationRepository, + logger *zap.Logger, +) *CertificationStateMachine { + sm := &CertificationStateMachine{ + transitions: make(map[enums.CertificationStatus][]StateTransition), + certRepo: certRepo, + logger: logger, + } + + // 初始化状态转换规则 + sm.initializeTransitions() + return sm +} + +// initializeTransitions 初始化状态转换规则 +func (sm *CertificationStateMachine) initializeTransitions() { + transitions := []StateTransition{ + // 正常流程转换 + {enums.StatusPending, enums.StatusInfoSubmitted, "submit_info", true, false, true}, + {enums.StatusInfoSubmitted, enums.StatusFaceVerified, "face_verify", true, false, true}, + {enums.StatusFaceVerified, enums.StatusContractApplied, "apply_contract", true, false, false}, + {enums.StatusContractApplied, enums.StatusContractPending, "system_process", false, false, false}, + {enums.StatusContractPending, enums.StatusContractApproved, "admin_approve", false, true, true}, + {enums.StatusContractApproved, enums.StatusContractSigned, "user_sign", true, false, true}, + {enums.StatusContractSigned, enums.StatusCompleted, "system_complete", false, false, false}, + + // 失败和重试转换 + {enums.StatusInfoSubmitted, enums.StatusFaceFailed, "face_fail", false, false, false}, + {enums.StatusFaceFailed, enums.StatusFaceVerified, "retry_face", true, false, true}, + {enums.StatusContractPending, enums.StatusRejected, "admin_reject", false, true, true}, + {enums.StatusRejected, enums.StatusInfoSubmitted, "restart_process", true, false, false}, + {enums.StatusContractApproved, enums.StatusSignFailed, "sign_fail", false, false, false}, + {enums.StatusSignFailed, enums.StatusContractSigned, "retry_sign", true, false, true}, + } + + // 构建状态转换映射 + for _, transition := range transitions { + sm.transitions[transition.From] = append(sm.transitions[transition.From], transition) + } +} + +// CanTransition 检查是否可以转换到指定状态 +func (sm *CertificationStateMachine) CanTransition( + from enums.CertificationStatus, + to enums.CertificationStatus, + isUser bool, + isAdmin bool, +) (bool, string) { + validTransitions, exists := sm.transitions[from] + if !exists { + return false, "当前状态不支持任何转换" + } + + for _, transition := range validTransitions { + if transition.To == to { + if isUser && !transition.AllowUser { + return false, "用户不允许执行此操作" + } + if isAdmin && !transition.AllowAdmin { + return false, "管理员不允许执行此操作" + } + if !isUser && !isAdmin && (transition.AllowUser || transition.AllowAdmin) { + return false, "此操作需要用户或管理员权限" + } + return true, "" + } + } + + return false, "不支持的状态转换" +} + +// TransitionTo 执行状态转换 +func (sm *CertificationStateMachine) TransitionTo( + ctx context.Context, + certificationID string, + targetStatus enums.CertificationStatus, + isUser bool, + isAdmin bool, + metadata map[string]interface{}, +) error { + // 获取当前认证记录 + cert, err := sm.certRepo.GetByID(ctx, certificationID) + if err != nil { + return fmt.Errorf("获取认证记录失败: %w", err) + } + + // 检查是否可以转换 + canTransition, reason := sm.CanTransition(cert.Status, targetStatus, isUser, isAdmin) + if !canTransition { + return fmt.Errorf("状态转换失败: %s", reason) + } + + // 执行状态转换前的验证 + if err := sm.validateTransition(ctx, cert, targetStatus, metadata); err != nil { + return fmt.Errorf("状态转换验证失败: %w", err) + } + + // 更新状态和时间戳 + oldStatus := cert.Status + cert.Status = targetStatus + sm.updateTimestamp(cert, targetStatus) + + // 更新其他字段 + sm.updateCertificationFields(cert, targetStatus, metadata) + + // 保存到数据库 + if err := sm.certRepo.Update(ctx, cert); err != nil { + return fmt.Errorf("保存状态转换失败: %w", err) + } + + sm.logger.Info("认证状态转换成功", + zap.String("certification_id", certificationID), + zap.String("from_status", string(oldStatus)), + zap.String("to_status", string(targetStatus)), + zap.Bool("is_user", isUser), + zap.Bool("is_admin", isAdmin), + ) + + return nil +} + +// updateTimestamp 更新对应的时间戳字段 +func (sm *CertificationStateMachine) updateTimestamp(cert *entities.Certification, status enums.CertificationStatus) { + now := time.Now() + + switch status { + case enums.StatusInfoSubmitted: + cert.InfoSubmittedAt = &now + case enums.StatusFaceVerified: + cert.FaceVerifiedAt = &now + case enums.StatusContractApplied: + cert.ContractAppliedAt = &now + case enums.StatusContractApproved: + cert.ContractApprovedAt = &now + case enums.StatusContractSigned: + cert.ContractSignedAt = &now + case enums.StatusCompleted: + cert.CompletedAt = &now + } +} + +// updateCertificationFields 根据状态更新认证记录的其他字段 +func (sm *CertificationStateMachine) updateCertificationFields( + cert *entities.Certification, + status enums.CertificationStatus, + metadata map[string]interface{}, +) { + switch status { + case enums.StatusContractApproved: + if adminID, ok := metadata["admin_id"].(string); ok { + cert.AdminID = &adminID + } + if approvalNotes, ok := metadata["approval_notes"].(string); ok { + cert.ApprovalNotes = approvalNotes + } + if signingURL, ok := metadata["signing_url"].(string); ok { + cert.SigningURL = signingURL + } + + case enums.StatusRejected: + if adminID, ok := metadata["admin_id"].(string); ok { + cert.AdminID = &adminID + } + if rejectReason, ok := metadata["reject_reason"].(string); ok { + cert.RejectReason = rejectReason + } + + case enums.StatusContractSigned: + if contractURL, ok := metadata["contract_url"].(string); ok { + cert.ContractURL = contractURL + } + } +} + +// validateTransition 验证状态转换的有效性 +func (sm *CertificationStateMachine) validateTransition( + ctx context.Context, + cert *entities.Certification, + targetStatus enums.CertificationStatus, + metadata map[string]interface{}, +) error { + switch targetStatus { + case enums.StatusInfoSubmitted: + // 验证企业信息是否完整 + if cert.EnterpriseID == nil { + return fmt.Errorf("企业信息未提交") + } + + case enums.StatusFaceVerified: + // 验证人脸识别是否成功 + // 这里可以添加人脸识别结果的验证逻辑 + + case enums.StatusContractApproved: + // 验证管理员审核信息 + if metadata["signing_url"] == nil || metadata["signing_url"].(string) == "" { + return fmt.Errorf("缺少合同签署链接") + } + + case enums.StatusRejected: + // 验证拒绝原因 + if metadata["reject_reason"] == nil || metadata["reject_reason"].(string) == "" { + return fmt.Errorf("缺少拒绝原因") + } + + case enums.StatusContractSigned: + // 验证合同签署信息 + if cert.SigningURL == "" { + return fmt.Errorf("缺少合同签署链接") + } + } + + return nil +} + +// GetValidNextStatuses 获取当前状态可以转换到的下一个状态列表 +func (sm *CertificationStateMachine) GetValidNextStatuses( + currentStatus enums.CertificationStatus, + isUser bool, + isAdmin bool, +) []enums.CertificationStatus { + var validStatuses []enums.CertificationStatus + + transitions, exists := sm.transitions[currentStatus] + if !exists { + return validStatuses + } + + for _, transition := range transitions { + if (isUser && transition.AllowUser) || (isAdmin && transition.AllowAdmin) { + validStatuses = append(validStatuses, transition.To) + } + } + + return validStatuses +} + +// GetTransitionAction 获取状态转换对应的操作名称 +func (sm *CertificationStateMachine) GetTransitionAction( + from enums.CertificationStatus, + to enums.CertificationStatus, +) string { + transitions, exists := sm.transitions[from] + if !exists { + return "" + } + + for _, transition := range transitions { + if transition.To == to { + return transition.Action + } + } + + return "" +} diff --git a/internal/domains/finance/dto/finance_dto.go b/internal/domains/finance/dto/finance_dto.go new file mode 100644 index 0000000..78182a8 --- /dev/null +++ b/internal/domains/finance/dto/finance_dto.go @@ -0,0 +1,140 @@ +package dto + +import ( + "time" + + "github.com/shopspring/decimal" +) + +// WalletInfo 钱包信息 +type WalletInfo struct { + ID string `json:"id"` // 钱包ID + UserID string `json:"user_id"` // 用户ID + IsActive bool `json:"is_active"` // 是否激活 + Balance decimal.Decimal `json:"balance"` // 余额 + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedAt time.Time `json:"updated_at"` // 更新时间 +} + +// UserSecretsInfo 用户密钥信息 +type UserSecretsInfo struct { + ID string `json:"id"` // 密钥ID + UserID string `json:"user_id"` // 用户ID + AccessID string `json:"access_id"` // 访问ID + AccessKey string `json:"access_key"` // 访问密钥 + IsActive bool `json:"is_active"` // 是否激活 + LastUsedAt *time.Time `json:"last_used_at"` // 最后使用时间 + ExpiresAt *time.Time `json:"expires_at"` // 过期时间 + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedAt time.Time `json:"updated_at"` // 更新时间 +} + +// CreateWalletRequest 创建钱包请求 +type CreateWalletRequest struct { + UserID string `json:"user_id" binding:"required"` // 用户ID +} + +// CreateWalletResponse 创建钱包响应 +type CreateWalletResponse struct { + Wallet WalletInfo `json:"wallet"` // 钱包信息 +} + +// GetWalletRequest 获取钱包请求 +type GetWalletRequest struct { + UserID string `form:"user_id" binding:"required"` // 用户ID +} + +// UpdateWalletRequest 更新钱包请求 +type UpdateWalletRequest struct { + UserID string `json:"user_id" binding:"required"` // 用户ID + Balance decimal.Decimal `json:"balance"` // 余额 + IsActive *bool `json:"is_active"` // 是否激活 +} + +// RechargeRequest 充值请求 +type RechargeRequest struct { + UserID string `json:"user_id" binding:"required"` // 用户ID + Amount decimal.Decimal `json:"amount" binding:"required"` // 充值金额 +} + +// RechargeResponse 充值响应 +type RechargeResponse struct { + WalletID string `json:"wallet_id"` // 钱包ID + Amount decimal.Decimal `json:"amount"` // 充值金额 + Balance decimal.Decimal `json:"balance"` // 充值后余额 +} + +// WithdrawRequest 提现请求 +type WithdrawRequest struct { + UserID string `json:"user_id" binding:"required"` // 用户ID + Amount decimal.Decimal `json:"amount" binding:"required"` // 提现金额 +} + +// WithdrawResponse 提现响应 +type WithdrawResponse struct { + WalletID string `json:"wallet_id"` // 钱包ID + Amount decimal.Decimal `json:"amount"` // 提现金额 + Balance decimal.Decimal `json:"balance"` // 提现后余额 +} + +// CreateUserSecretsRequest 创建用户密钥请求 +type CreateUserSecretsRequest struct { + UserID string `json:"user_id" binding:"required"` // 用户ID + ExpiresAt *time.Time `json:"expires_at"` // 过期时间 +} + +// CreateUserSecretsResponse 创建用户密钥响应 +type CreateUserSecretsResponse struct { + Secrets UserSecretsInfo `json:"secrets"` // 密钥信息 +} + +// GetUserSecretsRequest 获取用户密钥请求 +type GetUserSecretsRequest struct { + UserID string `form:"user_id" binding:"required"` // 用户ID +} + +// RegenerateAccessKeyRequest 重新生成访问密钥请求 +type RegenerateAccessKeyRequest struct { + UserID string `json:"user_id" binding:"required"` // 用户ID + ExpiresAt *time.Time `json:"expires_at"` // 过期时间 +} + +// RegenerateAccessKeyResponse 重新生成访问密钥响应 +type RegenerateAccessKeyResponse struct { + AccessID string `json:"access_id"` // 新的访问ID + AccessKey string `json:"access_key"` // 新的访问密钥 +} + +// DeactivateUserSecretsRequest 停用用户密钥请求 +type DeactivateUserSecretsRequest struct { + UserID string `json:"user_id" binding:"required"` // 用户ID +} + +// WalletTransactionRequest 钱包交易请求 +type WalletTransactionRequest struct { + FromUserID string `json:"from_user_id" binding:"required"` // 转出用户ID + ToUserID string `json:"to_user_id" binding:"required"` // 转入用户ID + Amount decimal.Decimal `json:"amount" binding:"required"` // 交易金额 + Notes string `json:"notes"` // 交易备注 +} + +// WalletTransactionResponse 钱包交易响应 +type WalletTransactionResponse struct { + TransactionID string `json:"transaction_id"` // 交易ID + FromUserID string `json:"from_user_id"` // 转出用户ID + ToUserID string `json:"to_user_id"` // 转入用户ID + Amount decimal.Decimal `json:"amount"` // 交易金额 + FromBalance decimal.Decimal `json:"from_balance"` // 转出后余额 + ToBalance decimal.Decimal `json:"to_balance"` // 转入后余额 + Notes string `json:"notes"` // 交易备注 + CreatedAt time.Time `json:"created_at"` // 交易时间 +} + +// WalletStatsResponse 钱包统计响应 +type WalletStatsResponse struct { + TotalWallets int64 `json:"total_wallets"` // 总钱包数 + ActiveWallets int64 `json:"active_wallets"` // 激活钱包数 + TotalBalance decimal.Decimal `json:"total_balance"` // 总余额 + TodayTransactions int64 `json:"today_transactions"` // 今日交易数 + TodayVolume decimal.Decimal `json:"today_volume"` // 今日交易量 +} diff --git a/internal/domains/finance/entities/user_secrets.go b/internal/domains/finance/entities/user_secrets.go new file mode 100644 index 0000000..d57c35d --- /dev/null +++ b/internal/domains/finance/entities/user_secrets.go @@ -0,0 +1,67 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +// UserSecrets 用户密钥实体 +// 存储用户的API访问密钥信息,用于第三方服务集成和API调用 +// 支持密钥的生命周期管理,包括激活状态、过期时间、使用统计等 +type UserSecrets struct { + // 基础标识 + ID string `gorm:"primaryKey;type:varchar(36)" comment:"密钥记录唯一标识"` + UserID string `gorm:"type:varchar(36);not null;uniqueIndex" comment:"关联用户ID"` + AccessID string `gorm:"type:varchar(100);not null;uniqueIndex" comment:"访问ID(用于API认证)"` + AccessKey string `gorm:"type:varchar(255);not null" comment:"访问密钥(加密存储)"` + + // 密钥状态 - 密钥的生命周期管理 + IsActive bool `gorm:"default:true" comment:"密钥是否激活"` + LastUsedAt *time.Time `comment:"最后使用时间"` + ExpiresAt *time.Time `comment:"密钥过期时间"` + + // 时间戳字段 + CreatedAt time.Time `gorm:"autoCreateTime" comment:"创建时间"` + UpdatedAt time.Time `gorm:"autoUpdateTime" comment:"更新时间"` + DeletedAt gorm.DeletedAt `gorm:"index" comment:"软删除时间"` +} + +// TableName 指定数据库表名 +func (UserSecrets) TableName() string { + return "user_secrets" +} + +// IsExpired 检查密钥是否已过期 +// 判断密钥是否超过有效期,过期后需要重新生成或续期 +func (u *UserSecrets) IsExpired() bool { + if u.ExpiresAt == nil { + return false // 没有过期时间表示永不过期 + } + return time.Now().After(*u.ExpiresAt) +} + +// IsValid 检查密钥是否有效 +// 综合判断密钥是否可用,包括激活状态和过期状态检查 +func (u *UserSecrets) IsValid() bool { + return u.IsActive && !u.IsExpired() +} + +// UpdateLastUsedAt 更新最后使用时间 +// 在密钥被使用时调用,记录最新的使用时间,用于使用统计和监控 +func (u *UserSecrets) UpdateLastUsedAt() { + now := time.Now() + u.LastUsedAt = &now +} + +// Deactivate 停用密钥 +// 将密钥设置为非激活状态,禁止使用该密钥进行API调用 +func (u *UserSecrets) Deactivate() { + u.IsActive = false +} + +// Activate 激活密钥 +// 重新启用密钥,允许使用该密钥进行API调用 +func (u *UserSecrets) Activate() { + u.IsActive = true +} diff --git a/internal/domains/finance/entities/wallet.go b/internal/domains/finance/entities/wallet.go new file mode 100644 index 0000000..0d3f04e --- /dev/null +++ b/internal/domains/finance/entities/wallet.go @@ -0,0 +1,71 @@ +package entities + +import ( + "fmt" + "time" + + "github.com/shopspring/decimal" + "gorm.io/gorm" +) + +// Wallet 钱包实体 +// 用户数字钱包的核心信息,支持多种钱包类型和精确的余额管理 +// 使用decimal类型确保金额计算的精确性,避免浮点数精度问题 +type Wallet struct { + // 基础标识 + ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"钱包唯一标识"` + UserID string `gorm:"type:varchar(36);not null;uniqueIndex" json:"user_id" comment:"关联用户ID"` + + // 钱包状态 - 钱包的基本状态信息 + IsActive bool `gorm:"default:true" json:"is_active" comment:"钱包是否激活"` + Balance decimal.Decimal `gorm:"type:decimal(20,8);default:0" json:"balance" comment:"钱包余额(精确到8位小数)"` + + // 钱包信息 - 钱包的详细配置信息 + WalletAddress string `gorm:"type:varchar(255)" json:"wallet_address,omitempty" comment:"钱包地址"` + WalletType string `gorm:"type:varchar(50);default:'MAIN'" json:"wallet_type" comment:"钱包类型(MAIN/DEPOSIT/WITHDRAWAL)"` // MAIN, DEPOSIT, WITHDRAWAL + + // 时间戳字段 + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" comment:"创建时间"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" comment:"更新时间"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-" comment:"软删除时间"` +} + +// TableName 指定数据库表名 +func (Wallet) TableName() string { + return "wallets" +} + +// IsZeroBalance 检查余额是否为零 +// 判断钱包余额是否为零,用于业务逻辑判断 +func (w *Wallet) IsZeroBalance() bool { + return w.Balance.IsZero() +} + +// HasSufficientBalance 检查是否有足够余额 +// 判断钱包余额是否足够支付指定金额,用于交易前的余额验证 +func (w *Wallet) HasSufficientBalance(amount decimal.Decimal) bool { + return w.Balance.GreaterThanOrEqual(amount) +} + +// AddBalance 增加余额 +// 向钱包增加指定金额,用于充值、收入等场景 +func (w *Wallet) AddBalance(amount decimal.Decimal) { + w.Balance = w.Balance.Add(amount) +} + +// SubtractBalance 减少余额 +// 从钱包扣除指定金额,用于消费、转账等场景 +// 如果余额不足会返回错误,确保资金安全 +func (w *Wallet) SubtractBalance(amount decimal.Decimal) error { + if !w.HasSufficientBalance(amount) { + return fmt.Errorf("余额不足") + } + w.Balance = w.Balance.Sub(amount) + return nil +} + +// GetFormattedBalance 获取格式化的余额字符串 +// 将decimal类型的余额转换为字符串格式,便于显示和传输 +func (w *Wallet) GetFormattedBalance() string { + return w.Balance.String() +} diff --git a/internal/domains/finance/handlers/finance_handler.go b/internal/domains/finance/handlers/finance_handler.go new file mode 100644 index 0000000..db17df4 --- /dev/null +++ b/internal/domains/finance/handlers/finance_handler.go @@ -0,0 +1,336 @@ +package handlers + +import ( + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "tyapi-server/internal/domains/finance/dto" + "tyapi-server/internal/domains/finance/services" + "tyapi-server/internal/shared/interfaces" +) + +// FinanceHandler 财务HTTP处理器 +type FinanceHandler struct { + financeService *services.FinanceService + responseBuilder interfaces.ResponseBuilder + logger *zap.Logger +} + +// NewFinanceHandler 创建财务HTTP处理器 +func NewFinanceHandler( + financeService *services.FinanceService, + responseBuilder interfaces.ResponseBuilder, + logger *zap.Logger, +) *FinanceHandler { + return &FinanceHandler{ + financeService: financeService, + responseBuilder: responseBuilder, + logger: logger, + } +} + +// CreateWallet 创建钱包 +// @Summary 创建钱包 +// @Description 为用户创建钱包 +// @Tags 财务系统 +// @Accept json +// @Produce json +// @Param request body dto.CreateWalletRequest true "创建钱包请求" +// @Success 201 {object} dto.CreateWalletResponse +// @Failure 400 {object} interfaces.ErrorResponse +// @Router /finance/wallet [post] +func (h *FinanceHandler) CreateWallet(c *gin.Context) { + var req dto.CreateWalletRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Warn("创建钱包参数验证失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, "请求参数错误") + return + } + + response, err := h.financeService.CreateWallet(c.Request.Context(), &req) + if err != nil { + h.logger.Error("创建钱包失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, err.Error()) + return + } + + h.responseBuilder.Created(c, response, "钱包创建成功") +} + +// GetWallet 获取钱包信息 +// @Summary 获取钱包信息 +// @Description 获取用户钱包信息 +// @Tags 财务系统 +// @Accept json +// @Produce json +// @Param user_id query string true "用户ID" +// @Success 200 {object} dto.WalletInfo +// @Failure 400 {object} interfaces.ErrorResponse +// @Failure 404 {object} interfaces.ErrorResponse +// @Router /finance/wallet [get] +func (h *FinanceHandler) GetWallet(c *gin.Context) { + userID := c.Query("user_id") + if userID == "" { + h.responseBuilder.BadRequest(c, "用户ID不能为空") + return + } + + wallet, err := h.financeService.GetWallet(c.Request.Context(), userID) + if err != nil { + h.logger.Error("获取钱包信息失败", zap.Error(err)) + h.responseBuilder.NotFound(c, err.Error()) + return + } + + h.responseBuilder.Success(c, wallet, "获取钱包信息成功") +} + +// UpdateWallet 更新钱包 +// @Summary 更新钱包 +// @Description 更新用户钱包信息 +// @Tags 财务系统 +// @Accept json +// @Produce json +// @Param request body dto.UpdateWalletRequest true "更新钱包请求" +// @Success 200 {object} interfaces.SuccessResponse +// @Failure 400 {object} interfaces.ErrorResponse +// @Failure 404 {object} interfaces.ErrorResponse +// @Router /finance/wallet [put] +func (h *FinanceHandler) UpdateWallet(c *gin.Context) { + var req dto.UpdateWalletRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Warn("更新钱包参数验证失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, "请求参数错误") + return + } + + err := h.financeService.UpdateWallet(c.Request.Context(), &req) + if err != nil { + h.logger.Error("更新钱包失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, err.Error()) + return + } + + h.responseBuilder.Success(c, nil, "钱包更新成功") +} + +// Recharge 充值 +// @Summary 钱包充值 +// @Description 为用户钱包充值 +// @Tags 财务系统 +// @Accept json +// @Produce json +// @Param request body dto.RechargeRequest true "充值请求" +// @Success 200 {object} dto.RechargeResponse +// @Failure 400 {object} interfaces.ErrorResponse +// @Failure 404 {object} interfaces.ErrorResponse +// @Router /finance/wallet/recharge [post] +func (h *FinanceHandler) Recharge(c *gin.Context) { + var req dto.RechargeRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Warn("充值参数验证失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, "请求参数错误") + return + } + + response, err := h.financeService.Recharge(c.Request.Context(), &req) + if err != nil { + h.logger.Error("充值失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, err.Error()) + return + } + + h.responseBuilder.Success(c, response, "充值成功") +} + +// Withdraw 提现 +// @Summary 钱包提现 +// @Description 从用户钱包提现 +// @Tags 财务系统 +// @Accept json +// @Produce json +// @Param request body dto.WithdrawRequest true "提现请求" +// @Success 200 {object} dto.WithdrawResponse +// @Failure 400 {object} interfaces.ErrorResponse +// @Failure 404 {object} interfaces.ErrorResponse +// @Router /finance/wallet/withdraw [post] +func (h *FinanceHandler) Withdraw(c *gin.Context) { + var req dto.WithdrawRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Warn("提现参数验证失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, "请求参数错误") + return + } + + response, err := h.financeService.Withdraw(c.Request.Context(), &req) + if err != nil { + h.logger.Error("提现失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, err.Error()) + return + } + + h.responseBuilder.Success(c, response, "提现成功") +} + +// CreateUserSecrets 创建用户密钥 +// @Summary 创建用户密钥 +// @Description 为用户创建访问密钥 +// @Tags 财务系统 +// @Accept json +// @Produce json +// @Param request body dto.CreateUserSecretsRequest true "创建密钥请求" +// @Success 201 {object} dto.CreateUserSecretsResponse +// @Failure 400 {object} interfaces.ErrorResponse +// @Router /finance/secrets [post] +func (h *FinanceHandler) CreateUserSecrets(c *gin.Context) { + var req dto.CreateUserSecretsRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Warn("创建密钥参数验证失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, "请求参数错误") + return + } + + response, err := h.financeService.CreateUserSecrets(c.Request.Context(), &req) + if err != nil { + h.logger.Error("创建密钥失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, err.Error()) + return + } + + h.responseBuilder.Created(c, response, "密钥创建成功") +} + +// GetUserSecrets 获取用户密钥 +// @Summary 获取用户密钥 +// @Description 获取用户访问密钥信息 +// @Tags 财务系统 +// @Accept json +// @Produce json +// @Param user_id query string true "用户ID" +// @Success 200 {object} dto.UserSecretsInfo +// @Failure 400 {object} interfaces.ErrorResponse +// @Failure 404 {object} interfaces.ErrorResponse +// @Router /finance/secrets [get] +func (h *FinanceHandler) GetUserSecrets(c *gin.Context) { + userID := c.Query("user_id") + if userID == "" { + h.responseBuilder.BadRequest(c, "用户ID不能为空") + return + } + + secrets, err := h.financeService.GetUserSecrets(c.Request.Context(), userID) + if err != nil { + h.logger.Error("获取密钥失败", zap.Error(err)) + h.responseBuilder.NotFound(c, err.Error()) + return + } + + h.responseBuilder.Success(c, secrets, "获取密钥成功") +} + +// RegenerateAccessKey 重新生成访问密钥 +// @Summary 重新生成访问密钥 +// @Description 重新生成用户的访问密钥 +// @Tags 财务系统 +// @Accept json +// @Produce json +// @Param request body dto.RegenerateAccessKeyRequest true "重新生成密钥请求" +// @Success 200 {object} dto.RegenerateAccessKeyResponse +// @Failure 400 {object} interfaces.ErrorResponse +// @Failure 404 {object} interfaces.ErrorResponse +// @Router /finance/secrets/regenerate [post] +func (h *FinanceHandler) RegenerateAccessKey(c *gin.Context) { + var req dto.RegenerateAccessKeyRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Warn("重新生成密钥参数验证失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, "请求参数错误") + return + } + + response, err := h.financeService.RegenerateAccessKey(c.Request.Context(), &req) + if err != nil { + h.logger.Error("重新生成密钥失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, err.Error()) + return + } + + h.responseBuilder.Success(c, response, "密钥重新生成成功") +} + +// DeactivateUserSecrets 停用用户密钥 +// @Summary 停用用户密钥 +// @Description 停用用户的访问密钥 +// @Tags 财务系统 +// @Accept json +// @Produce json +// @Param request body dto.DeactivateUserSecretsRequest true "停用密钥请求" +// @Success 200 {object} interfaces.SuccessResponse +// @Failure 400 {object} interfaces.ErrorResponse +// @Failure 404 {object} interfaces.ErrorResponse +// @Router /finance/secrets/deactivate [post] +func (h *FinanceHandler) DeactivateUserSecrets(c *gin.Context) { + var req dto.DeactivateUserSecretsRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Warn("停用密钥参数验证失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, "请求参数错误") + return + } + + err := h.financeService.DeactivateUserSecrets(c.Request.Context(), &req) + if err != nil { + h.logger.Error("停用密钥失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, err.Error()) + return + } + + h.responseBuilder.Success(c, nil, "密钥停用成功") +} + +// WalletTransaction 钱包交易 +// @Summary 钱包交易 +// @Description 用户间钱包转账 +// @Tags 财务系统 +// @Accept json +// @Produce json +// @Param request body dto.WalletTransactionRequest true "交易请求" +// @Success 200 {object} dto.WalletTransactionResponse +// @Failure 400 {object} interfaces.ErrorResponse +// @Failure 404 {object} interfaces.ErrorResponse +// @Router /finance/wallet/transaction [post] +func (h *FinanceHandler) WalletTransaction(c *gin.Context) { + var req dto.WalletTransactionRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Warn("交易参数验证失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, "请求参数错误") + return + } + + response, err := h.financeService.WalletTransaction(c.Request.Context(), &req) + if err != nil { + h.logger.Error("交易失败", zap.Error(err)) + h.responseBuilder.BadRequest(c, err.Error()) + return + } + + h.responseBuilder.Success(c, response, "交易成功") +} + +// GetWalletStats 获取钱包统计 +// @Summary 获取钱包统计 +// @Description 获取钱包系统统计信息 +// @Tags 财务系统 +// @Accept json +// @Produce json +// @Success 200 {object} dto.WalletStatsResponse +// @Failure 400 {object} interfaces.ErrorResponse +// @Router /finance/wallet/stats [get] +func (h *FinanceHandler) GetWalletStats(c *gin.Context) { + stats, err := h.financeService.GetWalletStats(c.Request.Context()) + if err != nil { + h.logger.Error("获取钱包统计失败", zap.Error(err)) + h.responseBuilder.InternalError(c, "获取统计信息失败") + return + } + + h.responseBuilder.Success(c, stats, "获取统计信息成功") +} diff --git a/internal/domains/finance/repositories/finance_repository.go b/internal/domains/finance/repositories/finance_repository.go new file mode 100644 index 0000000..a9182df --- /dev/null +++ b/internal/domains/finance/repositories/finance_repository.go @@ -0,0 +1,46 @@ +package repositories + +import ( + "context" + + "tyapi-server/internal/domains/finance/entities" + "tyapi-server/internal/shared/interfaces" +) + +// WalletRepository 钱包仓储接口 +type WalletRepository interface { + interfaces.Repository[entities.Wallet] + + // 钱包管理 + FindByUserID(ctx context.Context, userID string) (*entities.Wallet, error) + ExistsByUserID(ctx context.Context, userID string) (bool, error) + + // 余额操作 + UpdateBalance(ctx context.Context, userID string, balance interface{}) error + AddBalance(ctx context.Context, userID string, amount interface{}) error + SubtractBalance(ctx context.Context, userID string, amount interface{}) error + + // 统计查询 + GetTotalBalance(ctx context.Context) (interface{}, error) + GetActiveWalletCount(ctx context.Context) (int64, error) +} + +// UserSecretsRepository 用户密钥仓储接口 +type UserSecretsRepository interface { + interfaces.Repository[entities.UserSecrets] + + // 密钥管理 + FindByUserID(ctx context.Context, userID string) (*entities.UserSecrets, error) + FindByAccessID(ctx context.Context, accessID string) (*entities.UserSecrets, error) + ExistsByUserID(ctx context.Context, userID string) (bool, error) + ExistsByAccessID(ctx context.Context, accessID string) (bool, error) + + // 密钥操作 + UpdateLastUsedAt(ctx context.Context, accessID string) error + DeactivateByUserID(ctx context.Context, userID string) error + RegenerateAccessKey(ctx context.Context, userID string, accessID, accessKey string) error + + // 过期密钥清理 + GetExpiredSecrets(ctx context.Context) ([]entities.UserSecrets, error) + DeleteExpiredSecrets(ctx context.Context) error +} diff --git a/internal/domains/finance/repositories/gorm_finance_repository.go b/internal/domains/finance/repositories/gorm_finance_repository.go new file mode 100644 index 0000000..d388b85 --- /dev/null +++ b/internal/domains/finance/repositories/gorm_finance_repository.go @@ -0,0 +1,410 @@ +package repositories + +import ( + "context" + "time" + + "github.com/shopspring/decimal" + "go.uber.org/zap" + "gorm.io/gorm" + + "tyapi-server/internal/domains/finance/entities" + "tyapi-server/internal/shared/interfaces" +) + +// GormWalletRepository 钱包GORM仓储实现 +type GormWalletRepository struct { + db *gorm.DB + logger *zap.Logger +} + +// NewGormWalletRepository 创建钱包GORM仓储 +func NewGormWalletRepository(db *gorm.DB, logger *zap.Logger) *GormWalletRepository { + return &GormWalletRepository{ + db: db, + logger: logger, + } +} + +// Create 创建钱包 +func (r *GormWalletRepository) Create(ctx context.Context, wallet entities.Wallet) error { + r.logger.Info("创建钱包", zap.String("user_id", wallet.UserID)) + return r.db.WithContext(ctx).Create(&wallet).Error +} + +// GetByID 根据ID获取钱包 +func (r *GormWalletRepository) GetByID(ctx context.Context, id string) (entities.Wallet, error) { + var wallet entities.Wallet + err := r.db.WithContext(ctx).Where("id = ?", id).First(&wallet).Error + return wallet, err +} + +// Update 更新钱包 +func (r *GormWalletRepository) Update(ctx context.Context, wallet entities.Wallet) error { + r.logger.Info("更新钱包", zap.String("id", wallet.ID)) + return r.db.WithContext(ctx).Save(&wallet).Error +} + +// Delete 删除钱包 +func (r *GormWalletRepository) Delete(ctx context.Context, id string) error { + r.logger.Info("删除钱包", zap.String("id", id)) + return r.db.WithContext(ctx).Delete(&entities.Wallet{}, "id = ?", id).Error +} + +// SoftDelete 软删除钱包 +func (r *GormWalletRepository) SoftDelete(ctx context.Context, id string) error { + r.logger.Info("软删除钱包", zap.String("id", id)) + return r.db.WithContext(ctx).Delete(&entities.Wallet{}, "id = ?", id).Error +} + +// Restore 恢复钱包 +func (r *GormWalletRepository) Restore(ctx context.Context, id string) error { + r.logger.Info("恢复钱包", zap.String("id", id)) + return r.db.WithContext(ctx).Unscoped().Model(&entities.Wallet{}).Where("id = ?", id).Update("deleted_at", nil).Error +} + +// Count 统计钱包数量 +func (r *GormWalletRepository) Count(ctx context.Context, options interfaces.CountOptions) (int64, error) { + var count int64 + query := r.db.WithContext(ctx).Model(&entities.Wallet{}) + + if options.Filters != nil { + for key, value := range options.Filters { + query = query.Where(key+" = ?", value) + } + } + + if options.Search != "" { + query = query.Where("user_id LIKE ?", "%"+options.Search+"%") + } + + return count, query.Count(&count).Error +} + +// Exists 检查钱包是否存在 +func (r *GormWalletRepository) Exists(ctx context.Context, id string) (bool, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entities.Wallet{}).Where("id = ?", id).Count(&count).Error + return count > 0, err +} + +// CreateBatch 批量创建钱包 +func (r *GormWalletRepository) CreateBatch(ctx context.Context, wallets []entities.Wallet) error { + r.logger.Info("批量创建钱包", zap.Int("count", len(wallets))) + return r.db.WithContext(ctx).Create(&wallets).Error +} + +// GetByIDs 根据ID列表获取钱包 +func (r *GormWalletRepository) GetByIDs(ctx context.Context, ids []string) ([]entities.Wallet, error) { + var wallets []entities.Wallet + err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&wallets).Error + return wallets, err +} + +// UpdateBatch 批量更新钱包 +func (r *GormWalletRepository) UpdateBatch(ctx context.Context, wallets []entities.Wallet) error { + r.logger.Info("批量更新钱包", zap.Int("count", len(wallets))) + return r.db.WithContext(ctx).Save(&wallets).Error +} + +// DeleteBatch 批量删除钱包 +func (r *GormWalletRepository) DeleteBatch(ctx context.Context, ids []string) error { + r.logger.Info("批量删除钱包", zap.Strings("ids", ids)) + return r.db.WithContext(ctx).Delete(&entities.Wallet{}, "id IN ?", ids).Error +} + +// List 获取钱包列表 +func (r *GormWalletRepository) List(ctx context.Context, options interfaces.ListOptions) ([]entities.Wallet, error) { + var wallets []entities.Wallet + query := r.db.WithContext(ctx).Model(&entities.Wallet{}) + + if options.Filters != nil { + for key, value := range options.Filters { + query = query.Where(key+" = ?", value) + } + } + + if options.Search != "" { + query = query.Where("user_id LIKE ?", "%"+options.Search+"%") + } + + if options.Sort != "" { + order := "ASC" + if options.Order != "" { + order = options.Order + } + query = query.Order(options.Sort + " " + order) + } + + if options.Page > 0 && options.PageSize > 0 { + offset := (options.Page - 1) * options.PageSize + query = query.Offset(offset).Limit(options.PageSize) + } + + return wallets, query.Find(&wallets).Error +} + +// WithTx 使用事务 +func (r *GormWalletRepository) WithTx(tx interface{}) interfaces.Repository[entities.Wallet] { + if gormTx, ok := tx.(*gorm.DB); ok { + return &GormWalletRepository{ + db: gormTx, + logger: r.logger, + } + } + return r +} + +// FindByUserID 根据用户ID查找钱包 +func (r *GormWalletRepository) FindByUserID(ctx context.Context, userID string) (*entities.Wallet, error) { + var wallet entities.Wallet + err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&wallet).Error + if err != nil { + return nil, err + } + return &wallet, nil +} + +// ExistsByUserID 检查用户钱包是否存在 +func (r *GormWalletRepository) ExistsByUserID(ctx context.Context, userID string) (bool, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entities.Wallet{}).Where("user_id = ?", userID).Count(&count).Error + return count > 0, err +} + +// UpdateBalance 更新余额 +func (r *GormWalletRepository) UpdateBalance(ctx context.Context, userID string, balance interface{}) error { + return r.db.WithContext(ctx).Model(&entities.Wallet{}).Where("user_id = ?", userID).Update("balance", balance).Error +} + +// AddBalance 增加余额 +func (r *GormWalletRepository) AddBalance(ctx context.Context, userID string, amount interface{}) error { + return r.db.WithContext(ctx).Model(&entities.Wallet{}).Where("user_id = ?", userID).Update("balance", gorm.Expr("balance + ?", amount)).Error +} + +// SubtractBalance 减少余额 +func (r *GormWalletRepository) SubtractBalance(ctx context.Context, userID string, amount interface{}) error { + return r.db.WithContext(ctx).Model(&entities.Wallet{}).Where("user_id = ?", userID).Update("balance", gorm.Expr("balance - ?", amount)).Error +} + +// GetTotalBalance 获取总余额 +func (r *GormWalletRepository) GetTotalBalance(ctx context.Context) (interface{}, error) { + var total decimal.Decimal + err := r.db.WithContext(ctx).Model(&entities.Wallet{}).Select("COALESCE(SUM(balance), 0)").Scan(&total).Error + return total, err +} + +// GetActiveWalletCount 获取激活钱包数量 +func (r *GormWalletRepository) GetActiveWalletCount(ctx context.Context) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entities.Wallet{}).Where("is_active = ?", true).Count(&count).Error + return count, err +} + +// GormUserSecretsRepository 用户密钥GORM仓储实现 +type GormUserSecretsRepository struct { + db *gorm.DB + logger *zap.Logger +} + +// NewGormUserSecretsRepository 创建用户密钥GORM仓储 +func NewGormUserSecretsRepository(db *gorm.DB, logger *zap.Logger) *GormUserSecretsRepository { + return &GormUserSecretsRepository{ + db: db, + logger: logger, + } +} + +// Create 创建用户密钥 +func (r *GormUserSecretsRepository) Create(ctx context.Context, secrets entities.UserSecrets) error { + r.logger.Info("创建用户密钥", zap.String("user_id", secrets.UserID)) + return r.db.WithContext(ctx).Create(&secrets).Error +} + +// GetByID 根据ID获取用户密钥 +func (r *GormUserSecretsRepository) GetByID(ctx context.Context, id string) (entities.UserSecrets, error) { + var secrets entities.UserSecrets + err := r.db.WithContext(ctx).Where("id = ?", id).First(&secrets).Error + return secrets, err +} + +// Update 更新用户密钥 +func (r *GormUserSecretsRepository) Update(ctx context.Context, secrets entities.UserSecrets) error { + r.logger.Info("更新用户密钥", zap.String("id", secrets.ID)) + return r.db.WithContext(ctx).Save(&secrets).Error +} + +// Delete 删除用户密钥 +func (r *GormUserSecretsRepository) Delete(ctx context.Context, id string) error { + r.logger.Info("删除用户密钥", zap.String("id", id)) + return r.db.WithContext(ctx).Delete(&entities.UserSecrets{}, "id = ?", id).Error +} + +// SoftDelete 软删除用户密钥 +func (r *GormUserSecretsRepository) SoftDelete(ctx context.Context, id string) error { + r.logger.Info("软删除用户密钥", zap.String("id", id)) + return r.db.WithContext(ctx).Delete(&entities.UserSecrets{}, "id = ?", id).Error +} + +// Restore 恢复用户密钥 +func (r *GormUserSecretsRepository) Restore(ctx context.Context, id string) error { + r.logger.Info("恢复用户密钥", zap.String("id", id)) + return r.db.WithContext(ctx).Unscoped().Model(&entities.UserSecrets{}).Where("id = ?", id).Update("deleted_at", nil).Error +} + +// Count 统计用户密钥数量 +func (r *GormUserSecretsRepository) Count(ctx context.Context, options interfaces.CountOptions) (int64, error) { + var count int64 + query := r.db.WithContext(ctx).Model(&entities.UserSecrets{}) + + if options.Filters != nil { + for key, value := range options.Filters { + query = query.Where(key+" = ?", value) + } + } + + if options.Search != "" { + query = query.Where("user_id LIKE ? OR access_id LIKE ?", "%"+options.Search+"%", "%"+options.Search+"%") + } + + return count, query.Count(&count).Error +} + +// Exists 检查用户密钥是否存在 +func (r *GormUserSecretsRepository) Exists(ctx context.Context, id string) (bool, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entities.UserSecrets{}).Where("id = ?", id).Count(&count).Error + return count > 0, err +} + +// CreateBatch 批量创建用户密钥 +func (r *GormUserSecretsRepository) CreateBatch(ctx context.Context, secrets []entities.UserSecrets) error { + r.logger.Info("批量创建用户密钥", zap.Int("count", len(secrets))) + return r.db.WithContext(ctx).Create(&secrets).Error +} + +// GetByIDs 根据ID列表获取用户密钥 +func (r *GormUserSecretsRepository) GetByIDs(ctx context.Context, ids []string) ([]entities.UserSecrets, error) { + var secrets []entities.UserSecrets + err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&secrets).Error + return secrets, err +} + +// UpdateBatch 批量更新用户密钥 +func (r *GormUserSecretsRepository) UpdateBatch(ctx context.Context, secrets []entities.UserSecrets) error { + r.logger.Info("批量更新用户密钥", zap.Int("count", len(secrets))) + return r.db.WithContext(ctx).Save(&secrets).Error +} + +// DeleteBatch 批量删除用户密钥 +func (r *GormUserSecretsRepository) DeleteBatch(ctx context.Context, ids []string) error { + r.logger.Info("批量删除用户密钥", zap.Strings("ids", ids)) + return r.db.WithContext(ctx).Delete(&entities.UserSecrets{}, "id IN ?", ids).Error +} + +// List 获取用户密钥列表 +func (r *GormUserSecretsRepository) List(ctx context.Context, options interfaces.ListOptions) ([]entities.UserSecrets, error) { + var secrets []entities.UserSecrets + query := r.db.WithContext(ctx).Model(&entities.UserSecrets{}) + + if options.Filters != nil { + for key, value := range options.Filters { + query = query.Where(key+" = ?", value) + } + } + + if options.Search != "" { + query = query.Where("user_id LIKE ? OR access_id LIKE ?", "%"+options.Search+"%", "%"+options.Search+"%") + } + + if options.Sort != "" { + order := "ASC" + if options.Order != "" { + order = options.Order + } + query = query.Order(options.Sort + " " + order) + } + + if options.Page > 0 && options.PageSize > 0 { + offset := (options.Page - 1) * options.PageSize + query = query.Offset(offset).Limit(options.PageSize) + } + + return secrets, query.Find(&secrets).Error +} + +// WithTx 使用事务 +func (r *GormUserSecretsRepository) WithTx(tx interface{}) interfaces.Repository[entities.UserSecrets] { + if gormTx, ok := tx.(*gorm.DB); ok { + return &GormUserSecretsRepository{ + db: gormTx, + logger: r.logger, + } + } + return r +} + +// FindByUserID 根据用户ID查找密钥 +func (r *GormUserSecretsRepository) FindByUserID(ctx context.Context, userID string) (*entities.UserSecrets, error) { + var secrets entities.UserSecrets + err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&secrets).Error + if err != nil { + return nil, err + } + return &secrets, nil +} + +// FindByAccessID 根据访问ID查找密钥 +func (r *GormUserSecretsRepository) FindByAccessID(ctx context.Context, accessID string) (*entities.UserSecrets, error) { + var secrets entities.UserSecrets + err := r.db.WithContext(ctx).Where("access_id = ?", accessID).First(&secrets).Error + if err != nil { + return nil, err + } + return &secrets, nil +} + +// ExistsByUserID 检查用户密钥是否存在 +func (r *GormUserSecretsRepository) ExistsByUserID(ctx context.Context, userID string) (bool, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entities.UserSecrets{}).Where("user_id = ?", userID).Count(&count).Error + return count > 0, err +} + +// ExistsByAccessID 检查访问ID是否存在 +func (r *GormUserSecretsRepository) ExistsByAccessID(ctx context.Context, accessID string) (bool, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entities.UserSecrets{}).Where("access_id = ?", accessID).Count(&count).Error + return count > 0, err +} + +// UpdateLastUsedAt 更新最后使用时间 +func (r *GormUserSecretsRepository) UpdateLastUsedAt(ctx context.Context, accessID string) error { + return r.db.WithContext(ctx).Model(&entities.UserSecrets{}).Where("access_id = ?", accessID).Update("last_used_at", time.Now()).Error +} + +// DeactivateByUserID 停用用户密钥 +func (r *GormUserSecretsRepository) DeactivateByUserID(ctx context.Context, userID string) error { + return r.db.WithContext(ctx).Model(&entities.UserSecrets{}).Where("user_id = ?", userID).Update("is_active", false).Error +} + +// RegenerateAccessKey 重新生成访问密钥 +func (r *GormUserSecretsRepository) RegenerateAccessKey(ctx context.Context, userID string, accessID, accessKey string) error { + return r.db.WithContext(ctx).Model(&entities.UserSecrets{}).Where("user_id = ?", userID).Updates(map[string]interface{}{ + "access_id": accessID, + "access_key": accessKey, + "updated_at": time.Now(), + }).Error +} + +// GetExpiredSecrets 获取过期的密钥 +func (r *GormUserSecretsRepository) GetExpiredSecrets(ctx context.Context) ([]entities.UserSecrets, error) { + var secrets []entities.UserSecrets + err := r.db.WithContext(ctx).Where("expires_at IS NOT NULL AND expires_at < ?", time.Now()).Find(&secrets).Error + return secrets, err +} + +// DeleteExpiredSecrets 删除过期的密钥 +func (r *GormUserSecretsRepository) DeleteExpiredSecrets(ctx context.Context) error { + return r.db.WithContext(ctx).Where("expires_at IS NOT NULL AND expires_at < ?", time.Now()).Delete(&entities.UserSecrets{}).Error +} diff --git a/internal/domains/finance/routes/finance_routes.go b/internal/domains/finance/routes/finance_routes.go new file mode 100644 index 0000000..a4abe8a --- /dev/null +++ b/internal/domains/finance/routes/finance_routes.go @@ -0,0 +1,35 @@ +package routes + +import ( + "github.com/gin-gonic/gin" + + "tyapi-server/internal/domains/finance/handlers" +) + +// RegisterFinanceRoutes 注册财务路由 +func RegisterFinanceRoutes(router *gin.Engine, financeHandler *handlers.FinanceHandler) { + // 财务路由组 + financeGroup := router.Group("/api/finance") + { + // 钱包相关路由 + walletGroup := financeGroup.Group("/wallet") + { + walletGroup.POST("", financeHandler.CreateWallet) // 创建钱包 + walletGroup.GET("", financeHandler.GetWallet) // 获取钱包信息 + walletGroup.PUT("", financeHandler.UpdateWallet) // 更新钱包 + walletGroup.POST("/recharge", financeHandler.Recharge) // 充值 + walletGroup.POST("/withdraw", financeHandler.Withdraw) // 提现 + walletGroup.POST("/transaction", financeHandler.WalletTransaction) // 钱包交易 + walletGroup.GET("/stats", financeHandler.GetWalletStats) // 获取钱包统计 + } + + // 用户密钥相关路由 + secretsGroup := financeGroup.Group("/secrets") + { + secretsGroup.POST("", financeHandler.CreateUserSecrets) // 创建用户密钥 + secretsGroup.GET("", financeHandler.GetUserSecrets) // 获取用户密钥 + secretsGroup.POST("/regenerate", financeHandler.RegenerateAccessKey) // 重新生成访问密钥 + secretsGroup.POST("/deactivate", financeHandler.DeactivateUserSecrets) // 停用用户密钥 + } + } +} diff --git a/internal/domains/finance/services/finance_service.go b/internal/domains/finance/services/finance_service.go new file mode 100644 index 0000000..3f5338d --- /dev/null +++ b/internal/domains/finance/services/finance_service.go @@ -0,0 +1,470 @@ +package services + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/shopspring/decimal" + "go.uber.org/zap" + + "tyapi-server/internal/domains/finance/dto" + "tyapi-server/internal/domains/finance/entities" + "tyapi-server/internal/domains/finance/repositories" + "tyapi-server/internal/shared/interfaces" +) + +// FinanceService 财务服务 +type FinanceService struct { + walletRepo repositories.WalletRepository + userSecretsRepo repositories.UserSecretsRepository + responseBuilder interfaces.ResponseBuilder + logger *zap.Logger +} + +// NewFinanceService 创建财务服务 +func NewFinanceService( + walletRepo repositories.WalletRepository, + userSecretsRepo repositories.UserSecretsRepository, + responseBuilder interfaces.ResponseBuilder, + logger *zap.Logger, +) *FinanceService { + return &FinanceService{ + walletRepo: walletRepo, + userSecretsRepo: userSecretsRepo, + responseBuilder: responseBuilder, + logger: logger, + } +} + +// CreateWallet 创建钱包 +func (s *FinanceService) CreateWallet(ctx context.Context, req *dto.CreateWalletRequest) (*dto.CreateWalletResponse, error) { + s.logger.Info("创建钱包", zap.String("user_id", req.UserID)) + + // 检查用户是否已有钱包 + exists, err := s.walletRepo.ExistsByUserID(ctx, req.UserID) + if err != nil { + return nil, fmt.Errorf("检查钱包存在性失败: %w", err) + } + if exists { + return nil, fmt.Errorf("用户已存在钱包") + } + + // 创建钱包 + wallet := entities.Wallet{ + ID: s.generateID(), + UserID: req.UserID, + IsActive: true, + Balance: decimal.Zero, + } + + if err := s.walletRepo.Create(ctx, wallet); err != nil { + return nil, fmt.Errorf("创建钱包失败: %w", err) + } + + // 构建响应 + walletInfo := dto.WalletInfo{ + ID: wallet.ID, + UserID: wallet.UserID, + IsActive: wallet.IsActive, + Balance: wallet.Balance, + CreatedAt: wallet.CreatedAt, + UpdatedAt: wallet.UpdatedAt, + } + + s.logger.Info("钱包创建成功", zap.String("wallet_id", wallet.ID)) + return &dto.CreateWalletResponse{Wallet: walletInfo}, nil +} + +// GetWallet 获取钱包信息 +func (s *FinanceService) GetWallet(ctx context.Context, userID string) (*dto.WalletInfo, error) { + s.logger.Info("获取钱包信息", zap.String("user_id", userID)) + + wallet, err := s.walletRepo.FindByUserID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("钱包不存在") + } + + walletInfo := dto.WalletInfo{ + ID: wallet.ID, + UserID: wallet.UserID, + IsActive: wallet.IsActive, + Balance: wallet.Balance, + CreatedAt: wallet.CreatedAt, + UpdatedAt: wallet.UpdatedAt, + } + + return &walletInfo, nil +} + +// UpdateWallet 更新钱包 +func (s *FinanceService) UpdateWallet(ctx context.Context, req *dto.UpdateWalletRequest) error { + s.logger.Info("更新钱包", zap.String("user_id", req.UserID)) + + wallet, err := s.walletRepo.FindByUserID(ctx, req.UserID) + if err != nil { + return fmt.Errorf("钱包不存在") + } + + // 更新字段 + if !req.Balance.IsZero() { + wallet.Balance = req.Balance + } + if req.IsActive != nil { + wallet.IsActive = *req.IsActive + } + + if err := s.walletRepo.Update(ctx, *wallet); err != nil { + return fmt.Errorf("更新钱包失败: %w", err) + } + + s.logger.Info("钱包更新成功", zap.String("user_id", req.UserID)) + return nil +} + +// Recharge 充值 +func (s *FinanceService) Recharge(ctx context.Context, req *dto.RechargeRequest) (*dto.RechargeResponse, error) { + s.logger.Info("钱包充值", zap.String("user_id", req.UserID), zap.String("amount", req.Amount.String())) + + // 验证金额 + if req.Amount.LessThanOrEqual(decimal.Zero) { + return nil, fmt.Errorf("充值金额必须大于0") + } + + // 获取钱包 + wallet, err := s.walletRepo.FindByUserID(ctx, req.UserID) + if err != nil { + return nil, fmt.Errorf("钱包不存在") + } + + // 检查钱包状态 + if !wallet.IsActive { + return nil, fmt.Errorf("钱包已被禁用") + } + + // 增加余额 + if err := s.walletRepo.AddBalance(ctx, req.UserID, req.Amount); err != nil { + return nil, fmt.Errorf("充值失败: %w", err) + } + + // 获取更新后的余额 + updatedWallet, err := s.walletRepo.FindByUserID(ctx, req.UserID) + if err != nil { + return nil, fmt.Errorf("获取更新后余额失败: %w", err) + } + + s.logger.Info("充值成功", zap.String("user_id", req.UserID), zap.String("amount", req.Amount.String())) + return &dto.RechargeResponse{ + WalletID: updatedWallet.ID, + Amount: req.Amount, + Balance: updatedWallet.Balance, + }, nil +} + +// Withdraw 提现 +func (s *FinanceService) Withdraw(ctx context.Context, req *dto.WithdrawRequest) (*dto.WithdrawResponse, error) { + s.logger.Info("钱包提现", zap.String("user_id", req.UserID), zap.String("amount", req.Amount.String())) + + // 验证金额 + if req.Amount.LessThanOrEqual(decimal.Zero) { + return nil, fmt.Errorf("提现金额必须大于0") + } + + // 获取钱包 + wallet, err := s.walletRepo.FindByUserID(ctx, req.UserID) + if err != nil { + return nil, fmt.Errorf("钱包不存在") + } + + // 检查钱包状态 + if !wallet.IsActive { + return nil, fmt.Errorf("钱包已被禁用") + } + + // 检查余额是否足够 + if wallet.Balance.LessThan(req.Amount) { + return nil, fmt.Errorf("余额不足") + } + + // 减少余额 + if err := s.walletRepo.SubtractBalance(ctx, req.UserID, req.Amount); err != nil { + return nil, fmt.Errorf("提现失败: %w", err) + } + + // 获取更新后的余额 + updatedWallet, err := s.walletRepo.FindByUserID(ctx, req.UserID) + if err != nil { + return nil, fmt.Errorf("获取更新后余额失败: %w", err) + } + + s.logger.Info("提现成功", zap.String("user_id", req.UserID), zap.String("amount", req.Amount.String())) + return &dto.WithdrawResponse{ + WalletID: updatedWallet.ID, + Amount: req.Amount, + Balance: updatedWallet.Balance, + }, nil +} + +// CreateUserSecrets 创建用户密钥 +func (s *FinanceService) CreateUserSecrets(ctx context.Context, req *dto.CreateUserSecretsRequest) (*dto.CreateUserSecretsResponse, error) { + s.logger.Info("创建用户密钥", zap.String("user_id", req.UserID)) + + // 检查用户是否已有密钥 + exists, err := s.userSecretsRepo.ExistsByUserID(ctx, req.UserID) + if err != nil { + return nil, fmt.Errorf("检查密钥存在性失败: %w", err) + } + if exists { + return nil, fmt.Errorf("用户已存在密钥") + } + + // 生成访问ID和密钥 + accessID := s.generateAccessID() + accessKey := s.generateAccessKey() + + // 创建密钥 + secrets := entities.UserSecrets{ + ID: s.generateID(), + UserID: req.UserID, + AccessID: accessID, + AccessKey: accessKey, + IsActive: true, + ExpiresAt: req.ExpiresAt, + } + + if err := s.userSecretsRepo.Create(ctx, secrets); err != nil { + return nil, fmt.Errorf("创建密钥失败: %w", err) + } + + // 构建响应 + secretsInfo := dto.UserSecretsInfo{ + ID: secrets.ID, + UserID: secrets.UserID, + AccessID: secrets.AccessID, + AccessKey: secrets.AccessKey, + IsActive: secrets.IsActive, + LastUsedAt: secrets.LastUsedAt, + ExpiresAt: secrets.ExpiresAt, + CreatedAt: secrets.CreatedAt, + UpdatedAt: secrets.UpdatedAt, + } + + s.logger.Info("用户密钥创建成功", zap.String("user_id", req.UserID)) + return &dto.CreateUserSecretsResponse{Secrets: secretsInfo}, nil +} + +// GetUserSecrets 获取用户密钥 +func (s *FinanceService) GetUserSecrets(ctx context.Context, userID string) (*dto.UserSecretsInfo, error) { + s.logger.Info("获取用户密钥", zap.String("user_id", userID)) + + secrets, err := s.userSecretsRepo.FindByUserID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("密钥不存在") + } + + secretsInfo := dto.UserSecretsInfo{ + ID: secrets.ID, + UserID: secrets.UserID, + AccessID: secrets.AccessID, + AccessKey: secrets.AccessKey, + IsActive: secrets.IsActive, + LastUsedAt: secrets.LastUsedAt, + ExpiresAt: secrets.ExpiresAt, + CreatedAt: secrets.CreatedAt, + UpdatedAt: secrets.UpdatedAt, + } + + return &secretsInfo, nil +} + +// RegenerateAccessKey 重新生成访问密钥 +func (s *FinanceService) RegenerateAccessKey(ctx context.Context, req *dto.RegenerateAccessKeyRequest) (*dto.RegenerateAccessKeyResponse, error) { + s.logger.Info("重新生成访问密钥", zap.String("user_id", req.UserID)) + + // 检查密钥是否存在 + secrets, err := s.userSecretsRepo.FindByUserID(ctx, req.UserID) + if err != nil { + return nil, fmt.Errorf("密钥不存在") + } + + // 生成新的访问ID和密钥 + newAccessID := s.generateAccessID() + newAccessKey := s.generateAccessKey() + + // 更新密钥 + if err := s.userSecretsRepo.RegenerateAccessKey(ctx, req.UserID, newAccessID, newAccessKey); err != nil { + return nil, fmt.Errorf("重新生成密钥失败: %w", err) + } + + // 更新过期时间 + if req.ExpiresAt != nil { + secrets.ExpiresAt = req.ExpiresAt + if err := s.userSecretsRepo.Update(ctx, *secrets); err != nil { + s.logger.Error("更新密钥过期时间失败", zap.Error(err)) + } + } + + s.logger.Info("访问密钥重新生成成功", zap.String("user_id", req.UserID)) + return &dto.RegenerateAccessKeyResponse{ + AccessID: newAccessID, + AccessKey: newAccessKey, + }, nil +} + +// DeactivateUserSecrets 停用用户密钥 +func (s *FinanceService) DeactivateUserSecrets(ctx context.Context, req *dto.DeactivateUserSecretsRequest) error { + s.logger.Info("停用用户密钥", zap.String("user_id", req.UserID)) + + // 检查密钥是否存在 + if _, err := s.userSecretsRepo.FindByUserID(ctx, req.UserID); err != nil { + return fmt.Errorf("密钥不存在") + } + + // 停用密钥 + if err := s.userSecretsRepo.DeactivateByUserID(ctx, req.UserID); err != nil { + return fmt.Errorf("停用密钥失败: %w", err) + } + + s.logger.Info("用户密钥停用成功", zap.String("user_id", req.UserID)) + return nil +} + +// WalletTransaction 钱包交易 +func (s *FinanceService) WalletTransaction(ctx context.Context, req *dto.WalletTransactionRequest) (*dto.WalletTransactionResponse, error) { + s.logger.Info("钱包交易", + zap.String("from_user_id", req.FromUserID), + zap.String("to_user_id", req.ToUserID), + zap.String("amount", req.Amount.String())) + + // 验证金额 + if req.Amount.LessThanOrEqual(decimal.Zero) { + return nil, fmt.Errorf("交易金额必须大于0") + } + + // 验证用户不能给自己转账 + if req.FromUserID == req.ToUserID { + return nil, fmt.Errorf("不能给自己转账") + } + + // 获取转出钱包 + fromWallet, err := s.walletRepo.FindByUserID(ctx, req.FromUserID) + if err != nil { + return nil, fmt.Errorf("转出钱包不存在") + } + + // 获取转入钱包 + toWallet, err := s.walletRepo.FindByUserID(ctx, req.ToUserID) + if err != nil { + return nil, fmt.Errorf("转入钱包不存在") + } + + // 检查钱包状态 + if !fromWallet.IsActive { + return nil, fmt.Errorf("转出钱包已被禁用") + } + if !toWallet.IsActive { + return nil, fmt.Errorf("转入钱包已被禁用") + } + + // 检查余额是否足够 + if fromWallet.Balance.LessThan(req.Amount) { + return nil, fmt.Errorf("余额不足") + } + + // 执行交易(使用事务) + // 这里简化处理,实际应该使用数据库事务 + if err := s.walletRepo.SubtractBalance(ctx, req.FromUserID, req.Amount); err != nil { + return nil, fmt.Errorf("扣款失败: %w", err) + } + + if err := s.walletRepo.AddBalance(ctx, req.ToUserID, req.Amount); err != nil { + return nil, fmt.Errorf("入账失败: %w", err) + } + + // 获取更新后的余额 + updatedFromWallet, err := s.walletRepo.FindByUserID(ctx, req.FromUserID) + if err != nil { + return nil, fmt.Errorf("获取转出后余额失败: %w", err) + } + + updatedToWallet, err := s.walletRepo.FindByUserID(ctx, req.ToUserID) + if err != nil { + return nil, fmt.Errorf("获取转入后余额失败: %w", err) + } + + s.logger.Info("钱包交易成功", + zap.String("from_user_id", req.FromUserID), + zap.String("to_user_id", req.ToUserID), + zap.String("amount", req.Amount.String())) + + return &dto.WalletTransactionResponse{ + TransactionID: s.generateID(), + FromUserID: req.FromUserID, + ToUserID: req.ToUserID, + Amount: req.Amount, + FromBalance: updatedFromWallet.Balance, + ToBalance: updatedToWallet.Balance, + Notes: req.Notes, + CreatedAt: time.Now(), + }, nil +} + +// GetWalletStats 获取钱包统计 +func (s *FinanceService) GetWalletStats(ctx context.Context) (*dto.WalletStatsResponse, error) { + s.logger.Info("获取钱包统计") + + // 获取总钱包数 + totalWallets, err := s.walletRepo.Count(ctx, interfaces.CountOptions{}) + if err != nil { + return nil, fmt.Errorf("获取总钱包数失败: %w", err) + } + + // 获取激活钱包数 + activeWallets, err := s.walletRepo.GetActiveWalletCount(ctx) + if err != nil { + return nil, fmt.Errorf("获取激活钱包数失败: %w", err) + } + + // 获取总余额 + totalBalance, err := s.walletRepo.GetTotalBalance(ctx) + if err != nil { + return nil, fmt.Errorf("获取总余额失败: %w", err) + } + + // 这里简化处理,实际应该查询交易记录表 + todayTransactions := int64(0) + todayVolume := decimal.Zero + + return &dto.WalletStatsResponse{ + TotalWallets: totalWallets, + ActiveWallets: activeWallets, + TotalBalance: totalBalance.(decimal.Decimal), + TodayTransactions: todayTransactions, + TodayVolume: todayVolume, + }, nil +} + +// generateID 生成ID +func (s *FinanceService) generateID() string { + bytes := make([]byte, 16) + rand.Read(bytes) + return hex.EncodeToString(bytes) +} + +// generateAccessID 生成访问ID +func (s *FinanceService) generateAccessID() string { + bytes := make([]byte, 20) + rand.Read(bytes) + return hex.EncodeToString(bytes) +} + +// generateAccessKey 生成访问密钥 +func (s *FinanceService) generateAccessKey() string { + bytes := make([]byte, 32) + rand.Read(bytes) + hash := sha256.Sum256(bytes) + return hex.EncodeToString(hash[:]) +} diff --git a/internal/domains/user/dto/user_dto.go b/internal/domains/user/dto/user_dto.go index 36460dc..2a0c1af 100644 --- a/internal/domains/user/dto/user_dto.go +++ b/internal/domains/user/dto/user_dto.go @@ -34,6 +34,12 @@ type ChangePasswordRequest struct { Code string `json:"code" binding:"required,len=6" example:"123456"` } +// UpdateProfileRequest 更新用户信息请求 +type UpdateProfileRequest struct { + Phone string `json:"phone" binding:"omitempty,len=11" example:"13800138000"` + // 可以在这里添加更多用户信息字段,如昵称、头像等 +} + // UserResponse 用户响应 type UserResponse struct { ID string `json:"id" example:"123e4567-e89b-12d3-a456-426614174000"` diff --git a/internal/domains/user/entities/sms_code.go b/internal/domains/user/entities/sms_code.go index d28896b..8051538 100644 --- a/internal/domains/user/entities/sms_code.go +++ b/internal/domains/user/entities/sms_code.go @@ -6,50 +6,58 @@ import ( "gorm.io/gorm" ) -// SMSCode 短信验证码记录 +// SMSCode 短信验证码记录实体 +// 记录用户发送的所有短信验证码,支持多种使用场景 +// 包含验证码的有效期管理、使用状态跟踪、安全审计等功能 type SMSCode struct { - ID string `gorm:"primaryKey;type:varchar(36)" json:"id"` - Phone string `gorm:"type:varchar(20);not null;index" json:"phone"` - Code string `gorm:"type:varchar(10);not null" json:"-"` // 不返回给前端 - Scene SMSScene `gorm:"type:varchar(20);not null" json:"scene"` - Used bool `gorm:"default:false" json:"used"` - ExpiresAt time.Time `gorm:"not null" json:"expires_at"` - UsedAt *time.Time `json:"used_at,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + // 基础标识 + ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"短信验证码记录唯一标识"` + Phone string `gorm:"type:varchar(20);not null;index" json:"phone" comment:"接收手机号"` + Code string `gorm:"type:varchar(10);not null" json:"-" comment:"验证码内容(不返回给前端)"` + Scene SMSScene `gorm:"type:varchar(20);not null" json:"scene" comment:"使用场景"` + Used bool `gorm:"default:false" json:"used" comment:"是否已使用"` + ExpiresAt time.Time `gorm:"not null" json:"expires_at" comment:"过期时间"` + UsedAt *time.Time `json:"used_at,omitempty" comment:"使用时间"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" comment:"创建时间"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" comment:"更新时间"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-" comment:"软删除时间"` - // 额外信息 - IP string `gorm:"type:varchar(45)" json:"ip"` - UserAgent string `gorm:"type:varchar(500)" json:"user_agent"` + // 额外信息 - 安全审计相关数据 + IP string `gorm:"type:varchar(45)" json:"ip" comment:"发送IP地址"` + UserAgent string `gorm:"type:varchar(500)" json:"user_agent" comment:"客户端信息"` } -// SMSScene 短信验证码使用场景 +// SMSScene 短信验证码使用场景枚举 +// 定义系统中所有需要使用短信验证码的业务场景 type SMSScene string const ( - SMSSceneRegister SMSScene = "register" // 注册 - SMSSceneLogin SMSScene = "login" // 登录 - SMSSceneChangePassword SMSScene = "change_password" // 修改密码 - SMSSceneResetPassword SMSScene = "reset_password" // 重置密码 - SMSSceneBind SMSScene = "bind" // 绑定手机号 - SMSSceneUnbind SMSScene = "unbind" // 解绑手机号 + SMSSceneRegister SMSScene = "register" // 注册 - 新用户注册验证 + SMSSceneLogin SMSScene = "login" // 登录 - 手机号登录验证 + SMSSceneChangePassword SMSScene = "change_password" // 修改密码 - 修改密码验证 + SMSSceneResetPassword SMSScene = "reset_password" // 重置密码 - 忘记密码重置 + SMSSceneBind SMSScene = "bind" // 绑定手机号 - 绑定新手机号 + SMSSceneUnbind SMSScene = "unbind" // 解绑手机号 - 解绑当前手机号 ) -// 实现 Entity 接口 +// 实现 Entity 接口 - 提供统一的实体管理接口 +// GetID 获取实体唯一标识 func (s *SMSCode) GetID() string { return s.ID } +// GetCreatedAt 获取创建时间 func (s *SMSCode) GetCreatedAt() time.Time { return s.CreatedAt } +// GetUpdatedAt 获取更新时间 func (s *SMSCode) GetUpdatedAt() time.Time { return s.UpdatedAt } // Validate 验证短信验证码 +// 检查短信验证码记录的必填字段是否完整,确保数据的有效性 func (s *SMSCode) Validate() error { if s.Phone == "" { return &ValidationError{Message: "手机号不能为空"} @@ -64,24 +72,253 @@ func (s *SMSCode) Validate() error { return &ValidationError{Message: "过期时间不能为空"} } + // 验证手机号格式 + if !IsValidPhoneFormat(s.Phone) { + return &ValidationError{Message: "手机号格式无效"} + } + + // 验证验证码格式 + if err := s.validateCodeFormat(); err != nil { + return err + } + return nil } -// 业务方法 -func (s *SMSCode) IsExpired() bool { - return time.Now().After(s.ExpiresAt) +// ================ 业务方法 ================ + +// VerifyCode 验证验证码 +// 检查输入的验证码是否匹配且有效 +func (s *SMSCode) VerifyCode(inputCode string) error { + // 1. 检查验证码是否已使用 + if s.Used { + return &ValidationError{Message: "验证码已被使用"} + } + + // 2. 检查验证码是否已过期 + if s.IsExpired() { + return &ValidationError{Message: "验证码已过期"} + } + + // 3. 检查验证码是否匹配 + if s.Code != inputCode { + return &ValidationError{Message: "验证码错误"} + } + + // 4. 标记为已使用 + s.MarkAsUsed() + + return nil } +// IsExpired 检查验证码是否已过期 +// 判断当前时间是否超过验证码的有效期 +func (s *SMSCode) IsExpired() bool { + return time.Now().After(s.ExpiresAt) || time.Now().Equal(s.ExpiresAt) +} + +// IsValid 检查验证码是否有效 +// 综合判断验证码是否可用,包括未使用和未过期两个条件 func (s *SMSCode) IsValid() bool { return !s.Used && !s.IsExpired() } +// MarkAsUsed 标记验证码为已使用 +// 在验证码被成功使用后调用,记录使用时间并标记状态 func (s *SMSCode) MarkAsUsed() { s.Used = true now := time.Now() s.UsedAt = &now } +// CanResend 检查是否可以重新发送验证码 +// 基于时间间隔和场景判断是否允许重新发送 +func (s *SMSCode) CanResend(minInterval time.Duration) bool { + // 如果验证码已使用或已过期,可以重新发送 + if s.Used || s.IsExpired() { + return true + } + + // 检查距离上次发送的时间间隔 + timeSinceCreated := time.Since(s.CreatedAt) + return timeSinceCreated >= minInterval +} + +// GetRemainingTime 获取验证码剩余有效时间 +func (s *SMSCode) GetRemainingTime() time.Duration { + if s.IsExpired() { + return 0 + } + return s.ExpiresAt.Sub(time.Now()) +} + +// IsRecentlySent 检查是否最近发送过验证码 +func (s *SMSCode) IsRecentlySent(within time.Duration) bool { + return time.Since(s.CreatedAt) < within +} + +// GetMaskedCode 获取脱敏的验证码(用于日志记录) +func (s *SMSCode) GetMaskedCode() string { + if len(s.Code) < 3 { + return "***" + } + return s.Code[:1] + "***" + s.Code[len(s.Code)-1:] +} + +// GetMaskedPhone 获取脱敏的手机号 +func (s *SMSCode) GetMaskedPhone() string { + if len(s.Phone) < 7 { + return s.Phone + } + return s.Phone[:3] + "****" + s.Phone[len(s.Phone)-4:] +} + +// ================ 场景相关方法 ================ + +// IsSceneValid 检查场景是否有效 +func (s *SMSCode) IsSceneValid() bool { + validScenes := []SMSScene{ + SMSSceneRegister, + SMSSceneLogin, + SMSSceneChangePassword, + SMSSceneResetPassword, + SMSSceneBind, + SMSSceneUnbind, + } + + for _, scene := range validScenes { + if s.Scene == scene { + return true + } + } + return false +} + +// GetSceneName 获取场景的中文名称 +func (s *SMSCode) GetSceneName() string { + sceneNames := map[SMSScene]string{ + SMSSceneRegister: "用户注册", + SMSSceneLogin: "用户登录", + SMSSceneChangePassword: "修改密码", + SMSSceneResetPassword: "重置密码", + SMSSceneBind: "绑定手机号", + SMSSceneUnbind: "解绑手机号", + } + + if name, exists := sceneNames[s.Scene]; exists { + return name + } + return string(s.Scene) +} + +// ================ 安全相关方法 ================ + +// IsSuspicious 检查是否存在可疑行为 +func (s *SMSCode) IsSuspicious() bool { + // 检查IP地址是否为空(可能表示异常) + if s.IP == "" { + return true + } + + // 检查UserAgent是否为空(可能表示异常) + if s.UserAgent == "" { + return true + } + + // 可以添加更多安全检查逻辑 + // 例如:检查IP是否来自异常地区、UserAgent是否异常等 + + return false +} + +// GetSecurityInfo 获取安全信息摘要 +func (s *SMSCode) GetSecurityInfo() map[string]interface{} { + return map[string]interface{}{ + "ip": s.IP, + "user_agent": s.UserAgent, + "suspicious": s.IsSuspicious(), + "scene": s.GetSceneName(), + "created_at": s.CreatedAt, + } +} + +// ================ 私有辅助方法 ================ + +// validateCodeFormat 验证验证码格式 +func (s *SMSCode) validateCodeFormat() error { + // 检查验证码长度 + if len(s.Code) < 4 || len(s.Code) > 10 { + return &ValidationError{Message: "验证码长度必须在4-10位之间"} + } + + // 检查验证码是否只包含数字 + for _, char := range s.Code { + if char < '0' || char > '9' { + return &ValidationError{Message: "验证码只能包含数字"} + } + } + + return nil +} + +// ================ 静态工具方法 ================ + +// IsValidScene 检查场景是否有效(静态方法) +func IsValidScene(scene SMSScene) bool { + validScenes := []SMSScene{ + SMSSceneRegister, + SMSSceneLogin, + SMSSceneChangePassword, + SMSSceneResetPassword, + SMSSceneBind, + SMSSceneUnbind, + } + + for _, validScene := range validScenes { + if scene == validScene { + return true + } + } + return false +} + +// GetSceneName 获取场景的中文名称(静态方法) +func GetSceneName(scene SMSScene) string { + sceneNames := map[SMSScene]string{ + SMSSceneRegister: "用户注册", + SMSSceneLogin: "用户登录", + SMSSceneChangePassword: "修改密码", + SMSSceneResetPassword: "重置密码", + SMSSceneBind: "绑定手机号", + SMSSceneUnbind: "解绑手机号", + } + + if name, exists := sceneNames[scene]; exists { + return name + } + return string(scene) +} + +// NewSMSCode 创建新的短信验证码(工厂方法) +func NewSMSCode(phone, code string, scene SMSScene, expireTime time.Duration, clientIP, userAgent string) (*SMSCode, error) { + smsCode := &SMSCode{ + Phone: phone, + Code: code, + Scene: scene, + Used: false, + ExpiresAt: time.Now().Add(expireTime), + IP: clientIP, + UserAgent: userAgent, + } + + // 验证实体 + if err := smsCode.Validate(); err != nil { + return nil, err + } + + return smsCode, nil +} + // TableName 指定表名 func (SMSCode) TableName() string { return "sms_codes" diff --git a/internal/domains/user/entities/sms_code_test.go b/internal/domains/user/entities/sms_code_test.go new file mode 100644 index 0000000..93530a8 --- /dev/null +++ b/internal/domains/user/entities/sms_code_test.go @@ -0,0 +1,681 @@ +package entities + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestSMSCode_Validate(t *testing.T) { + tests := []struct { + name string + smsCode *SMSCode + wantErr bool + }{ + { + name: "有效验证码", + smsCode: &SMSCode{ + Phone: "13800138000", + Code: "123456", + Scene: SMSSceneRegister, + ExpiresAt: time.Now().Add(time.Hour), + }, + wantErr: false, + }, + { + name: "手机号为空", + smsCode: &SMSCode{ + Phone: "", + Code: "123456", + Scene: SMSSceneRegister, + ExpiresAt: time.Now().Add(time.Hour), + }, + wantErr: true, + }, + { + name: "验证码为空", + smsCode: &SMSCode{ + Phone: "13800138000", + Code: "", + Scene: SMSSceneRegister, + ExpiresAt: time.Now().Add(time.Hour), + }, + wantErr: true, + }, + { + name: "场景为空", + smsCode: &SMSCode{ + Phone: "13800138000", + Code: "123456", + Scene: "", + ExpiresAt: time.Now().Add(time.Hour), + }, + wantErr: true, + }, + { + name: "过期时间为零", + smsCode: &SMSCode{ + Phone: "13800138000", + Code: "123456", + Scene: SMSSceneRegister, + ExpiresAt: time.Time{}, + }, + wantErr: true, + }, + { + name: "手机号格式无效", + smsCode: &SMSCode{ + Phone: "123", + Code: "123456", + Scene: SMSSceneRegister, + ExpiresAt: time.Now().Add(time.Hour), + }, + wantErr: true, + }, + { + name: "验证码格式无效-包含字母", + smsCode: &SMSCode{ + Phone: "13800138000", + Code: "12345a", + Scene: SMSSceneRegister, + ExpiresAt: time.Now().Add(time.Hour), + }, + wantErr: true, + }, + { + name: "验证码长度过短", + smsCode: &SMSCode{ + Phone: "13800138000", + Code: "123", + Scene: SMSSceneRegister, + ExpiresAt: time.Now().Add(time.Hour), + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.smsCode.Validate() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestSMSCode_VerifyCode(t *testing.T) { + now := time.Now() + expiresAt := now.Add(time.Hour) + + tests := []struct { + name string + smsCode *SMSCode + inputCode string + wantErr bool + }{ + { + name: "验证码正确", + smsCode: &SMSCode{ + Code: "123456", + Used: false, + ExpiresAt: expiresAt, + }, + inputCode: "123456", + wantErr: false, + }, + { + name: "验证码错误", + smsCode: &SMSCode{ + Code: "123456", + Used: false, + ExpiresAt: expiresAt, + }, + inputCode: "654321", + wantErr: true, + }, + { + name: "验证码已使用", + smsCode: &SMSCode{ + Code: "123456", + Used: true, + ExpiresAt: expiresAt, + }, + inputCode: "123456", + wantErr: true, + }, + { + name: "验证码已过期", + smsCode: &SMSCode{ + Code: "123456", + Used: false, + ExpiresAt: now.Add(-time.Hour), + }, + inputCode: "123456", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.smsCode.VerifyCode(tt.inputCode) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + // 验证码正确时应该被标记为已使用 + assert.True(t, tt.smsCode.Used) + assert.NotNil(t, tt.smsCode.UsedAt) + } + }) + } +} + +func TestSMSCode_IsExpired(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + smsCode *SMSCode + expected bool + }{ + { + name: "未过期", + smsCode: &SMSCode{ + ExpiresAt: now.Add(time.Hour), + }, + expected: false, + }, + { + name: "已过期", + smsCode: &SMSCode{ + ExpiresAt: now.Add(-time.Hour), + }, + expected: true, + }, + { + name: "刚好过期", + smsCode: &SMSCode{ + ExpiresAt: now, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.smsCode.IsExpired() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSMSCode_IsValid(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + smsCode *SMSCode + expected bool + }{ + { + name: "有效验证码", + smsCode: &SMSCode{ + Used: false, + ExpiresAt: now.Add(time.Hour), + }, + expected: true, + }, + { + name: "已使用", + smsCode: &SMSCode{ + Used: true, + ExpiresAt: now.Add(time.Hour), + }, + expected: false, + }, + { + name: "已过期", + smsCode: &SMSCode{ + Used: false, + ExpiresAt: now.Add(-time.Hour), + }, + expected: false, + }, + { + name: "已使用且已过期", + smsCode: &SMSCode{ + Used: true, + ExpiresAt: now.Add(-time.Hour), + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.smsCode.IsValid() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSMSCode_CanResend(t *testing.T) { + now := time.Now() + minInterval := 60 * time.Second + + tests := []struct { + name string + smsCode *SMSCode + expected bool + }{ + { + name: "已使用-可以重发", + smsCode: &SMSCode{ + Used: true, + CreatedAt: now.Add(-30 * time.Second), + }, + expected: true, + }, + { + name: "已过期-可以重发", + smsCode: &SMSCode{ + Used: false, + ExpiresAt: now.Add(-time.Hour), + CreatedAt: now.Add(-30 * time.Second), + }, + expected: true, + }, + { + name: "未过期且未使用-间隔足够-可以重发", + smsCode: &SMSCode{ + Used: false, + ExpiresAt: now.Add(time.Hour), + CreatedAt: now.Add(-2 * time.Minute), + }, + expected: true, + }, + { + name: "未过期且未使用-间隔不足-不能重发", + smsCode: &SMSCode{ + Used: false, + ExpiresAt: now.Add(time.Hour), + CreatedAt: now.Add(-30 * time.Second), + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.smsCode.CanResend(minInterval) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSMSCode_GetRemainingTime(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + smsCode *SMSCode + expected time.Duration + }{ + { + name: "未过期", + smsCode: &SMSCode{ + ExpiresAt: now.Add(time.Hour), + }, + expected: time.Hour, + }, + { + name: "已过期", + smsCode: &SMSCode{ + ExpiresAt: now.Add(-time.Hour), + }, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.smsCode.GetRemainingTime() + // 由于时间计算可能有微小差异,我们检查是否在合理范围内 + if tt.expected > 0 { + assert.True(t, result > 0) + assert.True(t, result <= tt.expected) + } else { + assert.Equal(t, tt.expected, result) + } + }) + } +} + +func TestSMSCode_GetMaskedCode(t *testing.T) { + tests := []struct { + name string + code string + expected string + }{ + { + name: "6位验证码", + code: "123456", + expected: "1***6", + }, + { + name: "4位验证码", + code: "1234", + expected: "1***4", + }, + { + name: "短验证码", + code: "12", + expected: "***", + }, + { + name: "单字符", + code: "1", + expected: "***", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + smsCode := &SMSCode{Code: tt.code} + result := smsCode.GetMaskedCode() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSMSCode_GetMaskedPhone(t *testing.T) { + tests := []struct { + name string + phone string + expected string + }{ + { + name: "标准手机号", + phone: "13800138000", + expected: "138****8000", + }, + { + name: "短手机号", + phone: "138001", + expected: "138001", + }, + { + name: "空手机号", + phone: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + smsCode := &SMSCode{Phone: tt.phone} + result := smsCode.GetMaskedPhone() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSMSCode_IsSceneValid(t *testing.T) { + tests := []struct { + name string + scene SMSScene + expected bool + }{ + { + name: "注册场景", + scene: SMSSceneRegister, + expected: true, + }, + { + name: "登录场景", + scene: SMSSceneLogin, + expected: true, + }, + { + name: "无效场景", + scene: "invalid", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + smsCode := &SMSCode{Scene: tt.scene} + result := smsCode.IsSceneValid() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSMSCode_GetSceneName(t *testing.T) { + tests := []struct { + name string + scene SMSScene + expected string + }{ + { + name: "注册场景", + scene: SMSSceneRegister, + expected: "用户注册", + }, + { + name: "登录场景", + scene: SMSSceneLogin, + expected: "用户登录", + }, + { + name: "无效场景", + scene: "invalid", + expected: "invalid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + smsCode := &SMSCode{Scene: tt.scene} + result := smsCode.GetSceneName() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSMSCode_IsSuspicious(t *testing.T) { + tests := []struct { + name string + smsCode *SMSCode + expected bool + }{ + { + name: "正常记录", + smsCode: &SMSCode{ + IP: "192.168.1.1", + UserAgent: "Mozilla/5.0", + }, + expected: false, + }, + { + name: "IP为空-可疑", + smsCode: &SMSCode{ + IP: "", + UserAgent: "Mozilla/5.0", + }, + expected: true, + }, + { + name: "UserAgent为空-可疑", + smsCode: &SMSCode{ + IP: "192.168.1.1", + UserAgent: "", + }, + expected: true, + }, + { + name: "IP和UserAgent都为空-可疑", + smsCode: &SMSCode{ + IP: "", + UserAgent: "", + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.smsCode.IsSuspicious() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSMSCode_GetSecurityInfo(t *testing.T) { + now := time.Now() + smsCode := &SMSCode{ + IP: "192.168.1.1", + UserAgent: "Mozilla/5.0", + Scene: SMSSceneRegister, + CreatedAt: now, + } + + securityInfo := smsCode.GetSecurityInfo() + + assert.Equal(t, "192.168.1.1", securityInfo["ip"]) + assert.Equal(t, "Mozilla/5.0", securityInfo["user_agent"]) + assert.Equal(t, false, securityInfo["suspicious"]) + assert.Equal(t, "用户注册", securityInfo["scene"]) + assert.Equal(t, now, securityInfo["created_at"]) +} + +func TestIsValidScene(t *testing.T) { + tests := []struct { + name string + scene SMSScene + expected bool + }{ + { + name: "注册场景", + scene: SMSSceneRegister, + expected: true, + }, + { + name: "登录场景", + scene: SMSSceneLogin, + expected: true, + }, + { + name: "无效场景", + scene: "invalid", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsValidScene(tt.scene) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetSceneName(t *testing.T) { + tests := []struct { + name string + scene SMSScene + expected string + }{ + { + name: "注册场景", + scene: SMSSceneRegister, + expected: "用户注册", + }, + { + name: "登录场景", + scene: SMSSceneLogin, + expected: "用户登录", + }, + { + name: "无效场景", + scene: "invalid", + expected: "invalid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetSceneName(tt.scene) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestNewSMSCode(t *testing.T) { + tests := []struct { + name string + phone string + code string + scene SMSScene + expireTime time.Duration + clientIP string + userAgent string + expectError bool + }{ + { + name: "有效参数", + phone: "13800138000", + code: "123456", + scene: SMSSceneRegister, + expireTime: time.Hour, + clientIP: "192.168.1.1", + userAgent: "Mozilla/5.0", + expectError: false, + }, + { + name: "无效手机号", + phone: "123", + code: "123456", + scene: SMSSceneRegister, + expireTime: time.Hour, + clientIP: "192.168.1.1", + userAgent: "Mozilla/5.0", + expectError: true, + }, + { + name: "无效验证码", + phone: "13800138000", + code: "123", + scene: SMSSceneRegister, + expireTime: time.Hour, + clientIP: "192.168.1.1", + userAgent: "Mozilla/5.0", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + smsCode, err := NewSMSCode(tt.phone, tt.code, tt.scene, tt.expireTime, tt.clientIP, tt.userAgent) + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, smsCode) + } else { + assert.NoError(t, err) + assert.NotNil(t, smsCode) + assert.Equal(t, tt.phone, smsCode.Phone) + assert.Equal(t, tt.code, smsCode.Code) + assert.Equal(t, tt.scene, smsCode.Scene) + assert.Equal(t, tt.clientIP, smsCode.IP) + assert.Equal(t, tt.userAgent, smsCode.UserAgent) + assert.False(t, smsCode.Used) + assert.True(t, smsCode.ExpiresAt.After(time.Now())) + } + }) + } +} diff --git a/internal/domains/user/entities/user.go b/internal/domains/user/entities/user.go index 4a25437..4a4976e 100644 --- a/internal/domains/user/entities/user.go +++ b/internal/domains/user/entities/user.go @@ -1,35 +1,48 @@ package entities import ( + "errors" + "fmt" + "regexp" "time" + "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) // User 用户实体 +// 系统用户的核心信息,提供基础的账户管理功能 +// 支持手机号登录,密码加密存储,实现Entity接口便于统一管理 type User struct { - ID string `gorm:"primaryKey;type:varchar(36)" json:"id"` - Phone string `gorm:"uniqueIndex;type:varchar(20);not null" json:"phone"` - Password string `gorm:"type:varchar(255);not null" json:"-"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + // 基础标识 + ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"用户唯一标识"` + Phone string `gorm:"uniqueIndex;type:varchar(20);not null" json:"phone" comment:"手机号码(登录账号)"` + Password string `gorm:"type:varchar(255);not null" json:"-" comment:"登录密码(加密存储,不返回前端)"` + + // 时间戳字段 + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" comment:"创建时间"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" comment:"更新时间"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-" comment:"软删除时间"` } -// 实现 Entity 接口 +// 实现 Entity 接口 - 提供统一的实体管理接口 +// GetID 获取实体唯一标识 func (u *User) GetID() string { return u.ID } +// GetCreatedAt 获取创建时间 func (u *User) GetCreatedAt() time.Time { return u.CreatedAt } +// GetUpdatedAt 获取更新时间 func (u *User) GetUpdatedAt() time.Time { return u.UpdatedAt } -// 验证方法 +// Validate 验证用户信息 +// 检查用户必填字段是否完整,确保数据的有效性 func (u *User) Validate() error { if u.Phone == "" { return NewValidationError("手机号不能为空") @@ -37,23 +50,226 @@ func (u *User) Validate() error { if u.Password == "" { return NewValidationError("密码不能为空") } + + // 验证手机号格式 + if !u.IsValidPhone() { + return NewValidationError("手机号格式无效") + } + + // 验证密码强度 + if err := u.validatePasswordStrength(u.Password); err != nil { + return err + } + return nil } +// ================ 业务方法 ================ + +// ChangePassword 修改密码 +// 验证旧密码,检查新密码强度,更新密码 +func (u *User) ChangePassword(oldPassword, newPassword, confirmPassword string) error { + // 1. 验证确认密码 + if newPassword != confirmPassword { + return NewValidationError("新密码和确认新密码不匹配") + } + + // 2. 验证旧密码 + if !u.CheckPassword(oldPassword) { + return NewValidationError("当前密码错误") + } + + // 3. 验证新密码强度 + if err := u.validatePasswordStrength(newPassword); err != nil { + return err + } + + // 4. 检查新密码不能与旧密码相同 + if u.CheckPassword(newPassword) { + return NewValidationError("新密码不能与当前密码相同") + } + + // 5. 更新密码 + hashedPassword, err := u.hashPassword(newPassword) + if err != nil { + return fmt.Errorf("密码加密失败: %w", err) + } + u.Password = hashedPassword + + return nil +} + +// CheckPassword 验证密码是否正确 +func (u *User) CheckPassword(password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) + return err == nil +} + +// SetPassword 设置密码(用于注册或重置密码) +func (u *User) SetPassword(password string) error { + // 验证密码强度 + if err := u.validatePasswordStrength(password); err != nil { + return err + } + + // 加密密码 + hashedPassword, err := u.hashPassword(password) + if err != nil { + return fmt.Errorf("密码加密失败: %w", err) + } + + u.Password = hashedPassword + return nil +} + +// CanLogin 检查用户是否可以登录 +func (u *User) CanLogin() bool { + // 检查用户是否被删除 + if !u.DeletedAt.Time.IsZero() { + return false + } + + // 检查必要字段是否存在 + if u.Phone == "" || u.Password == "" { + return false + } + + return true +} + +// IsActive 检查用户是否处于活跃状态 +func (u *User) IsActive() bool { + return u.DeletedAt.Time.IsZero() +} + +// IsDeleted 检查用户是否已被删除 +func (u *User) IsDeleted() bool { + return !u.DeletedAt.Time.IsZero() +} + +// ================ 手机号相关方法 ================ + +// IsValidPhone 验证手机号格式 +func (u *User) IsValidPhone() bool { + return IsValidPhoneFormat(u.Phone) +} + +// SetPhone 设置手机号 +func (u *User) SetPhone(phone string) error { + if !IsValidPhoneFormat(phone) { + return NewValidationError("手机号格式无效") + } + u.Phone = phone + return nil +} + +// GetMaskedPhone 获取脱敏的手机号 +func (u *User) GetMaskedPhone() string { + if len(u.Phone) < 7 { + return u.Phone + } + return u.Phone[:3] + "****" + u.Phone[len(u.Phone)-4:] +} + +// ================ 私有辅助方法 ================ + +// hashPassword 加密密码 +func (u *User) hashPassword(password string) (string, error) { + hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hashedBytes), nil +} + +// validatePasswordStrength 验证密码强度 +func (u *User) validatePasswordStrength(password string) error { + if len(password) < 8 { + return NewValidationError("密码长度至少8位") + } + + if len(password) > 128 { + return NewValidationError("密码长度不能超过128位") + } + + // 检查是否包含数字 + hasDigit := regexp.MustCompile(`[0-9]`).MatchString(password) + if !hasDigit { + return NewValidationError("密码必须包含数字") + } + + // 检查是否包含字母 + hasLetter := regexp.MustCompile(`[a-zA-Z]`).MatchString(password) + if !hasLetter { + return NewValidationError("密码必须包含字母") + } + + // 检查是否包含特殊字符(可选,可以根据需求调整) + hasSpecial := regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password) + if !hasSpecial { + return NewValidationError("密码必须包含特殊字符") + } + + return nil +} + +// ================ 静态工具方法 ================ + +// IsValidPhoneFormat 验证手机号格式(静态方法) +func IsValidPhoneFormat(phone string) bool { + if phone == "" { + return false + } + // 中国手机号验证(11位数字,以1开头) + pattern := `^1[3-9]\d{9}$` + matched, _ := regexp.MatchString(pattern, phone) + return matched +} + +// NewUser 创建新用户(工厂方法) +func NewUser(phone, password string) (*User, error) { + user := &User{ + ID: "", // 由数据库或调用方设置 + Phone: phone, + } + + // 验证手机号 + if err := user.SetPhone(phone); err != nil { + return nil, err + } + + // 设置密码 + if err := user.SetPassword(password); err != nil { + return nil, err + } + + return user, nil +} + // TableName 指定表名 func (User) TableName() string { return "users" } // ValidationError 验证错误 +// 自定义验证错误类型,提供结构化的错误信息 type ValidationError struct { Message string } +// Error 实现error接口 func (e *ValidationError) Error() string { return e.Message } +// NewValidationError 创建新的验证错误 +// 工厂方法,用于创建验证错误实例 func NewValidationError(message string) *ValidationError { return &ValidationError{Message: message} } + +// IsValidationError 检查是否为验证错误 +func IsValidationError(err error) bool { + var validationErr *ValidationError + return errors.As(err, &validationErr) +} diff --git a/internal/domains/user/entities/user_test.go b/internal/domains/user/entities/user_test.go new file mode 100644 index 0000000..127265f --- /dev/null +++ b/internal/domains/user/entities/user_test.go @@ -0,0 +1,338 @@ +package entities + +import ( + "testing" +) + +func TestUser_ChangePassword(t *testing.T) { + // 创建测试用户 + user, err := NewUser("13800138000", "OldPassword123!") + if err != nil { + t.Fatalf("创建用户失败: %v", err) + } + + tests := []struct { + name string + oldPassword string + newPassword string + confirmPassword string + wantErr bool + errorContains string + }{ + { + name: "正常修改密码", + oldPassword: "OldPassword123!", + newPassword: "NewPassword123!", + confirmPassword: "NewPassword123!", + wantErr: false, + }, + { + name: "旧密码错误", + oldPassword: "WrongPassword123!", + newPassword: "NewPassword123!", + confirmPassword: "NewPassword123!", + wantErr: true, + errorContains: "当前密码错误", + }, + { + name: "确认密码不匹配", + oldPassword: "OldPassword123!", + newPassword: "NewPassword123!", + confirmPassword: "DifferentPassword123!", + wantErr: true, + errorContains: "新密码和确认新密码不匹配", + }, + { + name: "新密码与旧密码相同", + oldPassword: "OldPassword123!", + newPassword: "OldPassword123!", + confirmPassword: "OldPassword123!", + wantErr: true, + errorContains: "新密码不能与当前密码相同", + }, + { + name: "新密码强度不足", + oldPassword: "OldPassword123!", + newPassword: "weak", + confirmPassword: "weak", + wantErr: true, + errorContains: "密码长度至少8位", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 重置用户密码为初始状态 + user.SetPassword("OldPassword123!") + + err := user.ChangePassword(tt.oldPassword, tt.newPassword, tt.confirmPassword) + if tt.wantErr { + if err == nil { + t.Errorf("期望错误但没有得到错误") + return + } + if tt.errorContains != "" && !contains(err.Error(), tt.errorContains) { + t.Errorf("错误信息不包含期望的内容,期望包含: %s, 实际: %s", tt.errorContains, err.Error()) + } + } else { + if err != nil { + t.Errorf("不期望错误但得到了错误: %v", err) + } + // 验证密码确实被修改了 + if !user.CheckPassword(tt.newPassword) { + t.Errorf("密码修改后验证失败") + } + } + }) + } +} + +func TestUser_CheckPassword(t *testing.T) { + user, err := NewUser("13800138000", "TestPassword123!") + if err != nil { + t.Fatalf("创建用户失败: %v", err) + } + + tests := []struct { + name string + password string + want bool + }{ + { + name: "正确密码", + password: "TestPassword123!", + want: true, + }, + { + name: "错误密码", + password: "WrongPassword123!", + want: false, + }, + { + name: "空密码", + password: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := user.CheckPassword(tt.password) + if got != tt.want { + t.Errorf("CheckPassword() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestUser_SetPhone(t *testing.T) { + user := &User{} + + tests := []struct { + name string + phone string + wantErr bool + }{ + { + name: "有效手机号", + phone: "13800138000", + wantErr: false, + }, + { + name: "无效手机号-太短", + phone: "1380013800", + wantErr: true, + }, + { + name: "无效手机号-太长", + phone: "138001380000", + wantErr: true, + }, + { + name: "无效手机号-格式错误", + phone: "1380013800a", + wantErr: true, + }, + { + name: "无效手机号-不以1开头", + phone: "23800138000", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := user.SetPhone(tt.phone) + if tt.wantErr { + if err == nil { + t.Errorf("期望错误但没有得到错误") + } + } else { + if err != nil { + t.Errorf("不期望错误但得到了错误: %v", err) + } + if user.Phone != tt.phone { + t.Errorf("手机号设置失败,期望: %s, 实际: %s", tt.phone, user.Phone) + } + } + }) + } +} + +func TestUser_GetMaskedPhone(t *testing.T) { + tests := []struct { + name string + phone string + expected string + }{ + { + name: "正常手机号", + phone: "13800138000", + expected: "138****8000", + }, + { + name: "短手机号", + phone: "138001", + expected: "138001", + }, + { + name: "空手机号", + phone: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + user := &User{Phone: tt.phone} + got := user.GetMaskedPhone() + if got != tt.expected { + t.Errorf("GetMaskedPhone() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestIsValidPhoneFormat(t *testing.T) { + tests := []struct { + name string + phone string + want bool + }{ + { + name: "有效手机号-13开头", + phone: "13800138000", + want: true, + }, + { + name: "有效手机号-15开头", + phone: "15800138000", + want: true, + }, + { + name: "有效手机号-18开头", + phone: "18800138000", + want: true, + }, + { + name: "无效手机号-12开头", + phone: "12800138000", + want: false, + }, + { + name: "无效手机号-20开头", + phone: "20800138000", + want: false, + }, + { + name: "无效手机号-太短", + phone: "1380013800", + want: false, + }, + { + name: "无效手机号-太长", + phone: "138001380000", + want: false, + }, + { + name: "无效手机号-包含字母", + phone: "1380013800a", + want: false, + }, + { + name: "空手机号", + phone: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsValidPhoneFormat(tt.phone) + if got != tt.want { + t.Errorf("IsValidPhoneFormat() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNewUser(t *testing.T) { + tests := []struct { + name string + phone string + password string + wantErr bool + }{ + { + name: "有效用户信息", + phone: "13800138000", + password: "TestPassword123!", + wantErr: false, + }, + { + name: "无效手机号", + phone: "1380013800", + password: "TestPassword123!", + wantErr: true, + }, + { + name: "无效密码", + phone: "13800138000", + password: "weak", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + user, err := NewUser(tt.phone, tt.password) + if tt.wantErr { + if err == nil { + t.Errorf("期望错误但没有得到错误") + } + } else { + if err != nil { + t.Errorf("不期望错误但得到了错误: %v", err) + } + if user.Phone != tt.phone { + t.Errorf("手机号设置失败,期望: %s, 实际: %s", tt.phone, user.Phone) + } + if !user.CheckPassword(tt.password) { + t.Errorf("密码设置失败") + } + } + }) + } +} + +// 辅助函数 +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || (len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || func() bool { + for i := 1; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false + }()))) +} diff --git a/internal/domains/user/repositories/sms_code_repository.go b/internal/domains/user/repositories/sms_code_repository.go index f23b20d..dc52f55 100644 --- a/internal/domains/user/repositories/sms_code_repository.go +++ b/internal/domains/user/repositories/sms_code_repository.go @@ -81,6 +81,34 @@ func (r *SMSCodeRepository) MarkAsUsed(ctx context.Context, id string) error { return nil } +// Update 更新验证码记录 +func (r *SMSCodeRepository) Update(ctx context.Context, smsCode *entities.SMSCode) error { + if err := r.db.WithContext(ctx).Save(smsCode).Error; err != nil { + r.logger.Error("更新验证码记录失败", zap.Error(err)) + return err + } + + // 更新缓存 + cacheKey := r.buildCacheKey(smsCode.Phone, smsCode.Scene) + r.cache.Set(ctx, cacheKey, smsCode, 5*time.Minute) + + r.logger.Info("验证码记录更新成功", zap.String("code_id", smsCode.ID)) + return nil +} + +// GetRecentCode 获取最近的验证码记录(不限制有效性) +func (r *SMSCodeRepository) GetRecentCode(ctx context.Context, phone string, scene entities.SMSScene) (*entities.SMSCode, error) { + var smsCode entities.SMSCode + if err := r.db.WithContext(ctx). + Where("phone = ? AND scene = ?", phone, scene). + Order("created_at DESC"). + First(&smsCode).Error; err != nil { + return nil, err + } + + return &smsCode, nil +} + // CleanupExpired 清理过期的验证码 func (r *SMSCodeRepository) CleanupExpired(ctx context.Context) error { result := r.db.WithContext(ctx). diff --git a/internal/domains/user/repositories/user_repository.go b/internal/domains/user/repositories/user_repository.go index 2fd7374..0bed853 100644 --- a/internal/domains/user/repositories/user_repository.go +++ b/internal/domains/user/repositories/user_repository.go @@ -133,6 +133,41 @@ func (r *UserRepository) Delete(ctx context.Context, id string) error { return nil } +// SoftDelete 软删除用户 +func (r *UserRepository) SoftDelete(ctx context.Context, id string) error { + // 先获取用户信息用于清除缓存 + user, err := r.GetByID(ctx, id) + if err != nil { + return err + } + + if err := r.db.WithContext(ctx).Delete(&entities.User{}, "id = ?", id).Error; err != nil { + r.logger.Error("软删除用户失败", zap.Error(err)) + return err + } + + // 清除相关缓存 + r.deleteCacheByID(ctx, id) + r.deleteCacheByPhone(ctx, user.Phone) + + r.logger.Info("用户软删除成功", zap.String("user_id", id)) + return nil +} + +// Restore 恢复软删除的用户 +func (r *UserRepository) Restore(ctx context.Context, id string) error { + if err := r.db.WithContext(ctx).Unscoped().Model(&entities.User{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil { + r.logger.Error("恢复用户失败", zap.Error(err)) + return err + } + + // 清除相关缓存 + r.deleteCacheByID(ctx, id) + + r.logger.Info("用户恢复成功", zap.String("user_id", id)) + return nil +} + // List 分页获取用户列表 func (r *UserRepository) List(ctx context.Context, offset, limit int) ([]*entities.User, error) { var users []*entities.User diff --git a/internal/domains/user/services/sms_code_service.go b/internal/domains/user/services/sms_code_service.go index 3d3d4cf..5ac879f 100644 --- a/internal/domains/user/services/sms_code_service.go +++ b/internal/domains/user/services/sms_code_service.go @@ -43,83 +43,132 @@ func NewSMSCodeService( // SendCode 发送验证码 func (s *SMSCodeService) SendCode(ctx context.Context, phone string, scene entities.SMSScene, clientIP, userAgent string) error { - // 检查频率限制 + // 1. 检查频率限制 if err := s.checkRateLimit(ctx, phone); err != nil { return err } - // 生成验证码 + // 2. 生成验证码 code := s.smsClient.GenerateCode(s.config.CodeLength) - // 创建SMS验证码记录 - smsCode := &entities.SMSCode{ - ID: uuid.New().String(), - Phone: phone, - Code: code, - Scene: scene, - IP: clientIP, - UserAgent: userAgent, - Used: false, - ExpiresAt: time.Now().Add(s.config.ExpireTime), + // 3. 使用工厂方法创建SMS验证码记录 + smsCode, err := entities.NewSMSCode(phone, code, scene, s.config.ExpireTime, clientIP, userAgent) + if err != nil { + return fmt.Errorf("创建验证码记录失败: %w", err) } - // 保存验证码 + // 4. 设置ID + smsCode.ID = uuid.New().String() + + // 5. 保存验证码 if err := s.repo.Create(ctx, smsCode); err != nil { s.logger.Error("保存短信验证码失败", - zap.String("phone", phone), - zap.String("scene", string(scene)), + zap.String("phone", smsCode.GetMaskedPhone()), + zap.String("scene", smsCode.GetSceneName()), zap.Error(err)) return fmt.Errorf("保存验证码失败: %w", err) } - // 发送短信 + // 6. 发送短信 if err := s.smsClient.SendVerificationCode(ctx, phone, code); err != nil { // 记录发送失败但不删除验证码记录,让其自然过期 s.logger.Error("发送短信验证码失败", - zap.String("phone", phone), - zap.String("code", code), + zap.String("phone", smsCode.GetMaskedPhone()), + zap.String("code", smsCode.GetMaskedCode()), zap.Error(err)) return fmt.Errorf("短信发送失败: %w", err) } - // 更新发送记录缓存 + // 7. 更新发送记录缓存 s.updateSendRecord(ctx, phone) s.logger.Info("短信验证码发送成功", - zap.String("phone", phone), - zap.String("scene", string(scene))) + zap.String("phone", smsCode.GetMaskedPhone()), + zap.String("scene", smsCode.GetSceneName()), + zap.String("remaining_time", smsCode.GetRemainingTime().String())) return nil } // VerifyCode 验证验证码 func (s *SMSCodeService) VerifyCode(ctx context.Context, phone, code string, scene entities.SMSScene) error { - // 根据手机号和场景获取有效的验证码记录 + // 1. 根据手机号和场景获取有效的验证码记录 smsCode, err := s.repo.GetValidCode(ctx, phone, scene) if err != nil { return fmt.Errorf("验证码无效或已过期") } - // 验证验证码是否匹配 - if smsCode.Code != code { - return fmt.Errorf("验证码无效或已过期") + // 2. 使用实体的验证方法 + if err := smsCode.VerifyCode(code); err != nil { + return err } - // 标记验证码为已使用 - if err := s.repo.MarkAsUsed(ctx, smsCode.ID); err != nil { - s.logger.Error("标记验证码为已使用失败", + // 3. 保存更新后的验证码状态 + if err := s.repo.Update(ctx, smsCode); err != nil { + s.logger.Error("更新验证码状态失败", zap.String("code_id", smsCode.ID), zap.Error(err)) return fmt.Errorf("验证码状态更新失败") } s.logger.Info("短信验证码验证成功", - zap.String("phone", phone), - zap.String("scene", string(scene))) + zap.String("phone", smsCode.GetMaskedPhone()), + zap.String("scene", smsCode.GetSceneName())) return nil } +// CanResendCode 检查是否可以重新发送验证码 +func (s *SMSCodeService) CanResendCode(ctx context.Context, phone string, scene entities.SMSScene) (bool, error) { + // 1. 获取最近的验证码记录 + recentCode, err := s.repo.GetRecentCode(ctx, phone, scene) + if err != nil { + // 如果没有记录,可以发送 + return true, nil + } + + // 2. 使用实体的方法检查是否可以重新发送 + canResend := recentCode.CanResend(s.config.RateLimit.MinInterval) + + // 3. 记录检查结果 + if !canResend { + remainingTime := s.config.RateLimit.MinInterval - time.Since(recentCode.CreatedAt) + s.logger.Info("验证码发送频率限制", + zap.String("phone", recentCode.GetMaskedPhone()), + zap.String("scene", recentCode.GetSceneName()), + zap.Duration("remaining_wait_time", remainingTime)) + } + + return canResend, nil +} + +// GetCodeStatus 获取验证码状态信息 +func (s *SMSCodeService) GetCodeStatus(ctx context.Context, phone string, scene entities.SMSScene) (map[string]interface{}, error) { + // 1. 获取最近的验证码记录 + recentCode, err := s.repo.GetRecentCode(ctx, phone, scene) + if err != nil { + return map[string]interface{}{ + "has_code": false, + "message": "没有找到验证码记录", + }, nil + } + + // 2. 构建状态信息 + status := map[string]interface{}{ + "has_code": true, + "is_valid": recentCode.IsValid(), + "is_expired": recentCode.IsExpired(), + "is_used": recentCode.Used, + "remaining_time": recentCode.GetRemainingTime().String(), + "scene": recentCode.GetSceneName(), + "can_resend": recentCode.CanResend(s.config.RateLimit.MinInterval), + "created_at": recentCode.CreatedAt, + "security_info": recentCode.GetSecurityInfo(), + } + + return status, nil +} + // checkRateLimit 检查发送频率限制 func (s *SMSCodeService) checkRateLimit(ctx context.Context, phone string) error { now := time.Now() diff --git a/internal/domains/user/services/user_service.go b/internal/domains/user/services/user_service.go index 3f86152..11a307e 100644 --- a/internal/domains/user/services/user_service.go +++ b/internal/domains/user/services/user_service.go @@ -3,11 +3,9 @@ package services import ( "context" "fmt" - "regexp" "github.com/google/uuid" "go.uber.org/zap" - "golang.org/x/crypto/bcrypt" "tyapi-server/internal/domains/user/dto" "tyapi-server/internal/domains/user/entities" @@ -64,44 +62,32 @@ func (s *UserService) Shutdown(ctx context.Context) error { // Register 用户注册 func (s *UserService) Register(ctx context.Context, registerReq *dto.RegisterRequest) (*entities.User, error) { - // 验证手机号格式 - if !s.isValidPhone(registerReq.Phone) { - return nil, fmt.Errorf("手机号格式无效") - } - - // 验证密码确认 - if registerReq.Password != registerReq.ConfirmPassword { - return nil, fmt.Errorf("密码和确认密码不匹配") - } - - // 验证短信验证码 + // 1. 验证短信验证码 if err := s.smsCodeService.VerifyCode(ctx, registerReq.Phone, registerReq.Code, entities.SMSSceneRegister); err != nil { return nil, fmt.Errorf("验证码验证失败: %w", err) } - // 检查手机号是否已存在 + // 2. 检查手机号是否已存在 if err := s.checkPhoneDuplicate(ctx, registerReq.Phone); err != nil { return nil, err } - // 创建用户实体 - user := registerReq.ToEntity() + // 3. 使用工厂方法创建用户实体(业务规则验证在实体中完成) + user, err := entities.NewUser(registerReq.Phone, registerReq.Password) + if err != nil { + return nil, fmt.Errorf("创建用户失败: %w", err) + } + + // 4. 设置用户ID user.ID = uuid.New().String() - // 哈希密码 - hashedPassword, err := s.hashPassword(registerReq.Password) - if err != nil { - return nil, fmt.Errorf("密码加密失败: %w", err) - } - user.Password = hashedPassword - - // 保存用户 + // 5. 保存用户 if err := s.repo.Create(ctx, user); err != nil { s.logger.Error("创建用户失败", zap.Error(err)) return nil, fmt.Errorf("创建用户失败: %w", err) } - // 发布用户注册事件 + // 6. 发布用户注册事件 event := events.NewUserRegisteredEvent(user, s.getCorrelationID(ctx)) if err := s.eventBus.Publish(ctx, event); err != nil { s.logger.Warn("发布用户注册事件失败", zap.Error(err)) @@ -116,18 +102,23 @@ func (s *UserService) Register(ctx context.Context, registerReq *dto.RegisterReq // LoginWithPassword 密码登录 func (s *UserService) LoginWithPassword(ctx context.Context, loginReq *dto.LoginWithPasswordRequest) (*entities.User, error) { - // 根据手机号查找用户 + // 1. 根据手机号查找用户 user, err := s.repo.FindByPhone(ctx, loginReq.Phone) if err != nil { return nil, fmt.Errorf("用户名或密码错误") } - // 验证密码 - if !s.checkPassword(loginReq.Password, user.Password) { + // 2. 检查用户是否可以登录(委托给实体) + if !user.CanLogin() { + return nil, fmt.Errorf("用户状态异常,无法登录") + } + + // 3. 验证密码(委托给实体) + if !user.CheckPassword(loginReq.Password) { return nil, fmt.Errorf("用户名或密码错误") } - // 发布用户登录事件 + // 4. 发布用户登录事件 event := events.NewUserLoggedInEvent( user.ID, user.Phone, s.getClientIP(ctx), s.getUserAgent(ctx), @@ -145,18 +136,23 @@ func (s *UserService) LoginWithPassword(ctx context.Context, loginReq *dto.Login // LoginWithSMS 短信验证码登录 func (s *UserService) LoginWithSMS(ctx context.Context, loginReq *dto.LoginWithSMSRequest) (*entities.User, error) { - // 验证短信验证码 + // 1. 验证短信验证码 if err := s.smsCodeService.VerifyCode(ctx, loginReq.Phone, loginReq.Code, entities.SMSSceneLogin); err != nil { return nil, fmt.Errorf("验证码验证失败: %w", err) } - // 根据手机号查找用户 + // 2. 根据手机号查找用户 user, err := s.repo.FindByPhone(ctx, loginReq.Phone) if err != nil { return nil, fmt.Errorf("用户不存在") } - // 发布用户登录事件 + // 3. 检查用户是否可以登录(委托给实体) + if !user.CanLogin() { + return nil, fmt.Errorf("用户状态异常,无法登录") + } + + // 4. 发布用户登录事件 event := events.NewUserLoggedInEvent( user.ID, user.Phone, s.getClientIP(ctx), s.getUserAgent(ctx), @@ -174,40 +170,28 @@ func (s *UserService) LoginWithSMS(ctx context.Context, loginReq *dto.LoginWithS // ChangePassword 修改密码 func (s *UserService) ChangePassword(ctx context.Context, userID string, req *dto.ChangePasswordRequest) error { - // 验证新密码确认 - if req.NewPassword != req.ConfirmNewPassword { - return fmt.Errorf("新密码和确认新密码不匹配") - } - - // 获取用户信息 + // 1. 获取用户信息 user, err := s.repo.GetByID(ctx, userID) if err != nil { return fmt.Errorf("用户不存在: %w", err) } - // 验证短信验证码 + // 2. 验证短信验证码 if err := s.smsCodeService.VerifyCode(ctx, user.Phone, req.Code, entities.SMSSceneChangePassword); err != nil { return fmt.Errorf("验证码验证失败: %w", err) } - // 验证当前密码 - if !s.checkPassword(req.OldPassword, user.Password) { - return fmt.Errorf("当前密码错误") + // 3. 执行业务逻辑(委托给实体) + if err := user.ChangePassword(req.OldPassword, req.NewPassword, req.ConfirmNewPassword); err != nil { + return err } - // 哈希新密码 - hashedPassword, err := s.hashPassword(req.NewPassword) - if err != nil { - return fmt.Errorf("新密码加密失败: %w", err) - } - - // 更新密码 - user.Password = hashedPassword + // 4. 保存用户 if err := s.repo.Update(ctx, user); err != nil { return fmt.Errorf("密码更新失败: %w", err) } - // 发布密码修改事件 + // 5. 发布密码修改事件 event := events.NewUserPasswordChangedEvent(user.ID, user.Phone, s.getCorrelationID(ctx)) if err := s.eventBus.Publish(ctx, event); err != nil { s.logger.Warn("发布密码修改事件失败", zap.Error(err)) @@ -232,7 +216,61 @@ func (s *UserService) GetByID(ctx context.Context, id string) (*entities.User, e return user, nil } -// 工具方法 +// UpdateUserProfile 更新用户信息 +func (s *UserService) UpdateUserProfile(ctx context.Context, userID string, req *dto.UpdateProfileRequest) (*entities.User, error) { + // 1. 获取用户信息 + user, err := s.repo.GetByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("用户不存在: %w", err) + } + + // 2. 更新手机号(如果需要) + if req.Phone != "" && req.Phone != user.Phone { + // 检查新手机号是否已存在 + if err := s.checkPhoneDuplicate(ctx, req.Phone); err != nil { + return nil, err + } + + // 使用实体的方法设置手机号 + if err := user.SetPhone(req.Phone); err != nil { + return nil, err + } + } + + // 3. 保存用户 + if err := s.repo.Update(ctx, user); err != nil { + return nil, fmt.Errorf("更新用户信息失败: %w", err) + } + + s.logger.Info("用户信息更新成功", zap.String("user_id", userID)) + + return user, nil +} + +// DeactivateUser 停用用户 +func (s *UserService) DeactivateUser(ctx context.Context, userID string) error { + // 1. 获取用户信息 + user, err := s.repo.GetByID(ctx, userID) + if err != nil { + return fmt.Errorf("用户不存在: %w", err) + } + + // 2. 检查用户状态 + if user.IsDeleted() { + return fmt.Errorf("用户已被停用") + } + + // 3. 软删除用户(这里需要调用仓储的软删除方法) + if err := s.repo.SoftDelete(ctx, userID); err != nil { + return fmt.Errorf("停用用户失败: %w", err) + } + + s.logger.Info("用户停用成功", zap.String("user_id", userID)) + + return nil +} + +// ================ 工具方法 ================ // checkPhoneDuplicate 检查手机号重复 func (s *UserService) checkPhoneDuplicate(ctx context.Context, phone string) error { @@ -242,34 +280,6 @@ func (s *UserService) checkPhoneDuplicate(ctx context.Context, phone string) err return nil } -// hashPassword 加密密码 -func (s *UserService) hashPassword(password string) (string, error) { - hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return "", err - } - return string(hashedBytes), nil -} - -// checkPassword 验证密码 -func (s *UserService) checkPassword(password, hash string) bool { - err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) - return err == nil -} - -// isValidPhone 验证手机号格式 -func (s *UserService) isValidPhone(phone string) bool { - // 简单的中国手机号验证(11位数字,以1开头) - pattern := `^1[3-9]\d{9}$` - matched, _ := regexp.MatchString(pattern, phone) - return matched -} - -// generateUserID 生成用户ID -func (s *UserService) generateUserID() string { - return uuid.New().String() -} - // getCorrelationID 获取关联ID func (s *UserService) getCorrelationID(ctx context.Context) string { if id := ctx.Value("correlation_id"); id != nil { diff --git a/internal/shared/database/database.go b/internal/shared/database/database.go index f972b15..91ef3e0 100644 --- a/internal/shared/database/database.go +++ b/internal/shared/database/database.go @@ -43,6 +43,9 @@ func NewConnection(config Config) (*DB, error) { SingularTable: true, // 使用单数表名 }, DisableForeignKeyConstraintWhenMigrating: true, + NowFunc: func() time.Time { + return time.Now().In(time.FixedZone("CST", 8*3600)) // 强制使用北京时间 + }, } // 连接数据库 @@ -76,7 +79,7 @@ func NewConnection(config Config) (*DB, error) { // buildDSN 构建数据库连接字符串 func buildDSN(config Config) string { return fmt.Sprintf( - "host=%s user=%s password=%s dbname=%s port=%s sslmode=%s TimeZone=%s", + "host=%s user=%s password=%s dbname=%s port=%s sslmode=%s TimeZone=%s options='-c timezone=%s'", config.Host, config.User, config.Password, @@ -84,6 +87,7 @@ func buildDSN(config Config) string { config.Port, config.SSLMode, config.Timezone, + config.Timezone, ) } diff --git a/internal/shared/http/validator.go b/internal/shared/http/validator.go deleted file mode 100644 index ef7b8a0..0000000 --- a/internal/shared/http/validator.go +++ /dev/null @@ -1,303 +0,0 @@ -package http - -import ( - "fmt" - "strings" - - "tyapi-server/internal/shared/interfaces" - - "github.com/gin-gonic/gin" - "github.com/go-playground/validator/v10" -) - -// RequestValidator 请求验证器实现 -type RequestValidator struct { - validator *validator.Validate - response interfaces.ResponseBuilder -} - -// NewRequestValidator 创建请求验证器 -func NewRequestValidator(response interfaces.ResponseBuilder) interfaces.RequestValidator { - v := validator.New() - - // 注册自定义验证器 - registerCustomValidators(v) - - return &RequestValidator{ - validator: v, - response: response, - } -} - -// Validate 验证请求体 -func (v *RequestValidator) Validate(c *gin.Context, dto interface{}) error { - if err := v.validator.Struct(dto); err != nil { - validationErrors := v.formatValidationErrors(err) - v.response.BadRequest(c, "Validation failed", validationErrors) - return err - } - return nil -} - -// ValidateQuery 验证查询参数 -func (v *RequestValidator) ValidateQuery(c *gin.Context, dto interface{}) error { - if err := c.ShouldBindQuery(dto); err != nil { - v.response.BadRequest(c, "查询参数格式错误", err.Error()) - return err - } - - if err := v.validator.Struct(dto); err != nil { - validationErrors := v.formatValidationErrors(err) - v.response.ValidationError(c, validationErrors) - return err - } - return nil -} - -// ValidateParam 验证路径参数 -func (v *RequestValidator) ValidateParam(c *gin.Context, dto interface{}) error { - if err := c.ShouldBindUri(dto); err != nil { - v.response.BadRequest(c, "路径参数格式错误", err.Error()) - return err - } - - if err := v.validator.Struct(dto); err != nil { - validationErrors := v.formatValidationErrors(err) - v.response.ValidationError(c, validationErrors) - return err - } - return nil -} - -// BindAndValidate 绑定并验证请求 -func (v *RequestValidator) BindAndValidate(c *gin.Context, dto interface{}) error { - // 绑定请求体 - if err := c.ShouldBindJSON(dto); err != nil { - v.response.BadRequest(c, "请求体格式错误", err.Error()) - return err - } - - // 验证数据 - return v.Validate(c, dto) -} - -// formatValidationErrors 格式化验证错误 -func (v *RequestValidator) formatValidationErrors(err error) map[string][]string { - errors := make(map[string][]string) - - if validationErrors, ok := err.(validator.ValidationErrors); ok { - for _, fieldError := range validationErrors { - fieldName := v.getFieldName(fieldError) - errorMessage := v.getErrorMessage(fieldError) - - if _, exists := errors[fieldName]; !exists { - errors[fieldName] = []string{} - } - errors[fieldName] = append(errors[fieldName], errorMessage) - } - } - - return errors -} - -// getFieldName 获取字段名(JSON标签优先) -func (v *RequestValidator) getFieldName(fieldError validator.FieldError) string { - // 可以通过反射获取JSON标签,这里简化处理 - fieldName := fieldError.Field() - - // 转换为snake_case(可选) - return v.toSnakeCase(fieldName) -} - -// getErrorMessage 获取错误消息 -func (v *RequestValidator) getErrorMessage(fieldError validator.FieldError) string { - field := fieldError.Field() - tag := fieldError.Tag() - param := fieldError.Param() - - fieldDisplayName := v.getFieldDisplayName(field) - - switch tag { - case "required": - return fmt.Sprintf("%s 不能为空", fieldDisplayName) - case "email": - return fmt.Sprintf("%s 必须是有效的邮箱地址", fieldDisplayName) - case "min": - return fmt.Sprintf("%s 长度不能少于 %s 位", fieldDisplayName, param) - case "max": - return fmt.Sprintf("%s 长度不能超过 %s 位", fieldDisplayName, param) - case "len": - return fmt.Sprintf("%s 长度必须为 %s 位", fieldDisplayName, param) - case "gt": - return fmt.Sprintf("%s 必须大于 %s", fieldDisplayName, param) - case "gte": - return fmt.Sprintf("%s 必须大于等于 %s", fieldDisplayName, param) - case "lt": - return fmt.Sprintf("%s 必须小于 %s", fieldDisplayName, param) - case "lte": - return fmt.Sprintf("%s 必须小于等于 %s", fieldDisplayName, param) - case "oneof": - return fmt.Sprintf("%s 必须是以下值之一:[%s]", fieldDisplayName, param) - case "url": - return fmt.Sprintf("%s 必须是有效的URL地址", fieldDisplayName) - case "alpha": - return fmt.Sprintf("%s 只能包含字母", fieldDisplayName) - case "alphanum": - return fmt.Sprintf("%s 只能包含字母和数字", fieldDisplayName) - case "numeric": - return fmt.Sprintf("%s 必须是数字", fieldDisplayName) - case "phone": - return fmt.Sprintf("%s 必须是有效的手机号", fieldDisplayName) - case "username": - return fmt.Sprintf("%s 格式不正确,只能包含字母、数字、下划线,且不能以数字开头", fieldDisplayName) - case "strong_password": - return fmt.Sprintf("%s 强度不足,必须包含大小写字母和数字,且不少于8位", fieldDisplayName) - case "eqfield": - return fmt.Sprintf("%s 必须与 %s 一致", fieldDisplayName, v.getFieldDisplayName(param)) - default: - return fmt.Sprintf("%s 格式不正确", fieldDisplayName) - } -} - -// getFieldDisplayName 获取字段显示名称(中文) -func (v *RequestValidator) getFieldDisplayName(field string) string { - fieldNames := map[string]string{ - "phone": "手机号", - "password": "密码", - "confirm_password": "确认密码", - "old_password": "原密码", - "new_password": "新密码", - "confirm_new_password": "确认新密码", - "code": "验证码", - "username": "用户名", - "email": "邮箱", - "display_name": "显示名称", - "scene": "使用场景", - "Password": "密码", - "NewPassword": "新密码", - } - - if displayName, exists := fieldNames[field]; exists { - return displayName - } - return field -} - -// toSnakeCase 转换为snake_case -func (v *RequestValidator) toSnakeCase(str string) string { - var result strings.Builder - for i, r := range str { - if i > 0 && (r >= 'A' && r <= 'Z') { - result.WriteRune('_') - } - result.WriteRune(r) - } - return strings.ToLower(result.String()) -} - -// registerCustomValidators 注册自定义验证器 -func registerCustomValidators(v *validator.Validate) { - // 注册手机号验证器 - v.RegisterValidation("phone", validatePhone) - - // 注册用户名验证器 - v.RegisterValidation("username", validateUsername) - - // 注册密码强度验证器 - v.RegisterValidation("strong_password", validateStrongPassword) -} - -// validatePhone 验证手机号 -func validatePhone(fl validator.FieldLevel) bool { - phone := fl.Field().String() - if phone == "" { - return true // 空值由required标签处理 - } - - // 简单的手机号验证(可根据需要完善) - if len(phone) < 10 || len(phone) > 15 { - return false - } - - // 检查是否以+开头或全是数字 - if strings.HasPrefix(phone, "+") { - phone = phone[1:] - } - - for _, r := range phone { - if r < '0' || r > '9' { - if r != '-' && r != ' ' && r != '(' && r != ')' { - return false - } - } - } - - return true -} - -// validateUsername 验证用户名 -func validateUsername(fl validator.FieldLevel) bool { - username := fl.Field().String() - if username == "" { - return true // 空值由required标签处理 - } - - // 用户名规则:3-30个字符,只能包含字母、数字、下划线,不能以数字开头 - if len(username) < 3 || len(username) > 30 { - return false - } - - // 不能以数字开头 - if username[0] >= '0' && username[0] <= '9' { - return false - } - - // 只能包含字母、数字、下划线 - for _, r := range username { - if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_') { - return false - } - } - - return true -} - -// validateStrongPassword 验证密码强度 -func validateStrongPassword(fl validator.FieldLevel) bool { - password := fl.Field().String() - if password == "" { - return true // 空值由required标签处理 - } - - // 密码强度规则:至少8个字符,包含大小写字母、数字 - if len(password) < 8 { - return false - } - - hasUpper := false - hasLower := false - hasDigit := false - - for _, r := range password { - switch { - case r >= 'A' && r <= 'Z': - hasUpper = true - case r >= 'a' && r <= 'z': - hasLower = true - case r >= '0' && r <= '9': - hasDigit = true - } - } - - return hasUpper && hasLower && hasDigit -} - -// ValidateStruct 直接验证结构体(不通过HTTP上下文) -func (v *RequestValidator) ValidateStruct(dto interface{}) error { - return v.validator.Struct(dto) -} - -// GetValidator 获取原始验证器(用于特殊情况) -func (v *RequestValidator) GetValidator() *validator.Validate { - return v.validator -} diff --git a/internal/shared/http/validator_zh.go b/internal/shared/http/validator_zh.go index 404ba36..24241ff 100644 --- a/internal/shared/http/validator_zh.go +++ b/internal/shared/http/validator_zh.go @@ -1,6 +1,7 @@ package http import ( + "fmt" "strings" "tyapi-server/internal/shared/interfaces" @@ -14,16 +15,12 @@ import ( // RequestValidatorZh 中文验证器实现 type RequestValidatorZh struct { - validator *validator.Validate - translator ut.Translator response interfaces.ResponseBuilder + translator ut.Translator } // NewRequestValidatorZh 创建支持中文翻译的请求验证器 func NewRequestValidatorZh(response interfaces.ResponseBuilder) interfaces.RequestValidator { - // 创建验证器实例 - validate := validator.New() - // 创建中文locale zhLocale := zh.New() uni := ut.New(zhLocale, zhLocale) @@ -31,39 +28,64 @@ func NewRequestValidatorZh(response interfaces.ResponseBuilder) interfaces.Reque // 获取中文翻译器 trans, _ := uni.GetTranslator("zh") - // 注册中文翻译 - zh_translations.RegisterDefaultTranslations(validate, trans) + // 注册官方中文翻译 + zh_translations.RegisterDefaultTranslations(validator.New(), trans) - // 注册自定义验证器 - registerCustomValidatorsZh(validate, trans) + // 注册自定义翻译 + registerCustomTranslations(trans) return &RequestValidatorZh{ - validator: validate, - translator: trans, response: response, + translator: trans, } } +// registerCustomTranslations 注册自定义翻译 +func registerCustomTranslations(trans ut.Translator) { + // 自定义 eqfield 翻译(更友好的提示) + _ = trans.Add("eqfield", "{0}必须与{1}一致", true) + + // 自定义 required 翻译 + _ = trans.Add("required", "{0}不能为空", true) + + // 自定义 min 翻译 + _ = trans.Add("min", "{0}长度不能少于{1}位", true) + + // 自定义 max 翻译 + _ = trans.Add("max", "{0}长度不能超过{1}位", true) + + // 自定义 len 翻译 + _ = trans.Add("len", "{0}长度必须为{1}位", true) + + // 自定义 email 翻译 + _ = trans.Add("email", "{0}必须是有效的邮箱地址", true) + + // 自定义手机号翻译 + _ = trans.Add("phone", "{0}必须是有效的手机号", true) + + // 自定义用户名翻译 + _ = trans.Add("username", "{0}格式不正确,只能包含字母、数字、下划线,且不能以数字开头", true) + + // 自定义强密码翻译 + _ = trans.Add("strong_password", "{0}强度不足,必须包含大小写字母和数字,且不少于8位", true) +} + // Validate 验证请求体 func (v *RequestValidatorZh) Validate(c *gin.Context, dto interface{}) error { - if err := v.validator.Struct(dto); err != nil { - validationErrors := v.formatValidationErrorsZh(err) - v.response.ValidationError(c, validationErrors) - return err - } - return nil + // 直接使用 Gin 的绑定和验证 + return v.BindAndValidate(c, dto) } // ValidateQuery 验证查询参数 func (v *RequestValidatorZh) ValidateQuery(c *gin.Context, dto interface{}) error { if err := c.ShouldBindQuery(dto); err != nil { - v.response.BadRequest(c, "查询参数格式错误", err.Error()) - return err - } - - if err := v.validator.Struct(dto); err != nil { - validationErrors := v.formatValidationErrorsZh(err) - v.response.ValidationError(c, validationErrors) + // 处理查询参数验证错误 + if _, ok := err.(validator.ValidationErrors); ok { + validationErrors := v.formatValidationErrorsZh(err) + v.response.ValidationError(c, validationErrors) + } else { + v.response.BadRequest(c, "查询参数格式错误", err.Error()) + } return err } return nil @@ -72,13 +94,13 @@ func (v *RequestValidatorZh) ValidateQuery(c *gin.Context, dto interface{}) erro // ValidateParam 验证路径参数 func (v *RequestValidatorZh) ValidateParam(c *gin.Context, dto interface{}) error { if err := c.ShouldBindUri(dto); err != nil { - v.response.BadRequest(c, "路径参数格式错误", err.Error()) - return err - } - - if err := v.validator.Struct(dto); err != nil { - validationErrors := v.formatValidationErrorsZh(err) - v.response.ValidationError(c, validationErrors) + // 处理路径参数验证错误 + if _, ok := err.(validator.ValidationErrors); ok { + validationErrors := v.formatValidationErrorsZh(err) + v.response.ValidationError(c, validationErrors) + } else { + v.response.BadRequest(c, "路径参数格式错误", err.Error()) + } return err } return nil @@ -86,14 +108,20 @@ func (v *RequestValidatorZh) ValidateParam(c *gin.Context, dto interface{}) erro // BindAndValidate 绑定并验证请求 func (v *RequestValidatorZh) BindAndValidate(c *gin.Context, dto interface{}) error { - // 绑定请求体 + // 绑定请求体(Gin 会自动进行 binding 标签验证) if err := c.ShouldBindJSON(dto); err != nil { - v.response.BadRequest(c, "请求体格式错误", err.Error()) + // 处理 Gin binding 验证错误 + if _, ok := err.(validator.ValidationErrors); ok { + // 所有验证错误都使用 422 状态码 + validationErrors := v.formatValidationErrorsZh(err) + v.response.ValidationError(c, validationErrors) + } else { + v.response.BadRequest(c, "请求体格式错误", err.Error()) + } return err } - // 验证数据 - return v.Validate(c, dto) + return nil } // formatValidationErrorsZh 格式化验证错误(中文翻译版) @@ -104,15 +132,8 @@ func (v *RequestValidatorZh) formatValidationErrorsZh(err error) map[string][]st for _, fieldError := range validationErrors { fieldName := v.getFieldNameZh(fieldError) - // 首先尝试使用翻译器获取翻译后的错误消息 - errorMessage := fieldError.Translate(v.translator) - - // 如果翻译后的消息包含英文字段名,则替换为中文字段名 - fieldDisplayName := v.getFieldDisplayName(fieldError.Field()) - if fieldDisplayName != fieldError.Field() { - // 替换字段名为中文 - errorMessage = strings.ReplaceAll(errorMessage, fieldError.Field(), fieldDisplayName) - } + // 获取友好的中文错误消息 + errorMessage := v.getFriendlyErrorMessage(fieldError) if _, exists := errors[fieldName]; !exists { errors[fieldName] = []string{} @@ -124,6 +145,53 @@ func (v *RequestValidatorZh) formatValidationErrorsZh(err error) map[string][]st return errors } +// getFriendlyErrorMessage 获取友好的中文错误消息 +func (v *RequestValidatorZh) getFriendlyErrorMessage(fieldError validator.FieldError) string { + field := fieldError.Field() + tag := fieldError.Tag() + param := fieldError.Param() + + fieldDisplayName := v.getFieldDisplayName(field) + + // 优先使用官方翻译器 + errorMessage := fieldError.Translate(v.translator) + + // 如果官方翻译成功且不是英文,使用官方翻译 + if errorMessage != fieldError.Error() { + // 替换字段名为中文 + if fieldDisplayName != fieldError.Field() { + errorMessage = strings.ReplaceAll(errorMessage, fieldError.Field(), fieldDisplayName) + } + return errorMessage + } + + // 回退到自定义翻译 + switch tag { + case "required": + return fmt.Sprintf("%s不能为空", fieldDisplayName) + case "email": + return fmt.Sprintf("%s必须是有效的邮箱地址", fieldDisplayName) + case "min": + return fmt.Sprintf("%s长度不能少于%s位", fieldDisplayName, param) + case "max": + return fmt.Sprintf("%s长度不能超过%s位", fieldDisplayName, param) + case "len": + return fmt.Sprintf("%s长度必须为%s位", fieldDisplayName, param) + case "eqfield": + paramDisplayName := v.getFieldDisplayName(param) + return fmt.Sprintf("%s必须与%s一致", fieldDisplayName, paramDisplayName) + case "phone": + return fmt.Sprintf("%s必须是有效的手机号", fieldDisplayName) + case "username": + return fmt.Sprintf("%s格式不正确,只能包含字母、数字、下划线,且不能以数字开头", fieldDisplayName) + case "strong_password": + return fmt.Sprintf("%s强度不足,必须包含大小写字母和数字,且不少于8位", fieldDisplayName) + default: + // 默认错误消息 + return fmt.Sprintf("%s格式不正确", fieldDisplayName) + } +} + // getFieldNameZh 获取字段名(JSON标签优先) func (v *RequestValidatorZh) getFieldNameZh(fieldError validator.FieldError) string { fieldName := fieldError.Field() @@ -166,129 +234,3 @@ func (v *RequestValidatorZh) toSnakeCase(str string) string { } return strings.ToLower(result.String()) } - -// registerCustomValidatorsZh 注册自定义验证器和中文翻译 -func registerCustomValidatorsZh(v *validator.Validate, trans ut.Translator) { - // 注册手机号验证器 - v.RegisterValidation("phone", validatePhoneZh) - v.RegisterTranslation("phone", trans, func(ut ut.Translator) error { - return ut.Add("phone", "{0}必须是有效的手机号", true) - }, func(ut ut.Translator, fe validator.FieldError) string { - t, _ := ut.T("phone", fe.Field()) - return t - }) - - // 注册用户名验证器 - v.RegisterValidation("username", validateUsernameZh) - v.RegisterTranslation("username", trans, func(ut ut.Translator) error { - return ut.Add("username", "{0}格式不正确,只能包含字母、数字、下划线,且不能以数字开头", true) - }, func(ut ut.Translator, fe validator.FieldError) string { - t, _ := ut.T("username", fe.Field()) - return t - }) - - // 注册密码强度验证器 - v.RegisterValidation("strong_password", validateStrongPasswordZh) - v.RegisterTranslation("strong_password", trans, func(ut ut.Translator) error { - return ut.Add("strong_password", "{0}强度不足,必须包含大小写字母和数字,且不少于8位", true) - }, func(ut ut.Translator, fe validator.FieldError) string { - t, _ := ut.T("strong_password", fe.Field()) - return t - }) - - // 自定义eqfield翻译 - v.RegisterTranslation("eqfield", trans, func(ut ut.Translator) error { - return ut.Add("eqfield", "{0}必须等于{1}", true) - }, func(ut ut.Translator, fe validator.FieldError) string { - t, _ := ut.T("eqfield", fe.Field(), fe.Param()) - return t - }) -} - -// validatePhoneZh 验证手机号 -func validatePhoneZh(fl validator.FieldLevel) bool { - phone := fl.Field().String() - if phone == "" { - return true // 空值由required标签处理 - } - - // 中国手机号验证:11位,以1开头 - if len(phone) != 11 { - return false - } - - if !strings.HasPrefix(phone, "1") { - return false - } - - // 检查是否全是数字 - for _, r := range phone { - if r < '0' || r > '9' { - return false - } - } - - return true -} - -// validateUsernameZh 验证用户名 -func validateUsernameZh(fl validator.FieldLevel) bool { - username := fl.Field().String() - if username == "" { - return true // 空值由required标签处理 - } - - // 用户名规则:3-30个字符,只能包含字母、数字、下划线,不能以数字开头 - if len(username) < 3 || len(username) > 30 { - return false - } - - // 不能以数字开头 - if username[0] >= '0' && username[0] <= '9' { - return false - } - - // 只能包含字母、数字、下划线 - for _, r := range username { - if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_') { - return false - } - } - - return true -} - -// validateStrongPasswordZh 验证密码强度 -func validateStrongPasswordZh(fl validator.FieldLevel) bool { - password := fl.Field().String() - if password == "" { - return true // 空值由required标签处理 - } - - // 密码强度规则:至少8个字符,包含大小写字母、数字 - if len(password) < 8 { - return false - } - - hasUpper := false - hasLower := false - hasDigit := false - - for _, r := range password { - switch { - case r >= 'A' && r <= 'Z': - hasUpper = true - case r >= 'a' && r <= 'z': - hasLower = true - case r >= '0' && r <= '9': - hasDigit = true - } - } - - return hasUpper && hasLower && hasDigit -} - -// ValidateStruct 直接验证结构体 -func (v *RequestValidatorZh) ValidateStruct(dto interface{}) error { - return v.validator.Struct(dto) -} diff --git a/internal/shared/interfaces/http.go b/internal/shared/interfaces/http.go index 00eb4ab..1430736 100644 --- a/internal/shared/interfaces/http.go +++ b/internal/shared/interfaces/http.go @@ -95,9 +95,6 @@ type RequestValidator interface { // 绑定和验证 BindAndValidate(c *gin.Context, dto interface{}) error - - // 直接验证结构体 - ValidateStruct(dto interface{}) error } // PaginationMeta 分页元数据 diff --git a/internal/shared/notification/wechat_work_service.go b/internal/shared/notification/wechat_work_service.go new file mode 100644 index 0000000..d85616a --- /dev/null +++ b/internal/shared/notification/wechat_work_service.go @@ -0,0 +1,515 @@ +package notification + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "time" + + "go.uber.org/zap" +) + +// WeChatWorkService 企业微信通知服务 +type WeChatWorkService struct { + webhookURL string + secret string + timeout time.Duration + logger *zap.Logger +} + +// WechatWorkConfig 企业微信配置 +type WechatWorkConfig struct { + WebhookURL string `yaml:"webhook_url"` + Timeout time.Duration `yaml:"timeout"` +} + +// WechatWorkMessage 企业微信消息 +type WechatWorkMessage struct { + MsgType string `json:"msgtype"` + Text *WechatWorkText `json:"text,omitempty"` + Markdown *WechatWorkMarkdown `json:"markdown,omitempty"` +} + +// WechatWorkText 文本消息 +type WechatWorkText struct { + Content string `json:"content"` + MentionedList []string `json:"mentioned_list,omitempty"` + MentionedMobileList []string `json:"mentioned_mobile_list,omitempty"` +} + +// WechatWorkMarkdown Markdown消息 +type WechatWorkMarkdown struct { + Content string `json:"content"` +} + +// NewWeChatWorkService 创建企业微信通知服务 +func NewWeChatWorkService(webhookURL, secret string, logger *zap.Logger) *WeChatWorkService { + return &WeChatWorkService{ + webhookURL: webhookURL, + secret: secret, + timeout: 30 * time.Second, + logger: logger, + } +} + +// SendTextMessage 发送文本消息 +func (s *WeChatWorkService) SendTextMessage(ctx context.Context, content string, mentionedList []string, mentionedMobileList []string) error { + s.logger.Info("发送企业微信文本消息", + zap.String("content", content), + zap.Strings("mentioned_list", mentionedList), + ) + + message := map[string]interface{}{ + "msgtype": "text", + "text": map[string]interface{}{ + "content": content, + "mentioned_list": mentionedList, + "mentioned_mobile_list": mentionedMobileList, + }, + } + + return s.sendMessage(ctx, message) +} + +// SendMarkdownMessage 发送Markdown消息 +func (s *WeChatWorkService) SendMarkdownMessage(ctx context.Context, content string) error { + s.logger.Info("发送企业微信Markdown消息", zap.String("content", content)) + + message := map[string]interface{}{ + "msgtype": "markdown", + "markdown": map[string]interface{}{ + "content": content, + }, + } + + return s.sendMessage(ctx, message) +} + +// SendCardMessage 发送卡片消息 +func (s *WeChatWorkService) SendCardMessage(ctx context.Context, title, description, url string, btnText string) error { + s.logger.Info("发送企业微信卡片消息", + zap.String("title", title), + zap.String("description", description), + ) + + message := map[string]interface{}{ + "msgtype": "template_card", + "template_card": map[string]interface{}{ + "card_type": "text_notice", + "source": map[string]interface{}{ + "icon_url": "https://example.com/icon.png", + "desc": "企业认证系统", + }, + "main_title": map[string]interface{}{ + "title": title, + }, + "horizontal_content_list": []map[string]interface{}{ + { + "keyname": "描述", + "value": description, + }, + }, + "jump_list": []map[string]interface{}{ + { + "type": "1", + "title": btnText, + "url": url, + }, + }, + }, + } + + return s.sendMessage(ctx, message) +} + +// SendCertificationNotification 发送认证相关通知 +func (s *WeChatWorkService) SendCertificationNotification(ctx context.Context, notificationType string, data map[string]interface{}) error { + s.logger.Info("发送认证通知", zap.String("type", notificationType)) + + switch notificationType { + case "new_application": + return s.sendNewApplicationNotification(ctx, data) + case "ocr_success": + return s.sendOCRSuccessNotification(ctx, data) + case "ocr_failed": + return s.sendOCRFailedNotification(ctx, data) + case "face_verify_success": + return s.sendFaceVerifySuccessNotification(ctx, data) + case "face_verify_failed": + return s.sendFaceVerifyFailedNotification(ctx, data) + case "admin_approved": + return s.sendAdminApprovedNotification(ctx, data) + case "admin_rejected": + return s.sendAdminRejectedNotification(ctx, data) + case "contract_signed": + return s.sendContractSignedNotification(ctx, data) + case "certification_completed": + return s.sendCertificationCompletedNotification(ctx, data) + default: + return fmt.Errorf("不支持的通知类型: %s", notificationType) + } +} + +// sendNewApplicationNotification 发送新申请通知 +func (s *WeChatWorkService) sendNewApplicationNotification(ctx context.Context, data map[string]interface{}) error { + companyName := data["company_name"].(string) + applicantName := data["applicant_name"].(string) + applicationID := data["application_id"].(string) + + content := fmt.Sprintf(`## 🆕 新的企业认证申请 + +**企业名称**: %s +**申请人**: %s +**申请ID**: %s +**申请时间**: %s + +请管理员及时审核处理。`, + companyName, + applicantName, + applicationID, + time.Now().Format("2006-01-02 15:04:05")) + + return s.SendMarkdownMessage(ctx, content) +} + +// sendOCRSuccessNotification 发送OCR识别成功通知 +func (s *WeChatWorkService) sendOCRSuccessNotification(ctx context.Context, data map[string]interface{}) error { + companyName := data["company_name"].(string) + confidence := data["confidence"].(float64) + applicationID := data["application_id"].(string) + + content := fmt.Sprintf(`## ✅ OCR识别成功 + +**企业名称**: %s +**识别置信度**: %.2f%% +**申请ID**: %s +**识别时间**: %s + +营业执照信息已自动提取,请用户确认信息。`, + companyName, + confidence*100, + applicationID, + time.Now().Format("2006-01-02 15:04:05")) + + return s.SendMarkdownMessage(ctx, content) +} + +// sendOCRFailedNotification 发送OCR识别失败通知 +func (s *WeChatWorkService) sendOCRFailedNotification(ctx context.Context, data map[string]interface{}) error { + applicationID := data["application_id"].(string) + errorMsg := data["error_message"].(string) + + content := fmt.Sprintf(`## ❌ OCR识别失败 + +**申请ID**: %s +**错误信息**: %s +**失败时间**: %s + +请检查营业执照图片质量或联系技术支持。`, + applicationID, + errorMsg, + time.Now().Format("2006-01-02 15:04:05")) + + return s.SendMarkdownMessage(ctx, content) +} + +// sendFaceVerifySuccessNotification 发送人脸识别成功通知 +func (s *WeChatWorkService) sendFaceVerifySuccessNotification(ctx context.Context, data map[string]interface{}) error { + applicantName := data["applicant_name"].(string) + applicationID := data["application_id"].(string) + confidence := data["confidence"].(float64) + + content := fmt.Sprintf(`## ✅ 人脸识别成功 + +**申请人**: %s +**申请ID**: %s +**识别置信度**: %.2f%% +**识别时间**: %s + +身份验证通过,可以进行下一步操作。`, + applicantName, + applicationID, + confidence*100, + time.Now().Format("2006-01-02 15:04:05")) + + return s.SendMarkdownMessage(ctx, content) +} + +// sendFaceVerifyFailedNotification 发送人脸识别失败通知 +func (s *WeChatWorkService) sendFaceVerifyFailedNotification(ctx context.Context, data map[string]interface{}) error { + applicantName := data["applicant_name"].(string) + applicationID := data["application_id"].(string) + errorMsg := data["error_message"].(string) + + content := fmt.Sprintf(`## ❌ 人脸识别失败 + +**申请人**: %s +**申请ID**: %s +**错误信息**: %s +**失败时间**: %s + +请重新进行人脸识别或联系技术支持。`, + applicantName, + applicationID, + errorMsg, + time.Now().Format("2006-01-02 15:04:05")) + + return s.SendMarkdownMessage(ctx, content) +} + +// sendAdminApprovedNotification 发送管理员审核通过通知 +func (s *WeChatWorkService) sendAdminApprovedNotification(ctx context.Context, data map[string]interface{}) error { + companyName := data["company_name"].(string) + applicationID := data["application_id"].(string) + adminName := data["admin_name"].(string) + comment := data["comment"].(string) + + content := fmt.Sprintf(`## ✅ 管理员审核通过 + +**企业名称**: %s +**申请ID**: %s +**审核人**: %s +**审核意见**: %s +**审核时间**: %s + +认证申请已通过审核,请用户签署电子合同。`, + companyName, + applicationID, + adminName, + comment, + time.Now().Format("2006-01-02 15:04:05")) + + return s.SendMarkdownMessage(ctx, content) +} + +// sendAdminRejectedNotification 发送管理员审核拒绝通知 +func (s *WeChatWorkService) sendAdminRejectedNotification(ctx context.Context, data map[string]interface{}) error { + companyName := data["company_name"].(string) + applicationID := data["application_id"].(string) + adminName := data["admin_name"].(string) + reason := data["reason"].(string) + + content := fmt.Sprintf(`## ❌ 管理员审核拒绝 + +**企业名称**: %s +**申请ID**: %s +**审核人**: %s +**拒绝原因**: %s +**审核时间**: %s + +认证申请被拒绝,请根据反馈意见重新提交。`, + companyName, + applicationID, + adminName, + reason, + time.Now().Format("2006-01-02 15:04:05")) + + return s.SendMarkdownMessage(ctx, content) +} + +// sendContractSignedNotification 发送合同签署通知 +func (s *WeChatWorkService) sendContractSignedNotification(ctx context.Context, data map[string]interface{}) error { + companyName := data["company_name"].(string) + applicationID := data["application_id"].(string) + signerName := data["signer_name"].(string) + + content := fmt.Sprintf(`## 📝 电子合同已签署 + +**企业名称**: %s +**申请ID**: %s +**签署人**: %s +**签署时间**: %s + +电子合同签署完成,系统将自动生成钱包和Access Key。`, + companyName, + applicationID, + signerName, + time.Now().Format("2006-01-02 15:04:05")) + + return s.SendMarkdownMessage(ctx, content) +} + +// sendCertificationCompletedNotification 发送认证完成通知 +func (s *WeChatWorkService) sendCertificationCompletedNotification(ctx context.Context, data map[string]interface{}) error { + companyName := data["company_name"].(string) + applicationID := data["application_id"].(string) + walletAddress := data["wallet_address"].(string) + + content := fmt.Sprintf(`## 🎉 企业认证完成 + +**企业名称**: %s +**申请ID**: %s +**钱包地址**: %s +**完成时间**: %s + +恭喜!企业认证流程已完成,钱包和Access Key已生成。`, + companyName, + applicationID, + walletAddress, + time.Now().Format("2006-01-02 15:04:05")) + + return s.SendMarkdownMessage(ctx, content) +} + +// sendMessage 发送消息到企业微信 +func (s *WeChatWorkService) sendMessage(ctx context.Context, message map[string]interface{}) error { + // 生成签名URL + signedURL := s.generateSignedURL() + + // 序列化消息 + messageBytes, err := json.Marshal(message) + if err != nil { + return fmt.Errorf("序列化消息失败: %w", err) + } + + // 创建HTTP客户端 + client := &http.Client{ + Timeout: s.timeout, + } + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, "POST", signedURL, bytes.NewBuffer(messageBytes)) + if err != nil { + return fmt.Errorf("创建请求失败: %w", err) + } + + // 设置请求头 + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "tyapi-server/1.0") + + // 发送请求 + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("发送请求失败: %w", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("请求失败,状态码: %d", resp.StatusCode) + } + + // 解析响应 + var response map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return fmt.Errorf("解析响应失败: %w", err) + } + + // 检查错误码 + if errCode, ok := response["errcode"].(float64); ok && errCode != 0 { + errMsg := response["errmsg"].(string) + return fmt.Errorf("企业微信API错误: %d - %s", int(errCode), errMsg) + } + + s.logger.Info("企业微信消息发送成功", zap.Any("response", response)) + return nil +} + +// generateSignedURL 生成带签名的URL +func (s *WeChatWorkService) generateSignedURL() string { + if s.secret == "" { + return s.webhookURL + } + + // 生成时间戳 + timestamp := time.Now().Unix() + + // 生成随机字符串(这里简化处理,实际应该使用随机字符串) + nonce := fmt.Sprintf("%d", timestamp) + + // 构建签名字符串 + signStr := fmt.Sprintf("%d\n%s", timestamp, s.secret) + + // 计算签名 + h := hmac.New(sha256.New, []byte(s.secret)) + h.Write([]byte(signStr)) + signature := base64.StdEncoding.EncodeToString(h.Sum(nil)) + + // 构建签名URL + return fmt.Sprintf("%s×tamp=%d&nonce=%s&sign=%s", + s.webhookURL, timestamp, nonce, signature) +} + +// SendSystemAlert 发送系统告警 +func (s *WeChatWorkService) SendSystemAlert(ctx context.Context, level, title, message string) error { + s.logger.Info("发送系统告警", + zap.String("level", level), + zap.String("title", title), + ) + + // 根据告警级别选择图标 + var icon string + switch level { + case "info": + icon = "ℹ️" + case "warning": + icon = "⚠️" + case "error": + icon = "🚨" + case "critical": + icon = "💥" + default: + icon = "📢" + } + + content := fmt.Sprintf(`## %s 系统告警 + +**级别**: %s +**标题**: %s +**消息**: %s +**时间**: %s + +请相关人员及时处理。`, + icon, + level, + title, + message, + time.Now().Format("2006-01-02 15:04:05")) + + return s.SendMarkdownMessage(ctx, content) +} + +// SendDailyReport 发送每日报告 +func (s *WeChatWorkService) SendDailyReport(ctx context.Context, reportData map[string]interface{}) error { + s.logger.Info("发送每日报告") + + content := fmt.Sprintf(`## 📊 企业认证系统每日报告 + +**报告日期**: %s + +### 统计数据 +- **新增申请**: %d +- **OCR识别成功**: %d +- **OCR识别失败**: %d +- **人脸识别成功**: %d +- **人脸识别失败**: %d +- **审核通过**: %d +- **审核拒绝**: %d +- **认证完成**: %d + +### 系统状态 +- **系统运行时间**: %s +- **API调用次数**: %d +- **错误次数**: %d + +祝您工作愉快!`, + time.Now().Format("2006-01-02"), + reportData["new_applications"], + reportData["ocr_success"], + reportData["ocr_failed"], + reportData["face_verify_success"], + reportData["face_verify_failed"], + reportData["admin_approved"], + reportData["admin_rejected"], + reportData["certification_completed"], + reportData["uptime"], + reportData["api_calls"], + reportData["errors"]) + + return s.SendMarkdownMessage(ctx, content) +} diff --git a/internal/shared/ocr/baidu_ocr_service.go b/internal/shared/ocr/baidu_ocr_service.go new file mode 100644 index 0000000..29dcdb6 --- /dev/null +++ b/internal/shared/ocr/baidu_ocr_service.go @@ -0,0 +1,548 @@ +package ocr + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "go.uber.org/zap" + + "tyapi-server/internal/domains/certification/dto" +) + +// BaiduOCRService 百度OCR服务 +type BaiduOCRService struct { + appID string + apiKey string + secretKey string + endpoint string + timeout time.Duration + logger *zap.Logger +} + +// NewBaiduOCRService 创建百度OCR服务 +func NewBaiduOCRService(appID, apiKey, secretKey string, logger *zap.Logger) *BaiduOCRService { + return &BaiduOCRService{ + appID: appID, + apiKey: apiKey, + secretKey: secretKey, + endpoint: "https://aip.baidubce.com", + timeout: 30 * time.Second, + logger: logger, + } +} + +// RecognizeBusinessLicense 识别营业执照 +func (s *BaiduOCRService) RecognizeBusinessLicense(ctx context.Context, imageBytes []byte) (*dto.BusinessLicenseResult, error) { + s.logger.Info("开始识别营业执照", zap.Int("image_size", len(imageBytes))) + + // 获取访问令牌 + accessToken, err := s.getAccessToken(ctx) + if err != nil { + return nil, fmt.Errorf("获取访问令牌失败: %w", err) + } + + // 将图片转换为base64 + imageBase64 := base64.StdEncoding.EncodeToString(imageBytes) + + // 构建请求参数 + params := url.Values{} + params.Set("access_token", accessToken) + params.Set("image", imageBase64) + + // 发送请求 + apiURL := fmt.Sprintf("%s/rest/2.0/ocr/v1/business_license?%s", s.endpoint, params.Encode()) + resp, err := s.sendRequest(ctx, "POST", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("营业执照识别请求失败: %w", err) + } + + // 解析响应 + var result map[string]interface{} + if err := json.Unmarshal(resp, &result); err != nil { + return nil, fmt.Errorf("解析响应失败: %w", err) + } + + // 检查错误 + if errCode, ok := result["error_code"].(float64); ok && errCode != 0 { + errorMsg := result["error_msg"].(string) + return nil, fmt.Errorf("OCR识别失败: %s", errorMsg) + } + + // 解析识别结果 + licenseResult := s.parseBusinessLicenseResult(result) + + s.logger.Info("营业执照识别成功", + zap.String("company_name", licenseResult.CompanyName), + zap.String("legal_representative", licenseResult.LegalRepresentative), + zap.String("registered_capital", licenseResult.RegisteredCapital), + ) + + return licenseResult, nil +} + +// RecognizeIDCard 识别身份证 +func (s *BaiduOCRService) RecognizeIDCard(ctx context.Context, imageBytes []byte, side string) (*dto.IDCardResult, error) { + s.logger.Info("开始识别身份证", zap.String("side", side), zap.Int("image_size", len(imageBytes))) + + // 获取访问令牌 + accessToken, err := s.getAccessToken(ctx) + if err != nil { + return nil, fmt.Errorf("获取访问令牌失败: %w", err) + } + + // 将图片转换为base64 + imageBase64 := base64.StdEncoding.EncodeToString(imageBytes) + + // 构建请求参数 + params := url.Values{} + params.Set("access_token", accessToken) + params.Set("image", imageBase64) + params.Set("side", side) + + // 发送请求 + apiURL := fmt.Sprintf("%s/rest/2.0/ocr/v1/idcard?%s", s.endpoint, params.Encode()) + resp, err := s.sendRequest(ctx, "POST", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("身份证识别请求失败: %w", err) + } + + // 解析响应 + var result map[string]interface{} + if err := json.Unmarshal(resp, &result); err != nil { + return nil, fmt.Errorf("解析响应失败: %w", err) + } + + // 检查错误 + if errCode, ok := result["error_code"].(float64); ok && errCode != 0 { + errorMsg := result["error_msg"].(string) + return nil, fmt.Errorf("OCR识别失败: %s", errorMsg) + } + + // 解析识别结果 + idCardResult := s.parseIDCardResult(result, side) + + s.logger.Info("身份证识别成功", + zap.String("name", idCardResult.Name), + zap.String("id_number", idCardResult.IDNumber), + zap.String("side", side), + ) + + return idCardResult, nil +} + +// RecognizeGeneralText 通用文字识别 +func (s *BaiduOCRService) RecognizeGeneralText(ctx context.Context, imageBytes []byte) (*dto.GeneralTextResult, error) { + s.logger.Info("开始通用文字识别", zap.Int("image_size", len(imageBytes))) + + // 获取访问令牌 + accessToken, err := s.getAccessToken(ctx) + if err != nil { + return nil, fmt.Errorf("获取访问令牌失败: %w", err) + } + + // 将图片转换为base64 + imageBase64 := base64.StdEncoding.EncodeToString(imageBytes) + + // 构建请求参数 + params := url.Values{} + params.Set("access_token", accessToken) + params.Set("image", imageBase64) + + // 发送请求 + apiURL := fmt.Sprintf("%s/rest/2.0/ocr/v1/general_basic?%s", s.endpoint, params.Encode()) + resp, err := s.sendRequest(ctx, "POST", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("通用文字识别请求失败: %w", err) + } + + // 解析响应 + var result map[string]interface{} + if err := json.Unmarshal(resp, &result); err != nil { + return nil, fmt.Errorf("解析响应失败: %w", err) + } + + // 检查错误 + if errCode, ok := result["error_code"].(float64); ok && errCode != 0 { + errorMsg := result["error_msg"].(string) + return nil, fmt.Errorf("OCR识别失败: %s", errorMsg) + } + + // 解析识别结果 + textResult := s.parseGeneralTextResult(result) + + s.logger.Info("通用文字识别成功", + zap.Int("word_count", len(textResult.Words)), + zap.Float64("confidence", textResult.Confidence), + ) + + return textResult, nil +} + +// RecognizeFromURL 从URL识别图片 +func (s *BaiduOCRService) RecognizeFromURL(ctx context.Context, imageURL string, ocrType string) (interface{}, error) { + s.logger.Info("从URL识别图片", zap.String("url", imageURL), zap.String("type", ocrType)) + + // 下载图片 + imageBytes, err := s.downloadImage(ctx, imageURL) + if err != nil { + s.logger.Error("下载图片失败", zap.Error(err)) + return nil, fmt.Errorf("下载图片失败: %w", err) + } + + // 根据类型调用相应的识别方法 + switch ocrType { + case "business_license": + return s.RecognizeBusinessLicense(ctx, imageBytes) + case "idcard_front": + return s.RecognizeIDCard(ctx, imageBytes, "front") + case "idcard_back": + return s.RecognizeIDCard(ctx, imageBytes, "back") + case "general_text": + return s.RecognizeGeneralText(ctx, imageBytes) + default: + return nil, fmt.Errorf("不支持的OCR类型: %s", ocrType) + } +} + +// getAccessToken 获取百度API访问令牌 +func (s *BaiduOCRService) getAccessToken(ctx context.Context) (string, error) { + // 构建获取访问令牌的URL + tokenURL := fmt.Sprintf("%s/oauth/2.0/token?grant_type=client_credentials&client_id=%s&client_secret=%s", + s.endpoint, s.apiKey, s.secretKey) + + // 发送请求 + resp, err := s.sendRequest(ctx, "POST", tokenURL, nil) + if err != nil { + return "", fmt.Errorf("获取访问令牌请求失败: %w", err) + } + + // 解析响应 + var result map[string]interface{} + if err := json.Unmarshal(resp, &result); err != nil { + return "", fmt.Errorf("解析访问令牌响应失败: %w", err) + } + + // 检查错误 + if errCode, ok := result["error"].(string); ok && errCode != "" { + errorDesc := result["error_description"].(string) + return "", fmt.Errorf("获取访问令牌失败: %s - %s", errCode, errorDesc) + } + + // 提取访问令牌 + accessToken, ok := result["access_token"].(string) + if !ok { + return "", fmt.Errorf("响应中未找到访问令牌") + } + + return accessToken, nil +} + +// sendRequest 发送HTTP请求 +func (s *BaiduOCRService) sendRequest(ctx context.Context, method, url string, body io.Reader) ([]byte, error) { + // 创建HTTP客户端 + client := &http.Client{ + Timeout: s.timeout, + } + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + + // 设置请求头 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "tyapi-server/1.0") + + // 发送请求 + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("发送请求失败: %w", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("请求失败,状态码: %d", resp.StatusCode) + } + + // 读取响应内容 + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应内容失败: %w", err) + } + + return responseBody, nil +} + +// parseBusinessLicenseResult 解析营业执照识别结果 +func (s *BaiduOCRService) parseBusinessLicenseResult(result map[string]interface{}) *dto.BusinessLicenseResult { + // 解析百度OCR返回的结果 + wordsResult := result["words_result"].(map[string]interface{}) + + licenseResult := &dto.BusinessLicenseResult{ + Confidence: s.extractConfidence(result), + Words: s.extractWords(result), + } + + // 提取关键字段 + if companyName, ok := wordsResult["单位名称"]; ok { + if word, ok := companyName.(map[string]interface{}); ok { + licenseResult.CompanyName = word["words"].(string) + } + } + + if legalRep, ok := wordsResult["法人"]; ok { + if word, ok := legalRep.(map[string]interface{}); ok { + licenseResult.LegalRepresentative = word["words"].(string) + } + } + + if regCapital, ok := wordsResult["注册资本"]; ok { + if word, ok := regCapital.(map[string]interface{}); ok { + licenseResult.RegisteredCapital = word["words"].(string) + } + } + + if regAddress, ok := wordsResult["地址"]; ok { + if word, ok := regAddress.(map[string]interface{}); ok { + licenseResult.RegisteredAddress = word["words"].(string) + } + } + + if regNumber, ok := wordsResult["社会信用代码"]; ok { + if word, ok := regNumber.(map[string]interface{}); ok { + licenseResult.RegistrationNumber = word["words"].(string) + } + } + + if businessScope, ok := wordsResult["经营范围"]; ok { + if word, ok := businessScope.(map[string]interface{}); ok { + licenseResult.BusinessScope = word["words"].(string) + } + } + + if regDate, ok := wordsResult["成立日期"]; ok { + if word, ok := regDate.(map[string]interface{}); ok { + licenseResult.RegistrationDate = word["words"].(string) + } + } + + if validDate, ok := wordsResult["营业期限"]; ok { + if word, ok := validDate.(map[string]interface{}); ok { + licenseResult.ValidDate = word["words"].(string) + } + } + + return licenseResult +} + +// parseIDCardResult 解析身份证识别结果 +func (s *BaiduOCRService) parseIDCardResult(result map[string]interface{}, side string) *dto.IDCardResult { + wordsResult := result["words_result"].(map[string]interface{}) + + idCardResult := &dto.IDCardResult{ + Side: side, + Confidence: s.extractConfidence(result), + Words: s.extractWords(result), + } + + if side == "front" { + // 正面信息 + if name, ok := wordsResult["姓名"]; ok { + if word, ok := name.(map[string]interface{}); ok { + idCardResult.Name = word["words"].(string) + } + } + + if sex, ok := wordsResult["性别"]; ok { + if word, ok := sex.(map[string]interface{}); ok { + idCardResult.Sex = word["words"].(string) + } + } + + if nation, ok := wordsResult["民族"]; ok { + if word, ok := nation.(map[string]interface{}); ok { + idCardResult.Nation = word["words"].(string) + } + } + + if birth, ok := wordsResult["出生"]; ok { + if word, ok := birth.(map[string]interface{}); ok { + idCardResult.BirthDate = word["words"].(string) + } + } + + if address, ok := wordsResult["住址"]; ok { + if word, ok := address.(map[string]interface{}); ok { + idCardResult.Address = word["words"].(string) + } + } + + if idNumber, ok := wordsResult["公民身份号码"]; ok { + if word, ok := idNumber.(map[string]interface{}); ok { + idCardResult.IDNumber = word["words"].(string) + } + } + } else { + // 背面信息 + if authority, ok := wordsResult["签发机关"]; ok { + if word, ok := authority.(map[string]interface{}); ok { + idCardResult.IssuingAuthority = word["words"].(string) + } + } + + if validDate, ok := wordsResult["有效期限"]; ok { + if word, ok := validDate.(map[string]interface{}); ok { + idCardResult.ValidDate = word["words"].(string) + } + } + } + + return idCardResult +} + +// parseGeneralTextResult 解析通用文字识别结果 +func (s *BaiduOCRService) parseGeneralTextResult(result map[string]interface{}) *dto.GeneralTextResult { + wordsResult := result["words_result"].([]interface{}) + + textResult := &dto.GeneralTextResult{ + Confidence: s.extractConfidence(result), + Words: make([]string, 0, len(wordsResult)), + } + + // 提取所有识别的文字 + for _, word := range wordsResult { + if wordMap, ok := word.(map[string]interface{}); ok { + if words, ok := wordMap["words"].(string); ok { + textResult.Words = append(textResult.Words, words) + } + } + } + + return textResult +} + +// extractConfidence 提取置信度 +func (s *BaiduOCRService) extractConfidence(result map[string]interface{}) float64 { + if confidence, ok := result["confidence"].(float64); ok { + return confidence + } + return 0.0 +} + +// extractWords 提取识别的文字 +func (s *BaiduOCRService) extractWords(result map[string]interface{}) []string { + words := make([]string, 0) + + if wordsResult, ok := result["words_result"]; ok { + switch v := wordsResult.(type) { + case map[string]interface{}: + // 营业执照等结构化文档 + for _, word := range v { + if wordMap, ok := word.(map[string]interface{}); ok { + if wordsStr, ok := wordMap["words"].(string); ok { + words = append(words, wordsStr) + } + } + } + case []interface{}: + // 通用文字识别 + for _, word := range v { + if wordMap, ok := word.(map[string]interface{}); ok { + if wordsStr, ok := wordMap["words"].(string); ok { + words = append(words, wordsStr) + } + } + } + } + } + + return words +} + +// downloadImage 下载图片 +func (s *BaiduOCRService) downloadImage(ctx context.Context, imageURL string) ([]byte, error) { + // 创建HTTP客户端 + client := &http.Client{ + Timeout: 30 * time.Second, + } + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + + // 发送请求 + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("下载图片失败: %w", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("下载图片失败,状态码: %d", resp.StatusCode) + } + + // 读取响应内容 + imageBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取图片内容失败: %w", err) + } + + return imageBytes, nil +} + +// ValidateBusinessLicense 验证营业执照识别结果 +func (s *BaiduOCRService) ValidateBusinessLicense(result *dto.BusinessLicenseResult) error { + if result.Confidence < 0.8 { + return fmt.Errorf("识别置信度过低: %.2f", result.Confidence) + } + + if result.CompanyName == "" { + return fmt.Errorf("未能识别公司名称") + } + + if result.LegalRepresentative == "" { + return fmt.Errorf("未能识别法定代表人") + } + + if result.RegistrationNumber == "" { + return fmt.Errorf("未能识别统一社会信用代码") + } + + return nil +} + +// ValidateIDCard 验证身份证识别结果 +func (s *BaiduOCRService) ValidateIDCard(result *dto.IDCardResult) error { + if result.Confidence < 0.8 { + return fmt.Errorf("识别置信度过低: %.2f", result.Confidence) + } + + if result.Side == "front" { + if result.Name == "" { + return fmt.Errorf("未能识别姓名") + } + if result.IDNumber == "" { + return fmt.Errorf("未能识别身份证号码") + } + } else { + if result.IssuingAuthority == "" { + return fmt.Errorf("未能识别签发机关") + } + if result.ValidDate == "" { + return fmt.Errorf("未能识别有效期限") + } + } + + return nil +} diff --git a/internal/shared/ocr/ocr_interface.go b/internal/shared/ocr/ocr_interface.go new file mode 100644 index 0000000..2359e36 --- /dev/null +++ b/internal/shared/ocr/ocr_interface.go @@ -0,0 +1,44 @@ +package ocr + +import ( + "context" + "tyapi-server/internal/domains/certification/dto" +) + +// OCRService OCR识别服务接口 +type OCRService interface { + // 识别营业执照 + RecognizeBusinessLicense(ctx context.Context, imageURL string) (*dto.OCREnterpriseInfo, error) + RecognizeBusinessLicenseFromBytes(ctx context.Context, imageBytes []byte) (*dto.OCREnterpriseInfo, error) + + // 识别身份证 + RecognizeIDCard(ctx context.Context, imageURL string, side string) (*IDCardInfo, error) + + // 通用文字识别 + RecognizeGeneralText(ctx context.Context, imageURL string) (*GeneralTextResult, error) +} + +// IDCardInfo 身份证识别信息 +type IDCardInfo struct { + Name string `json:"name"` // 姓名 + IDCardNumber string `json:"id_card_number"` // 身份证号 + Gender string `json:"gender"` // 性别 + Nation string `json:"nation"` // 民族 + Birthday string `json:"birthday"` // 出生日期 + Address string `json:"address"` // 住址 + IssuingAgency string `json:"issuing_agency"` // 签发机关 + ValidPeriod string `json:"valid_period"` // 有效期限 + Confidence float64 `json:"confidence"` // 识别置信度 +} + +// GeneralTextResult 通用文字识别结果 +type GeneralTextResult struct { + Words []TextLine `json:"words"` // 识别的文字行 + Confidence float64 `json:"confidence"` // 整体置信度 +} + +// TextLine 文字行 +type TextLine struct { + Text string `json:"text"` // 文字内容 + Confidence float64 `json:"confidence"` // 置信度 +} diff --git a/internal/shared/sms/sms_service.go b/internal/shared/sms/sms_service.go index 97103b0..bacea8f 100644 --- a/internal/shared/sms/sms_service.go +++ b/internal/shared/sms/sms_service.go @@ -31,7 +31,6 @@ func NewAliSMSService(cfg config.SMSConfig, logger *zap.Logger) (*AliSMSService, if err != nil { return nil, fmt.Errorf("创建短信客户端失败: %w", err) } - return &AliSMSService{ client: client, config: cfg, diff --git a/internal/shared/storage/qiniu_storage_service.go b/internal/shared/storage/qiniu_storage_service.go new file mode 100644 index 0000000..c56e2ca --- /dev/null +++ b/internal/shared/storage/qiniu_storage_service.go @@ -0,0 +1,332 @@ +package storage + +import ( + "context" + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "fmt" + "io" + "path/filepath" + "strings" + "time" + + "github.com/qiniu/go-sdk/v7/auth/qbox" + "github.com/qiniu/go-sdk/v7/storage" + "go.uber.org/zap" +) + +// QiNiuStorageService 七牛云存储服务 +type QiNiuStorageService struct { + accessKey string + secretKey string + bucket string + domain string + region string + logger *zap.Logger + mac *qbox.Mac + bucketManager *storage.BucketManager +} + +// QiNiuStorageConfig 七牛云存储配置 +type QiNiuStorageConfig struct { + AccessKey string `yaml:"access_key"` + SecretKey string `yaml:"secret_key"` + Bucket string `yaml:"bucket"` + Domain string `yaml:"domain"` + Region string `yaml:"region"` +} + +// NewQiNiuStorageService 创建七牛云存储服务 +func NewQiNiuStorageService(accessKey, secretKey, bucket, domain, region string, logger *zap.Logger) *QiNiuStorageService { + mac := qbox.NewMac(accessKey, secretKey) + cfg := storage.Config{ + Region: &storage.Zone{ + RsHost: fmt.Sprintf("rs-%s.qiniu.com", region), + }, + } + bucketManager := storage.NewBucketManager(mac, &cfg) + + return &QiNiuStorageService{ + accessKey: accessKey, + secretKey: secretKey, + bucket: bucket, + domain: domain, + region: region, + logger: logger, + mac: mac, + bucketManager: bucketManager, + } +} + +// UploadFile 上传文件到七牛云 +func (s *QiNiuStorageService) UploadFile(ctx context.Context, fileBytes []byte, fileName string) (*UploadResult, error) { + s.logger.Info("开始上传文件到七牛云", + zap.String("file_name", fileName), + zap.Int("file_size", len(fileBytes)), + ) + + // 生成唯一的文件key + key := s.generateFileKey(fileName) + + // 创建上传凭证 + putPolicy := storage.PutPolicy{ + Scope: s.bucket, + } + upToken := putPolicy.UploadToken(s.mac) + + // 配置上传参数 + cfg := storage.Config{ + Region: &storage.Zone{ + RsHost: fmt.Sprintf("rs-%s.qiniu.com", s.region), + }, + } + formUploader := storage.NewFormUploader(&cfg) + ret := storage.PutRet{} + + // 上传文件 + err := formUploader.Put(ctx, &ret, upToken, key, strings.NewReader(string(fileBytes)), int64(len(fileBytes)), &storage.PutExtra{}) + if err != nil { + s.logger.Error("文件上传失败", + zap.String("file_name", fileName), + zap.String("key", key), + zap.Error(err), + ) + return nil, fmt.Errorf("文件上传失败: %w", err) + } + + // 构建文件URL + fileURL := s.GetFileURL(ctx, key) + + s.logger.Info("文件上传成功", + zap.String("file_name", fileName), + zap.String("key", key), + zap.String("url", fileURL), + ) + + return &UploadResult{ + Key: key, + URL: fileURL, + MimeType: s.getMimeType(fileName), + Size: int64(len(fileBytes)), + Hash: ret.Hash, + }, nil +} + +// GenerateUploadToken 生成上传凭证 +func (s *QiNiuStorageService) GenerateUploadToken(ctx context.Context, key string) (string, error) { + putPolicy := storage.PutPolicy{ + Scope: s.bucket, + // 设置过期时间(1小时) + Expires: uint64(time.Now().Add(time.Hour).Unix()), + } + + token := putPolicy.UploadToken(s.mac) + return token, nil +} + +// GetFileURL 获取文件访问URL +func (s *QiNiuStorageService) GetFileURL(ctx context.Context, key string) string { + // 如果是私有空间,需要生成带签名的URL + if s.isPrivateBucket() { + deadline := time.Now().Add(time.Hour).Unix() // 1小时过期 + privateAccessURL := storage.MakePrivateURL(s.mac, s.domain, key, deadline) + return privateAccessURL + } + + // 公开空间直接返回URL + return fmt.Sprintf("%s/%s", s.domain, key) +} + +// GetPrivateFileURL 获取私有文件访问URL +func (s *QiNiuStorageService) GetPrivateFileURL(ctx context.Context, key string, expires int64) (string, error) { + baseURL := s.GetFileURL(ctx, key) + + // TODO: 实际集成七牛云SDK生成私有URL + s.logger.Info("生成七牛云私有文件URL", + zap.String("key", key), + zap.Int64("expires", expires), + ) + + // 模拟返回私有URL + return fmt.Sprintf("%s?token=mock_private_token&expires=%d", baseURL, expires), nil +} + +// DeleteFile 删除文件 +func (s *QiNiuStorageService) DeleteFile(ctx context.Context, key string) error { + s.logger.Info("删除七牛云文件", zap.String("key", key)) + + err := s.bucketManager.Delete(s.bucket, key) + if err != nil { + s.logger.Error("删除文件失败", + zap.String("key", key), + zap.Error(err), + ) + return fmt.Errorf("删除文件失败: %w", err) + } + + s.logger.Info("文件删除成功", zap.String("key", key)) + return nil +} + +// FileExists 检查文件是否存在 +func (s *QiNiuStorageService) FileExists(ctx context.Context, key string) (bool, error) { + // TODO: 实际集成七牛云SDK检查文件存在性 + s.logger.Info("检查七牛云文件存在性", zap.String("key", key)) + + // 模拟文件存在 + return true, nil +} + +// GetFileInfo 获取文件信息 +func (s *QiNiuStorageService) GetFileInfo(ctx context.Context, key string) (*FileInfo, error) { + fileInfo, err := s.bucketManager.Stat(s.bucket, key) + if err != nil { + s.logger.Error("获取文件信息失败", + zap.String("key", key), + zap.Error(err), + ) + return nil, fmt.Errorf("获取文件信息失败: %w", err) + } + + return &FileInfo{ + Key: key, + Size: fileInfo.Fsize, + MimeType: fileInfo.MimeType, + Hash: fileInfo.Hash, + PutTime: fileInfo.PutTime, + }, nil +} + +// ListFiles 列出文件 +func (s *QiNiuStorageService) ListFiles(ctx context.Context, prefix string, limit int) ([]*FileInfo, error) { + entries, _, _, hasMore, err := s.bucketManager.ListFiles(s.bucket, prefix, "", "", limit) + if err != nil { + s.logger.Error("列出文件失败", + zap.String("prefix", prefix), + zap.Error(err), + ) + return nil, fmt.Errorf("列出文件失败: %w", err) + } + + var fileInfos []*FileInfo + for _, entry := range entries { + fileInfo := &FileInfo{ + Key: entry.Key, + Size: entry.Fsize, + MimeType: entry.MimeType, + Hash: entry.Hash, + PutTime: entry.PutTime, + } + fileInfos = append(fileInfos, fileInfo) + } + + _ = hasMore // 暂时忽略hasMore + return fileInfos, nil +} + +// generateFileKey 生成文件key +func (s *QiNiuStorageService) generateFileKey(fileName string) string { + // 生成时间戳 + timestamp := time.Now().Format("20060102_150405") + // 生成随机字符串 + randomStr := fmt.Sprintf("%d", time.Now().UnixNano()%1000000) + // 获取文件扩展名 + ext := filepath.Ext(fileName) + // 构建key: 日期/时间戳_随机数.扩展名 + key := fmt.Sprintf("certification/%s/%s_%s%s", + time.Now().Format("20060102"), timestamp, randomStr, ext) + + return key +} + +// getMimeType 根据文件名获取MIME类型 +func (s *QiNiuStorageService) getMimeType(fileName string) string { + ext := strings.ToLower(filepath.Ext(fileName)) + switch ext { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".pdf": + return "application/pdf" + case ".gif": + return "image/gif" + case ".bmp": + return "image/bmp" + case ".webp": + return "image/webp" + default: + return "application/octet-stream" + } +} + +// isPrivateBucket 判断是否为私有空间 +func (s *QiNiuStorageService) isPrivateBucket() bool { + // 这里可以根据配置或域名特征判断 + // 私有空间的域名通常包含特定标识 + return strings.Contains(s.domain, "private") || + strings.Contains(s.domain, "auth") || + strings.Contains(s.domain, "secure") +} + +// generateSignature 生成签名(用于私有空间访问) +func (s *QiNiuStorageService) generateSignature(data string) string { + h := hmac.New(sha1.New, []byte(s.secretKey)) + h.Write([]byte(data)) + return base64.URLEncoding.EncodeToString(h.Sum(nil)) +} + +// UploadFromReader 从Reader上传文件 +func (s *QiNiuStorageService) UploadFromReader(ctx context.Context, reader io.Reader, fileName string, fileSize int64) (*UploadResult, error) { + s.logger.Info("从Reader上传文件到七牛云", + zap.String("file_name", fileName), + zap.Int64("file_size", fileSize), + ) + + // 生成唯一的文件key + key := s.generateFileKey(fileName) + + // 创建上传凭证 + putPolicy := storage.PutPolicy{ + Scope: s.bucket, + } + upToken := putPolicy.UploadToken(s.mac) + + // 配置上传参数 + cfg := storage.Config{ + Region: &storage.Zone{ + RsHost: fmt.Sprintf("rs-%s.qiniu.com", s.region), + }, + } + formUploader := storage.NewFormUploader(&cfg) + ret := storage.PutRet{} + + // 上传文件 + err := formUploader.Put(ctx, &ret, upToken, key, reader, fileSize, &storage.PutExtra{}) + if err != nil { + s.logger.Error("从Reader上传文件失败", + zap.String("file_name", fileName), + zap.String("key", key), + zap.Error(err), + ) + return nil, fmt.Errorf("文件上传失败: %w", err) + } + + // 构建文件URL + fileURL := s.GetFileURL(ctx, key) + + s.logger.Info("从Reader上传文件成功", + zap.String("file_name", fileName), + zap.String("key", key), + zap.String("url", fileURL), + ) + + return &UploadResult{ + Key: key, + URL: fileURL, + MimeType: s.getMimeType(fileName), + Size: fileSize, + Hash: ret.Hash, + }, nil +} diff --git a/internal/shared/storage/storage_interface.go b/internal/shared/storage/storage_interface.go new file mode 100644 index 0000000..96991a5 --- /dev/null +++ b/internal/shared/storage/storage_interface.go @@ -0,0 +1,43 @@ +package storage + +import ( + "context" + "io" +) + +// StorageService 存储服务接口 +type StorageService interface { + // 文件上传 + UploadFile(ctx context.Context, fileBytes []byte, fileName string) (*UploadResult, error) + UploadFromReader(ctx context.Context, reader io.Reader, fileName string, size int64) (*UploadResult, error) + + // 生成上传凭证 + GenerateUploadToken(ctx context.Context, key string) (string, error) + + // 文件访问 + GetFileURL(ctx context.Context, key string) string + GetPrivateFileURL(ctx context.Context, key string, expires int64) (string, error) + + // 文件管理 + DeleteFile(ctx context.Context, key string) error + FileExists(ctx context.Context, key string) (bool, error) + GetFileInfo(ctx context.Context, key string) (*FileInfo, error) +} + +// UploadResult 文件上传结果 +type UploadResult struct { + URL string `json:"url"` // 文件访问URL + Key string `json:"key"` // 存储键名 + Size int64 `json:"size"` // 文件大小 + MimeType string `json:"mime_type"` // 文件类型 + Hash string `json:"hash"` // 文件哈希值 +} + +// FileInfo 文件信息 +type FileInfo struct { + Key string `json:"key"` // 存储键名 + Size int64 `json:"size"` // 文件大小 + MimeType string `json:"mime_type"` // 文件类型 + Hash string `json:"hash"` // 文件哈希值 + PutTime int64 `json:"put_time"` // 上传时间戳 +} diff --git a/scripts/set_timezone.sql b/scripts/set_timezone.sql new file mode 100644 index 0000000..be76411 --- /dev/null +++ b/scripts/set_timezone.sql @@ -0,0 +1,13 @@ +-- 设置时区为北京时间 +ALTER SYSTEM SET timezone = 'Asia/Shanghai'; + +ALTER SYSTEM SET log_timezone = 'Asia/Shanghai'; + +-- 重新加载配置 +SELECT pg_reload_conf (); + +-- 验证时区设置 +SELECT name, setting +FROM pg_settings +WHERE + name IN ('timezone', 'log_timezone'); \ No newline at end of file