feat: 新增全局操作密码功能,将线下充值验证从登录密码改为统一操作密码
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
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:
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -62,6 +62,7 @@ type Handlers struct {
|
||||
AgentRecharge *admin.AgentRechargeHandler
|
||||
Refund *admin.RefundHandler
|
||||
ClientWechat *app.ClientWechatHandler
|
||||
SuperAdmin *admin.SuperAdminHandler
|
||||
}
|
||||
|
||||
// Middlewares 封装所有中间件
|
||||
|
||||
71
internal/handler/admin/super_admin.go
Normal file
71
internal/handler/admin/super_admin.go
Normal 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})
|
||||
}
|
||||
12
internal/model/dto/super_admin_dto.go
Normal file
12
internal/model/dto/super_admin_dto.go
Normal 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:"操作密码是否已设置"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
28
internal/routes/super_admin.go
Normal file
28
internal/routes/super_admin.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
68
internal/service/operation_password/service.go
Normal file
68
internal/service/operation_password/service.go
Normal 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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-17
|
||||
74
openspec/changes/super-admin-operation-password/design.md
Normal file
74
openspec/changes/super-admin-operation-password/design.md
Normal file
@@ -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 <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。
|
||||
|
||||
### 决策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` 接口使用新操作密码可正常通过
|
||||
34
openspec/changes/super-admin-operation-password/proposal.md
Normal file
34
openspec/changes/super-admin-operation-password/proposal.md
Normal file
@@ -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(已有,无需新增依赖)
|
||||
@@ -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,错误消息"操作密码未设置,请联系超级管理员"
|
||||
55
openspec/changes/super-admin-operation-password/tasks.md
Normal file
55
openspec/changes/super-admin-operation-password/tasks.md
Normal 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
|
||||
@@ -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
|
||||
// ========================================
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user