From 6caf0f6141ab669b9fd6b42e2df2a04c2659d722 Mon Sep 17 00:00:00 2001 From: huang Date: Sat, 18 Apr 2026 09:06:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E5=AF=86=E7=A0=81=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E5=B0=86=E7=BA=BF=E4=B8=8B=E5=85=85=E5=80=BC=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E4=BB=8E=E7=99=BB=E5=BD=95=E5=AF=86=E7=A0=81=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=93=8D=E4=BD=9C=E5=AF=86=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 RedisSystemOperationPasswordKey 常量函数(pkg/constants/redis.go) - 新增 operation_password Service(Set/IsSet/Verify,bcrypt 哈希存 Redis) - 新增 SuperAdminHandler 及两个接口: - POST /api/admin/super-admin/operation-password(设置/重置,仅超级管理员) - GET /api/admin/super-admin/operation-password/status(查询是否已设置) - AgentRecharge.OfflinePay 操作密码验证从"查当前用户登录密码"改为"全局操作密码 Verify" - Bootstrap/路由/文档生成器同步注册 --- docs/admin-openapi.yaml | 115 ++++++++++++++++++ internal/bootstrap/handlers.go | 1 + internal/bootstrap/services.go | 6 +- internal/bootstrap/types.go | 1 + internal/handler/admin/super_admin.go | 71 +++++++++++ internal/model/dto/super_admin_dto.go | 12 ++ internal/routes/admin.go | 3 + internal/routes/super_admin.go | 28 +++++ internal/service/agent_recharge/service.go | 58 ++++----- .../service/operation_password/service.go | 68 +++++++++++ .../.openspec.yaml | 2 + .../super-admin-operation-password/design.md | 74 +++++++++++ .../proposal.md | 34 ++++++ .../specs/global-operation-password/spec.md | 58 +++++++++ .../super-admin-operation-password/tasks.md | 55 +++++++++ pkg/constants/redis.go | 11 ++ pkg/openapi/handlers.go | 1 + 17 files changed, 568 insertions(+), 30 deletions(-) create mode 100644 internal/handler/admin/super_admin.go create mode 100644 internal/model/dto/super_admin_dto.go create mode 100644 internal/routes/super_admin.go create mode 100644 internal/service/operation_password/service.go create mode 100644 openspec/changes/super-admin-operation-password/.openspec.yaml create mode 100644 openspec/changes/super-admin-operation-password/design.md create mode 100644 openspec/changes/super-admin-operation-password/proposal.md create mode 100644 openspec/changes/super-admin-operation-password/specs/global-operation-password/spec.md create mode 100644 openspec/changes/super-admin-operation-password/tasks.md diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 2f880c6..d9b9a7b 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -4940,6 +4940,12 @@ components: - threshold - amount type: object + DtoOperationPasswordStatusResponse: + properties: + is_set: + description: 操作密码是否已设置 + type: boolean + type: object DtoOrderItemResponse: properties: amount: @@ -6242,6 +6248,20 @@ components: description: 时效值(日期或月数) type: string type: object + DtoSetOperationPasswordRequest: + properties: + confirm_password: + description: 确认密码,必须与 password 一致 + type: string + password: + description: 新操作密码,长度 6~50 位 + maxLength: 50 + minLength: 6 + type: string + required: + - password + - confirm_password + type: object DtoSetSpeedLimitRequest: properties: speed_limit: @@ -21243,6 +21263,101 @@ paths: summary: 获取文件上传预签名 URL tags: - 对象存储 + /api/admin/super-admin/operation-password: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoSetOperationPasswordRequest' + responses: + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 设置/修改全局操作密码 + tags: + - 超级管理员 + /api/admin/super-admin/operation-password/status: + get: + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoOperationPasswordStatusResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询操作密码是否已设置 + tags: + - 超级管理员 /api/admin/wechat-configs: get: parameters: diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index dd4082f..2df53d4 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -133,5 +133,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate), Refund: admin.NewRefundHandler(svc.Refund), ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger), + SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword), } } diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 536b1df..3568017 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -41,6 +41,7 @@ import ( shopSvc "github.com/break/junhong_cmp_fiber/internal/service/shop" agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge" + operationPasswordSvc "github.com/break/junhong_cmp_fiber/internal/service/operation_password" pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling" refundSvc "github.com/break/junhong_cmp_fiber/internal/service/refund" shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission" @@ -101,6 +102,7 @@ type services struct { PackageActivation *packageSvc.ActivationService Refund *refundSvc.Service TrafficQuery *trafficSvc.QueryService + OperationPassword *operationPasswordSvc.Service } func initServices(s *stores, deps *Dependencies) *services { @@ -140,6 +142,7 @@ func initServices(s *stores, deps *Dependencies) *services { stopResumeService := iotCardSvc.NewStopResumeService(deps.Redis, s.IotCard, s.PackageUsage, s.DeviceSimBinding, deps.GatewayClient, deps.Logger) device := deviceSvc.New(deps.DB, deps.Redis, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient, s.AssetIdentifier) + operationPassword := operationPasswordSvc.New(deps.Redis) return &services{ Account: account, @@ -223,14 +226,15 @@ func initServices(s *stores, deps *Dependencies) *services { s.AgentWallet, s.AgentWalletTransaction, s.Shop, - s.Account, wechatConfig, accountAudit, + operationPassword, deps.Redis, deps.Logger, ), PackageActivation: packageActivation, TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage), + OperationPassword: operationPassword, Refund: refundSvc.New( deps.DB, s.RefundRequest, diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index 15c876e..4dbdc54 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -62,6 +62,7 @@ type Handlers struct { AgentRecharge *admin.AgentRechargeHandler Refund *admin.RefundHandler ClientWechat *app.ClientWechatHandler + SuperAdmin *admin.SuperAdminHandler } // Middlewares 封装所有中间件 diff --git a/internal/handler/admin/super_admin.go b/internal/handler/admin/super_admin.go new file mode 100644 index 0000000..893a9cc --- /dev/null +++ b/internal/handler/admin/super_admin.go @@ -0,0 +1,71 @@ +package admin + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/internal/service/operation_password" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// SuperAdminHandler 超级管理员专属操作处理器 +type SuperAdminHandler struct { + operationPasswordService *operation_password.Service +} + +// NewSuperAdminHandler 创建超级管理员处理器 +func NewSuperAdminHandler(operationPasswordService *operation_password.Service) *SuperAdminHandler { + return &SuperAdminHandler{ + operationPasswordService: operationPasswordService, + } +} + +// SetOperationPassword 设置/修改全局操作密码(仅超级管理员) +// POST /api/admin/super-admin/operation-password +func (h *SuperAdminHandler) SetOperationPassword(c *fiber.Ctx) error { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if userType != constants.UserTypeSuperAdmin { + return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + + var req dto.SetOperationPasswordRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + + if req.Password == "" || req.ConfirmPassword == "" { + return errors.New(errors.CodeInvalidParam) + } + + if req.Password != req.ConfirmPassword { + return errors.New(errors.CodeInvalidParam, "两次输入的密码不一致") + } + + if len(req.Password) < 6 || len(req.Password) > 50 { + return errors.New(errors.CodeInvalidParam, "密码长度必须在 6~50 位之间") + } + + ctx := c.UserContext() + if err := h.operationPasswordService.Set(ctx, req.Password); err != nil { + return err + } + + return response.Success(c, nil) +} + +// GetOperationPasswordStatus 查询操作密码是否已设置(仅超级管理员) +// GET /api/admin/super-admin/operation-password/status +func (h *SuperAdminHandler) GetOperationPasswordStatus(c *fiber.Ctx) error { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if userType != constants.UserTypeSuperAdmin { + return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + + ctx := c.UserContext() + isSet := h.operationPasswordService.IsSet(ctx) + + return response.Success(c, &dto.OperationPasswordStatusResponse{IsSet: isSet}) +} diff --git a/internal/model/dto/super_admin_dto.go b/internal/model/dto/super_admin_dto.go new file mode 100644 index 0000000..a5879e1 --- /dev/null +++ b/internal/model/dto/super_admin_dto.go @@ -0,0 +1,12 @@ +package dto + +// SetOperationPasswordRequest 设置全局操作密码请求 +type SetOperationPasswordRequest struct { + Password string `json:"password" validate:"required,min=6,max=50" required:"true" minLength:"6" maxLength:"50" description:"新操作密码,长度 6~50 位"` + ConfirmPassword string `json:"confirm_password" validate:"required" required:"true" description:"确认密码,必须与 password 一致"` +} + +// OperationPasswordStatusResponse 操作密码状态响应 +type OperationPasswordStatusResponse struct { + IsSet bool `json:"is_set" description:"操作密码是否已设置"` +} diff --git a/internal/routes/admin.go b/internal/routes/admin.go index 78690b9..a1ca163 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -122,4 +122,7 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.Refund != nil { registerRefundRoutes(authGroup, handlers.Refund, doc, basePath) } + if handlers.SuperAdmin != nil { + registerSuperAdminRoutes(authGroup, handlers.SuperAdmin, doc, basePath) + } } diff --git a/internal/routes/super_admin.go b/internal/routes/super_admin.go new file mode 100644 index 0000000..a5ff481 --- /dev/null +++ b/internal/routes/super_admin.go @@ -0,0 +1,28 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/admin" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +func registerSuperAdminRoutes(router fiber.Router, handler *admin.SuperAdminHandler, doc *openapi.Generator, basePath string) { + group := router.Group("/super-admin") + groupPath := basePath + "/super-admin" + + Register(group, doc, groupPath, "POST", "/operation-password", handler.SetOperationPassword, RouteSpec{ + Summary: "设置/修改全局操作密码", + Tags: []string{"超级管理员"}, + Input: new(dto.SetOperationPasswordRequest), + Auth: true, + }) + + Register(group, doc, groupPath, "GET", "/operation-password/status", handler.GetOperationPasswordStatus, RouteSpec{ + Summary: "查询操作密码是否已设置", + Tags: []string{"超级管理员"}, + Output: new(dto.OperationPasswordStatusResponse), + Auth: true, + }) +} diff --git a/internal/service/agent_recharge/service.go b/internal/service/agent_recharge/service.go index c7cf58b..d204893 100644 --- a/internal/service/agent_recharge/service.go +++ b/internal/service/agent_recharge/service.go @@ -11,7 +11,6 @@ import ( "github.com/redis/go-redis/v9" "go.uber.org/zap" - "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/model" @@ -27,6 +26,11 @@ type AuditServiceInterface interface { LogOperation(ctx context.Context, log *model.AccountOperationLog) } +// OperationPasswordServiceInterface 全局操作密码服务接口 +type OperationPasswordServiceInterface interface { + Verify(ctx context.Context, inputPassword string) error +} + // WechatConfigServiceInterface 支付配置服务接口 type WechatConfigServiceInterface interface { GetActiveConfig(ctx context.Context) (*model.WechatConfig, error) @@ -35,16 +39,16 @@ type WechatConfigServiceInterface interface { // Service 代理预充值业务服务 // 负责代理钱包充值订单的创建、线下确认、回调处理等业务逻辑 type Service struct { - db *gorm.DB - agentRechargeStore *postgres.AgentRechargeStore - agentWalletStore *postgres.AgentWalletStore - agentWalletTxStore *postgres.AgentWalletTransactionStore - shopStore *postgres.ShopStore - accountStore *postgres.AccountStore - wechatConfigService WechatConfigServiceInterface - auditService AuditServiceInterface - redis *redis.Client - logger *zap.Logger + db *gorm.DB + agentRechargeStore *postgres.AgentRechargeStore + agentWalletStore *postgres.AgentWalletStore + agentWalletTxStore *postgres.AgentWalletTransactionStore + shopStore *postgres.ShopStore + wechatConfigService WechatConfigServiceInterface + auditService AuditServiceInterface + operationPasswordService OperationPasswordServiceInterface + redis *redis.Client + logger *zap.Logger } // New 创建代理预充值服务实例 @@ -54,23 +58,23 @@ func New( agentWalletStore *postgres.AgentWalletStore, agentWalletTxStore *postgres.AgentWalletTransactionStore, shopStore *postgres.ShopStore, - accountStore *postgres.AccountStore, wechatConfigService WechatConfigServiceInterface, auditService AuditServiceInterface, + operationPasswordService OperationPasswordServiceInterface, rdb *redis.Client, logger *zap.Logger, ) *Service { return &Service{ - db: db, - agentRechargeStore: agentRechargeStore, - agentWalletStore: agentWalletStore, - agentWalletTxStore: agentWalletTxStore, - shopStore: shopStore, - accountStore: accountStore, - wechatConfigService: wechatConfigService, - auditService: auditService, - redis: rdb, - logger: logger, + db: db, + agentRechargeStore: agentRechargeStore, + agentWalletStore: agentWalletStore, + agentWalletTxStore: agentWalletTxStore, + shopStore: shopStore, + wechatConfigService: wechatConfigService, + auditService: auditService, + operationPasswordService: operationPasswordService, + redis: rdb, + logger: logger, } } @@ -175,13 +179,9 @@ func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOffline return nil, errors.New(errors.CodeForbidden, "仅平台管理员可确认线下充值") } - // 验证操作密码 - account, err := s.accountStore.GetByID(ctx, userID) - if err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询操作人账号失败") - } - if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(req.OperationPassword)); err != nil { - return nil, errors.New(errors.CodeInvalidParam, "操作密码错误") + // 验证全局操作密码 + if err := s.operationPasswordService.Verify(ctx, req.OperationPassword); err != nil { + return nil, err } record, err := s.agentRechargeStore.GetByID(ctx, id) diff --git a/internal/service/operation_password/service.go b/internal/service/operation_password/service.go new file mode 100644 index 0000000..b262d61 --- /dev/null +++ b/internal/service/operation_password/service.go @@ -0,0 +1,68 @@ +// Package operation_password 提供全局操作密码的设置、查询与验证服务 +// 操作密码以 bcrypt 哈希存储于 Redis,仅超级管理员可维护 +package operation_password + +import ( + "context" + + "github.com/redis/go-redis/v9" + "golang.org/x/crypto/bcrypt" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// Service 全局操作密码服务 +type Service struct { + redis *redis.Client +} + +// New 创建操作密码服务实例 +func New(rdb *redis.Client) *Service { + return &Service{redis: rdb} +} + +// Set 设置/修改全局操作密码 +// 对明文密码做 bcrypt 哈希后永久存储到 Redis(无 TTL) +func (s *Service) Set(ctx context.Context, password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return errors.Wrap(errors.CodeInternalError, err, "操作密码加密失败") + } + + key := constants.RedisSystemOperationPasswordKey() + if err := s.redis.Set(ctx, key, string(hash), 0).Err(); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "操作密码存储失败") + } + + return nil +} + +// IsSet 查询操作密码是否已设置 +func (s *Service) IsSet(ctx context.Context) bool { + key := constants.RedisSystemOperationPasswordKey() + n, err := s.redis.Exists(ctx, key).Result() + if err != nil { + return false + } + return n > 0 +} + +// Verify 验证操作密码是否正确 +// key 不存在时返回"操作密码未设置"错误,密码错误时返回"操作密码错误" +func (s *Service) Verify(ctx context.Context, inputPassword string) error { + key := constants.RedisSystemOperationPasswordKey() + hash, err := s.redis.Get(ctx, key).Result() + if err == redis.Nil { + return errors.New(errors.CodeInvalidParam, "操作密码未设置,请联系超级管理员") + } + if err != nil { + return errors.Wrap(errors.CodeInternalError, err, "查询操作密码失败") + } + + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(inputPassword)); err != nil { + return errors.New(errors.CodeInvalidParam, "操作密码错误") + } + + return nil +} diff --git a/openspec/changes/super-admin-operation-password/.openspec.yaml b/openspec/changes/super-admin-operation-password/.openspec.yaml new file mode 100644 index 0000000..863bff1 --- /dev/null +++ b/openspec/changes/super-admin-operation-password/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-17 diff --git a/openspec/changes/super-admin-operation-password/design.md b/openspec/changes/super-admin-operation-password/design.md new file mode 100644 index 0000000..ac2580a --- /dev/null +++ b/openspec/changes/super-admin-operation-password/design.md @@ -0,0 +1,74 @@ +## Context + +当前代理充值线下确认接口(`OfflinePay`)通过将 `operation_password` 与操作人自己的登录密码(`account.Password`)做 bcrypt 对比来实现操作密码验证。这个机制存在两个问题: + +1. **缺乏独立性**:操作密码与登录密码耦合,修改登录密码即等于修改操作密码,无法独立管控。 +2. **安全边界不清**:需要操作密码的敏感操作(如确认大额资金流转)应由超级管理员统一管控,而非各操作人分散管理。 + +设计决策:操作密码存 Redis,不开新表。原因:操作密码是全局单一配置,没有历史版本需求,Redis 的 SET/GET 完全满足需求,避免引入新表的维护成本。 + +## Goals / Non-Goals + +**Goals:** +- 超级管理员可以设置/修改全局操作密码(存 Redis,bcrypt 哈希) +- 提供查询接口返回操作密码是否已设置(不暴露密码本身) +- `OfflinePay` 的操作密码验证从"当前用户登录密码"改为"全局操作密码" +- 封装 `VerifyOperationPassword` 工具函数,供后续接口统一复用 + +**Non-Goals:** +- 不做操作密码的过期机制(非当前需求) +- 不做操作密码历史记录 +- 不修改除 `agent_recharge.OfflinePay` 以外的其他接口(其他接口暂无此需求) + +## Decisions + +### 决策1:存储介质选 Redis 而非数据库新表 + +**选择**:Redis `SET system:operation_password ` 永久存储(无 TTL)。 + +**理由**:全局单一配置,无历史记录需求,Redis 的简单 KV 足够。Key 命名遵循项目规范,定义在 `pkg/constants/redis.go`。 + +**备选**:新建 `tb_system_config` 表 → 引入表管理、迁移、GORM 操作,过度设计。 + +### 决策2:新接口归属路径 + +**选择**:`POST /api/admin/super-admin/operation-password`(设置/修改)和 `GET /api/admin/super-admin/operation-password/status`(查询状态)。 + +**理由**:`/super-admin/` 路径前缀清晰表达"仅超级管理员"语义,与现有路由命名惯例一致。Handler 层检查 `userType == UserTypeSuperAdmin`,非超级管理员返回 403。 + +### 决策3:Service 层封装独立的 OperationPassword 服务 + +**选择**:新建 `internal/service/operation_password/service.go`,提供 `Set(ctx, password)` 和 `Verify(ctx, inputPassword) error` 两个方法。 + +**理由**:将操作密码逻辑从 `agent_recharge` Service 中解耦,后续接口可直接注入复用,避免逻辑散落多处。 + +### 决策4:未设置时的行为 + +**选择**:操作密码未设置时,`Verify` 返回"操作密码未设置,请联系超级管理员"错误,拒绝操作。 + +**理由**:不降级为登录密码(安全边界清晰)。初次上线前超级管理员需先设置操作密码。 + +### 决策5:bcrypt 哈希存 Redis + +**选择**:Set 时用 `bcrypt.GenerateFromPassword` 哈希后存储,Verify 时用 `bcrypt.CompareHashAndPassword` 对比。 + +**理由**:即使 Redis 被访问,也无法还原明文密码。与现有登录密码处理方式一致。 + +## Risks / Trade-offs + +**[风险] 初次上线前操作密码未设置** +→ `OfflinePay` 接口会返回"操作密码未设置"错误。需在上线前由超级管理员设置初始操作密码。 + +**[风险] Redis 数据丢失** +→ 如 Redis 数据被清空,操作密码丢失,所有需要操作密码的接口将拒绝服务。 +→ 缓解:Redis 持久化(AOF/RDB)已在生产环境启用;超级管理员可随时重设。 + +**[风险] bcrypt 计算开销** +→ bcrypt 默认 cost=10,约 100ms/次。此接口调用频率极低(管理操作),不影响系统整体性能。 + +## Migration Plan + +1. 部署代码变更(新接口 + Redis 验证逻辑) +2. 超级管理员登录后台,调用 `POST /api/admin/super-admin/operation-password` 设置初始操作密码 +3. 验证 `GET /api/admin/super-admin/operation-password/status` 返回已设置 +4. 验证 `OfflinePay` 接口使用新操作密码可正常通过 diff --git a/openspec/changes/super-admin-operation-password/proposal.md b/openspec/changes/super-admin-operation-password/proposal.md new file mode 100644 index 0000000..685b0cf --- /dev/null +++ b/openspec/changes/super-admin-operation-password/proposal.md @@ -0,0 +1,34 @@ +## Why + +系统中需要操作密码验证的接口(如代理充值线下确认)目前使用**当前登录用户自己的登录密码**作为操作密码,缺乏独立的操作层安全机制。需要引入一个**全局操作密码**,仅由超级管理员设置和修改,所有需要操作密码的接口统一验证这一密码,实现"操作密码"与"登录密码"的职责分离。 + +## What Changes + +- 新增 `POST /api/admin/super-admin/operation-password` 接口:超级管理员设置/修改全局操作密码 +- 新增 `GET /api/admin/super-admin/operation-password/status` 接口:查询操作密码是否已设置 +- 全局操作密码以 bcrypt 哈希值存储于 Redis,Key 定义在 `pkg/constants/redis.go` +- `agent_recharge` `OfflinePay` 的操作密码验证逻辑从"当前用户登录密码"改为"全局操作密码" +- 新增统一工具函数 `VerifyOperationPassword(ctx, inputPassword)` 供后续接口复用 + +## Capabilities + +### New Capabilities + +- `global-operation-password`:全局操作密码的设置、修改、验证能力,存储于 Redis,仅超级管理员可维护 + +### Modified Capabilities + +(无已有 spec 变更) + +## Impact + +| 层级 | 文件 | 变更类型 | +|------|------|---------| +| Handler | `internal/handler/admin/`(新建 `super_admin.go`) | 新建 | +| Service | `internal/service/`(新建 `operation_password/service.go`) | 新建 | +| Service | `internal/service/agent_recharge/service.go` | 修改验证逻辑 | +| Constants | `pkg/constants/redis.go` | 新增 Redis Key 常量 | +| Bootstrap | `internal/bootstrap/handlers.go`、`services.go` | 注册新组件 | +| Routes | 路由注册文件 | 新增路由 | + +**依赖**:Redis(已有,无需新增依赖) diff --git a/openspec/changes/super-admin-operation-password/specs/global-operation-password/spec.md b/openspec/changes/super-admin-operation-password/specs/global-operation-password/spec.md new file mode 100644 index 0000000..6a202d4 --- /dev/null +++ b/openspec/changes/super-admin-operation-password/specs/global-operation-password/spec.md @@ -0,0 +1,58 @@ +## ADDED Requirements + +### Requirement: 超级管理员可设置和修改全局操作密码 + +系统 SHALL 提供 `POST /api/admin/super-admin/operation-password` 接口,仅允许 `user_type=1`(超级管理员)调用,用于设置或修改全局操作密码。密码以 bcrypt 哈希值存储于 Redis。 + +#### Scenario: 超级管理员首次设置操作密码 + +- **WHEN** 超级管理员调用 `POST /api/admin/super-admin/operation-password`,传入合法的 `password` 和 `confirm_password`(两者一致) +- **THEN** 系统将 bcrypt 哈希后的密码写入 Redis,返回 200 成功 + +#### Scenario: 超级管理员修改已有操作密码 + +- **WHEN** 操作密码已存在,超级管理员重新调用设置接口传入新密码 +- **THEN** 系统覆盖 Redis 中的旧哈希值,返回 200 成功 + +#### Scenario: 非超级管理员调用被拒绝 + +- **WHEN** `user_type != 1` 的账号调用 `POST /api/admin/super-admin/operation-password` +- **THEN** 系统返回 403,错误消息"仅超级管理员可设置操作密码" + +#### Scenario: 两次密码不一致 + +- **WHEN** 超级管理员传入的 `password` 与 `confirm_password` 不一致 +- **THEN** 系统返回 400,错误消息"两次输入的密码不一致" + +### Requirement: 可查询操作密码设置状态 + +系统 SHALL 提供 `GET /api/admin/super-admin/operation-password/status` 接口,返回操作密码是否已设置(布尔值),不返回密码本身,仅超级管理员可调用。 + +#### Scenario: 查询已设置状态 + +- **WHEN** 操作密码已设置,超级管理员调用状态查询接口 +- **THEN** 返回 `{"is_set": true}` + +#### Scenario: 查询未设置状态 + +- **WHEN** 操作密码未设置(Redis 中无对应 key),超级管理员调用状态查询接口 +- **THEN** 返回 `{"is_set": false}` + +### Requirement: 操作密码验证使用全局操作密码 + +系统 SHALL 将所有需要操作密码验证的接口(目前为 `agent-recharges` 线下充值确认)改为验证全局操作密码,而非当前登录用户的登录密码。 + +#### Scenario: 操作密码正确时通过验证 + +- **WHEN** 调用需要操作密码的接口,传入正确的全局操作密码 +- **THEN** 验证通过,接口正常执行后续业务逻辑 + +#### Scenario: 操作密码错误时拒绝 + +- **WHEN** 调用需要操作密码的接口,传入错误的密码 +- **THEN** 系统返回 400,错误消息"操作密码错误" + +#### Scenario: 操作密码未设置时拒绝 + +- **WHEN** Redis 中无操作密码,调用需要操作密码的接口 +- **THEN** 系统返回 400,错误消息"操作密码未设置,请联系超级管理员" diff --git a/openspec/changes/super-admin-operation-password/tasks.md b/openspec/changes/super-admin-operation-password/tasks.md new file mode 100644 index 0000000..0c119d3 --- /dev/null +++ b/openspec/changes/super-admin-operation-password/tasks.md @@ -0,0 +1,55 @@ +## 1. Redis Key 常量 + +- [x] 1.1 在 `pkg/constants/redis.go` 中添加全局操作密码 Redis Key 生成函数 `RedisSystemOperationPasswordKey() string`,返回固定字符串 `"system:operation_password"` +- [x] 1.2 运行 `lsp_diagnostics` 确认 `redis.go` 无错误 + +## 2. DTO 层 + +- [x] 2.1 新建 `internal/model/dto/super_admin_dto.go`,定义以下结构体: + - `SetOperationPasswordRequest`:字段 `Password`(`validate:"required,min=6,max=50"`)、`ConfirmPassword`(`validate:"required"`),含 description 标签 + - `OperationPasswordStatusResponse`:字段 `IsSet bool`,含 description 标签 +- [x] 2.2 运行 `lsp_diagnostics` 确认 `super_admin_dto.go` 无错误 + +## 3. OperationPassword Service + +- [x] 3.1 新建 `internal/service/operation_password/service.go`,实现以下方法: + - `Set(ctx, password string) error`:bcrypt 哈希后写入 Redis(永久存储,无 TTL) + - `IsSet(ctx) bool`:查询 Redis key 是否存在 + - `Verify(ctx, inputPassword string) error`:从 Redis 读取哈希值,用 `bcrypt.CompareHashAndPassword` 对比;key 不存在时返回"操作密码未设置,请联系超级管理员",密码错误时返回"操作密码错误" + - Service 结构体通过构造函数注入 `*redis.Client` +- [x] 3.2 运行 `lsp_diagnostics` 确认 `operation_password/service.go` 无错误 + +## 4. SuperAdmin Handler + +- [x] 4.1 新建 `internal/handler/admin/super_admin.go`,实现以下接口: + - `SetOperationPassword`(`POST /api/admin/super-admin/operation-password`):检查 `user_type == UserTypeSuperAdmin`,验证两次密码一致,调用 `operationPasswordService.Set()` + - `GetOperationPasswordStatus`(`GET /api/admin/super-admin/operation-password/status`):检查 `user_type == UserTypeSuperAdmin`,调用 `IsSet()` 返回状态 + - 添加 Handler 方法中文注释(含 HTTP 方法和路径) +- [x] 4.2 运行 `lsp_diagnostics` 确认 `super_admin.go` 无错误 + +## 5. Bootstrap 注册 + +- [x] 5.1 在 `internal/bootstrap/services.go` 中注册 `OperationPasswordService`(注入 Redis 客户端) +- [x] 5.2 在 `internal/bootstrap/handlers.go` 中注册 `SuperAdminHandler`(注入 `OperationPasswordService`) +- [x] 5.3 在路由文件中注册两个新接口(`POST /api/admin/super-admin/operation-password` 和 `GET /api/admin/super-admin/operation-password/status`),路由需经过 admin 认证中间件 +- [x] 5.4 运行 `lsp_diagnostics` 确认 bootstrap 和路由文件无错误 + +## 6. 文档生成器更新 + +- [x] 6.1 在 `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 的 `Handlers` 结构体中注册 `SuperAdminHandler`(参考现有 handler 注册方式) + +## 7. 修改 AgentRecharge 操作密码验证逻辑 + +- [x] 7.1 在 `internal/service/agent_recharge/service.go` 的 `OfflinePay` 方法中,将操作密码验证从"查询当前用户账号 + bcrypt 对比登录密码"改为"调用 `operationPasswordService.Verify(ctx, req.OperationPassword)`" +- [x] 7.2 在 `agent_recharge/service.go` 的 Service 结构体中注入 `operationPasswordService`,在构造函数中传入 +- [x] 7.3 在 `internal/bootstrap/services.go` 中更新 `AgentRechargeService` 的构造调用,传入 `OperationPasswordService` +- [x] 7.4 运行 `lsp_diagnostics` 确认 `agent_recharge/service.go` 和 bootstrap 相关文件无错误 + +## 8. 接口验证 + +- [ ] 8.1 调用 `GET /api/admin/super-admin/operation-password/status`,验证返回 `{"is_set": false}`(初始状态) +- [ ] 8.2 调用 `POST /api/admin/super-admin/operation-password`(传入合法密码),验证返回 200 +- [ ] 8.3 调用 `GET /api/admin/super-admin/operation-password/status`,验证返回 `{"is_set": true}` +- [ ] 8.4 调用 `OfflinePay` 接口传入旧登录密码,验证返回"操作密码错误"(旧密码已失效) +- [ ] 8.5 调用 `OfflinePay` 接口传入新设置的操作密码,验证正常通过 +- [ ] 8.6 使用非超级管理员账号调用设置接口,验证返回 403 diff --git a/pkg/constants/redis.go b/pkg/constants/redis.go index 3b66920..d7158b5 100644 --- a/pkg/constants/redis.go +++ b/pkg/constants/redis.go @@ -442,6 +442,17 @@ func RedisPollingConfigChangedChannel() string { return "polling:config:changed" } +// ======================================== +// 系统全局配置 Redis Key +// ======================================== + +// RedisSystemOperationPasswordKey 全局操作密码 Redis 键 +// 用途:存储超级管理员设置的全局操作密码(bcrypt 哈希值) +// 过期时间:永久存储(无 TTL) +func RedisSystemOperationPasswordKey() string { + return "system:operation_password" +} + // ======================================== // 支付配置缓存 Redis Key // ======================================== diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 305a7b9..fae8ec9 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -63,5 +63,6 @@ func BuildDocHandlers() *bootstrap.Handlers { AgentRecharge: admin.NewAgentRechargeHandler(nil, nil), Refund: admin.NewRefundHandler(nil), ClientWechat: app.NewClientWechatHandler(nil, nil, nil), + SuperAdmin: admin.NewSuperAdminHandler(nil), } }