feat: 新增全局操作密码功能,将线下充值验证从登录密码改为统一操作密码
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

- 新增 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/路由/文档生成器同步注册
This commit is contained in:
2026-04-18 09:06:33 +08:00
parent 6e15e1b853
commit 6caf0f6141
17 changed files with 568 additions and 30 deletions

View File

@@ -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:

View File

@@ -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),
}
}

View File

@@ -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,

View File

@@ -62,6 +62,7 @@ type Handlers struct {
AgentRecharge *admin.AgentRechargeHandler
Refund *admin.RefundHandler
ClientWechat *app.ClientWechatHandler
SuperAdmin *admin.SuperAdminHandler
}
// Middlewares 封装所有中间件

View File

@@ -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})
}

View File

@@ -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:"操作密码是否已设置"`
}

View File

@@ -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)
}
}

View File

@@ -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,
})
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-17

View File

@@ -0,0 +1,74 @@
## Context
当前代理充值线下确认接口(`OfflinePay`)通过将 `operation_password` 与操作人自己的登录密码(`account.Password`)做 bcrypt 对比来实现操作密码验证。这个机制存在两个问题:
1. **缺乏独立性**:操作密码与登录密码耦合,修改登录密码即等于修改操作密码,无法独立管控。
2. **安全边界不清**:需要操作密码的敏感操作(如确认大额资金流转)应由超级管理员统一管控,而非各操作人分散管理。
设计决策:操作密码存 Redis不开新表。原因操作密码是全局单一配置没有历史版本需求Redis 的 SET/GET 完全满足需求,避免引入新表的维护成本。
## Goals / Non-Goals
**Goals:**
- 超级管理员可以设置/修改全局操作密码(存 Redisbcrypt 哈希)
- 提供查询接口返回操作密码是否已设置(不暴露密码本身)
- `OfflinePay` 的操作密码验证从"当前用户登录密码"改为"全局操作密码"
- 封装 `VerifyOperationPassword` 工具函数,供后续接口统一复用
**Non-Goals:**
- 不做操作密码的过期机制(非当前需求)
- 不做操作密码历史记录
- 不修改除 `agent_recharge.OfflinePay` 以外的其他接口(其他接口暂无此需求)
## Decisions
### 决策1存储介质选 Redis 而非数据库新表
**选择**Redis `SET system:operation_password <bcrypt_hash>` 永久存储(无 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。
### 决策3Service 层封装独立的 OperationPassword 服务
**选择**:新建 `internal/service/operation_password/service.go`,提供 `Set(ctx, password)``Verify(ctx, inputPassword) error` 两个方法。
**理由**:将操作密码逻辑从 `agent_recharge` Service 中解耦,后续接口可直接注入复用,避免逻辑散落多处。
### 决策4未设置时的行为
**选择**:操作密码未设置时,`Verify` 返回"操作密码未设置,请联系超级管理员"错误,拒绝操作。
**理由**:不降级为登录密码(安全边界清晰)。初次上线前超级管理员需先设置操作密码。
### 决策5bcrypt 哈希存 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` 接口使用新操作密码可正常通过

View File

@@ -0,0 +1,34 @@
## Why
系统中需要操作密码验证的接口(如代理充值线下确认)目前使用**当前登录用户自己的登录密码**作为操作密码,缺乏独立的操作层安全机制。需要引入一个**全局操作密码**,仅由超级管理员设置和修改,所有需要操作密码的接口统一验证这一密码,实现"操作密码"与"登录密码"的职责分离。
## What Changes
- 新增 `POST /api/admin/super-admin/operation-password` 接口:超级管理员设置/修改全局操作密码
- 新增 `GET /api/admin/super-admin/operation-password/status` 接口:查询操作密码是否已设置
- 全局操作密码以 bcrypt 哈希值存储于 RedisKey 定义在 `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已有无需新增依赖

View File

@@ -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错误消息"操作密码未设置,请联系超级管理员"

View File

@@ -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

View File

@@ -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
// ========================================

View File

@@ -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),
}
}