feat: 完成B端认证系统和商户管理模块测试补全
主要变更: - 新增B端认证系统(后台+H5):登录、登出、Token刷新、密码修改 - 完善商户管理和商户账号管理功能 - 补全单元测试(ShopService: 72.5%, ShopAccountService: 79.8%) - 新增集成测试(商户管理+商户账号管理) - 归档OpenSpec提案(add-shop-account-management, implement-b-end-auth-system) - 完善文档(使用指南、API文档、认证架构说明) 测试统计: - 13个测试套件,37个测试用例,100%通过率 - 平均覆盖率76.2%,达标 OpenSpec验证:通过(strict模式)
This commit is contained in:
505
docs/auth-usage-guide.md
Normal file
505
docs/auth-usage-guide.md
Normal file
@@ -0,0 +1,505 @@
|
||||
# B 端认证系统使用指南
|
||||
|
||||
本文档指导开发者如何在君鸿卡管系统中使用 B 端认证功能,包括在新路由中集成认证、获取用户信息、撤销 Token 等操作。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [快速开始](#快速开始)
|
||||
- [在路由中集成认证](#在路由中集成认证)
|
||||
- [获取当前用户信息](#获取当前用户信息)
|
||||
- [Token 管理](#token-管理)
|
||||
- [常见问题](#常见问题)
|
||||
- [最佳实践](#最佳实践)
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
###认证系统已集成到项目的 bootstrap 流程中,无需额外配置即可使用。
|
||||
|
||||
### 核心组件
|
||||
|
||||
| 组件 | 位置 | 用途 |
|
||||
|------|------|------|
|
||||
| TokenManager | `pkg/auth/token.go` | Token 生成、验证、撤销 |
|
||||
| AuthService | `internal/service/auth/service.go` | 认证业务逻辑 |
|
||||
| Auth Middleware | `pkg/middleware/auth.go` | 认证中间件 |
|
||||
| Auth Handler | `internal/handler/{admin,h5}/auth.go` | 认证接口处理器 |
|
||||
|
||||
### 配置项
|
||||
|
||||
在 `configs/config.yaml` 中配置 Token 有效期:
|
||||
|
||||
```yaml
|
||||
jwt:
|
||||
secret_key: "your-secret-key-here"
|
||||
token_duration: 3600 # JWT 有效期(个人客户,秒)
|
||||
access_token_ttl: 86400 # Access Token 有效期(B端,秒)
|
||||
refresh_token_ttl: 604800 # Refresh Token 有效期(B端,秒)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 在路由中集成认证
|
||||
|
||||
### 1. 使用现有的认证中间件
|
||||
|
||||
后台和 H5 的认证中间件已在 `internal/bootstrap/middlewares.go` 中配置好。
|
||||
|
||||
**后台路由示例**:
|
||||
|
||||
```go
|
||||
// internal/routes/admin.go
|
||||
func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, middlewares *bootstrap.Middlewares, doc *openapi.Generator, basePath string) {
|
||||
// 公开路由(无需认证)
|
||||
router.Post(basePath+"/login", handlers.AdminAuth.Login)
|
||||
router.Post(basePath+"/refresh-token", handlers.AdminAuth.RefreshToken)
|
||||
|
||||
// 受保护路由(需要认证)
|
||||
authGroup := router.Group("", middlewares.AdminAuth)
|
||||
authGroup.Post(basePath+"/logout", handlers.AdminAuth.Logout)
|
||||
authGroup.Get(basePath+"/me", handlers.AdminAuth.GetMe)
|
||||
authGroup.Post(basePath+"/password", handlers.AdminAuth.ChangePassword)
|
||||
|
||||
// 添加其他需要认证的路由
|
||||
authGroup.Get(basePath+"/users", handlers.User.List)
|
||||
authGroup.Post(basePath+"/users", handlers.User.Create)
|
||||
}
|
||||
```
|
||||
|
||||
**H5 路由示例**:
|
||||
|
||||
```go
|
||||
// internal/routes/h5.go
|
||||
func RegisterH5Routes(router fiber.Router, handlers *bootstrap.Handlers, middlewares *bootstrap.Middlewares, doc *openapi.Generator, basePath string) {
|
||||
// 公开路由
|
||||
router.Post(basePath+"/login", handlers.H5Auth.Login)
|
||||
|
||||
// 受保护路由
|
||||
authGroup := router.Group("", middlewares.H5Auth)
|
||||
authGroup.Get(basePath+"/orders", handlers.Order.List)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 创建自定义认证中间件
|
||||
|
||||
如果需要自定义认证逻辑(例如特殊权限检查),可以创建自己的中间件:
|
||||
|
||||
```go
|
||||
// internal/middleware/custom_auth.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
pkgmiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// SuperAdminOnly 只允许超级管理员访问
|
||||
func SuperAdminOnly(tokenManager *auth.TokenManager) fiber.Handler {
|
||||
return pkgmiddleware.Auth(pkgmiddleware.AuthConfig{
|
||||
TokenValidator: func(token string) (*pkgmiddleware.UserContextInfo, error) {
|
||||
tokenInfo, err := tokenManager.ValidateAccessToken(context.Background(), token)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidToken, "令牌无效")
|
||||
}
|
||||
|
||||
// 只允许超级管理员
|
||||
if tokenInfo.UserType != constants.UserTypeSuperAdmin {
|
||||
return nil, errors.New(errors.CodeForbidden, "权限不足")
|
||||
}
|
||||
|
||||
return &pkgmiddleware.UserContextInfo{
|
||||
UserID: tokenInfo.UserID,
|
||||
UserType: tokenInfo.UserType,
|
||||
ShopID: tokenInfo.ShopID,
|
||||
EnterpriseID: tokenInfo.EnterpriseID,
|
||||
}, nil
|
||||
},
|
||||
SkipPaths: []string{}, // 无公开路径
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 获取当前用户信息
|
||||
|
||||
### 1. 在 Handler 中获取用户 ID
|
||||
|
||||
使用 `pkg/middleware` 提供的工具函数:
|
||||
|
||||
```go
|
||||
// internal/handler/admin/user.go
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
userService *user.Service
|
||||
}
|
||||
|
||||
func (h *UserHandler) GetProfile(c *fiber.Ctx) error {
|
||||
// 从 context 获取当前用户 ID
|
||||
userID := middleware.GetUserIDFromContext(c.UserContext())
|
||||
if userID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
// 使用 userID 查询用户信息
|
||||
profile, err := h.userService.GetProfile(c.UserContext(), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, profile)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 获取完整的用户上下文
|
||||
|
||||
```go
|
||||
func (h *UserHandler) DoSomething(c *fiber.Ctx) error {
|
||||
ctx := c.UserContext()
|
||||
|
||||
// 获取各种用户信息
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
shopID := middleware.GetShopIDFromContext(ctx)
|
||||
enterpriseID := middleware.GetEnterpriseIDFromContext(ctx)
|
||||
|
||||
// 根据用户类型执行不同逻辑
|
||||
switch userType {
|
||||
case constants.UserTypeSuperAdmin:
|
||||
// 超级管理员逻辑
|
||||
case constants.UserTypeAgent:
|
||||
// 代理商逻辑,使用 shopID
|
||||
case constants.UserTypeEnterprise:
|
||||
// 企业客户逻辑,使用 enterpriseID
|
||||
}
|
||||
|
||||
return response.Success(c, nil)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 在 Service 层使用用户信息
|
||||
|
||||
Service 层应通过参数接收用户信息,而不是直接从 context 获取:
|
||||
|
||||
```go
|
||||
// internal/service/order/service.go
|
||||
package order
|
||||
|
||||
type Service struct {
|
||||
orderStore *postgres.OrderStore
|
||||
}
|
||||
|
||||
// 推荐:显式传递 userID
|
||||
func (s *Service) ListOrders(ctx context.Context, userID uint, filters *OrderFilters) ([]*model.Order, error) {
|
||||
// 根据用户权限过滤订单
|
||||
return s.orderStore.ListByUser(ctx, userID, filters)
|
||||
}
|
||||
|
||||
// 不推荐:从 context 中获取
|
||||
// func (s *Service) ListOrders(ctx context.Context, filters *OrderFilters) ([]*model.Order, error) {
|
||||
// userID := middleware.GetUserIDFromContext(ctx) // 不推荐
|
||||
// ...
|
||||
// }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Token 管理
|
||||
|
||||
### 1. 生成 Token
|
||||
|
||||
在认证服务中已实现,无需手动调用。如需在其他场景使用:
|
||||
|
||||
```go
|
||||
package myservice
|
||||
|
||||
import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||
)
|
||||
|
||||
func (s *Service) IssueTokenForUser(ctx context.Context, userID uint) (string, string, error) {
|
||||
tokenInfo := &auth.TokenInfo{
|
||||
UserID: userID,
|
||||
UserType: 1,
|
||||
ShopID: 0,
|
||||
EnterpriseID: 0,
|
||||
Username: "user",
|
||||
Device: "web",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
|
||||
accessToken, refreshToken, err := s.tokenManager.GenerateTokenPair(ctx, tokenInfo)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return accessToken, refreshToken, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 验证 Token
|
||||
|
||||
Token 验证已由中间件自动完成。如需手动验证:
|
||||
|
||||
```go
|
||||
func (s *Service) ManuallyValidateToken(ctx context.Context, token string) (*auth.TokenInfo, error) {
|
||||
tokenInfo, err := s.tokenManager.ValidateAccessToken(ctx, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokenInfo, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 撤销 Token
|
||||
|
||||
**撤销单个 Token**:
|
||||
|
||||
```go
|
||||
func (s *Service) RevokeToken(ctx context.Context, token string) error {
|
||||
return s.tokenManager.RevokeToken(ctx, token)
|
||||
}
|
||||
```
|
||||
|
||||
**撤销用户所有 Token**(例如修改密码后):
|
||||
|
||||
```go
|
||||
func (s *Service) RevokeAllUserTokens(ctx context.Context, userID uint) error {
|
||||
return s.tokenManager.RevokeAllUserTokens(ctx, userID)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 如何测试需要认证的接口?
|
||||
|
||||
**方法 1:使用真实 Token**
|
||||
|
||||
```bash
|
||||
# 1. 先登录获取 token
|
||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/admin/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"Admin@123456"}' \
|
||||
| jq -r '.data.access_token')
|
||||
|
||||
# 2. 使用 token 访问接口
|
||||
curl -X GET http://localhost:8080/api/admin/users \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
**方法 2:在集成测试中模拟**
|
||||
|
||||
```go
|
||||
// tests/integration/user_test.go
|
||||
func TestListUsers(t *testing.T) {
|
||||
// 创建测试账号
|
||||
account := createTestAccount(t)
|
||||
|
||||
// 生成 token
|
||||
tokenManager := auth.NewTokenManager(redisClient, 24*time.Hour, 7*24*time.Hour)
|
||||
accessToken, _, err := tokenManager.GenerateTokenPair(ctx, &auth.TokenInfo{
|
||||
UserID: account.ID,
|
||||
UserType: account.UserType,
|
||||
Username: account.Username,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 发送请求
|
||||
req := httptest.NewRequest("GET", "/api/admin/users", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
```
|
||||
|
||||
### Q2: 如何处理 Token 过期?
|
||||
|
||||
前端应捕获 `1003` 错误码,自动使用 Refresh Token 刷新:
|
||||
|
||||
```javascript
|
||||
// 前端示例(伪代码)
|
||||
async function apiRequest(url, options) {
|
||||
let response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'Authorization': `Bearer ${getAccessToken()}`
|
||||
}
|
||||
});
|
||||
|
||||
// Token 过期
|
||||
if (response.status === 401 && response.data.code === 1003) {
|
||||
// 刷新 token
|
||||
const newToken = await refreshAccessToken();
|
||||
setAccessToken(newToken);
|
||||
|
||||
// 重试原请求
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'Authorization': `Bearer ${newToken}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
const response = await fetch('/api/admin/refresh-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: getRefreshToken() })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return data.data.access_token;
|
||||
}
|
||||
```
|
||||
|
||||
### Q3: 如何区分后台和 H5 用户?
|
||||
|
||||
通过 `userType` 字段区分:
|
||||
|
||||
```go
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
|
||||
switch userType {
|
||||
case constants.UserTypeSuperAdmin: // 1
|
||||
// 超级管理员
|
||||
case constants.UserTypePlatform: // 2
|
||||
// 平台用户
|
||||
case constants.UserTypeAgent: // 3
|
||||
// 代理商(后台和 H5 都可以)
|
||||
case constants.UserTypeEnterprise: // 4
|
||||
// 企业客户(仅 H5)
|
||||
}
|
||||
```
|
||||
|
||||
### Q4: 如何实现"记住我"功能?
|
||||
|
||||
当前系统不支持"记住我"。如需实现:
|
||||
|
||||
1. 增加一个长期 Token 类型(30 天)
|
||||
2. 前端存储到 LocalStorage 或 Cookie
|
||||
3. 后端需要额外的安全机制(如设备指纹)
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 安全实践
|
||||
|
||||
✅ **推荐做法**:
|
||||
|
||||
- 所有敏感操作(修改密码、删除数据)要求二次验证
|
||||
- Token 存储在 HttpOnly Cookie 或安全存储中
|
||||
- 使用 HTTPS 传输
|
||||
- 定期更新密码
|
||||
- 修改密码后撤销所有旧 Token
|
||||
|
||||
❌ **避免做法**:
|
||||
|
||||
- 不要在 URL 中传递 Token
|
||||
- 不要在浏览器 LocalStorage 中存储 Token(XSS 风险)
|
||||
- 不要在日志中记录完整 Token
|
||||
- 不要与他人分享 Token
|
||||
|
||||
### 2. 错误处理
|
||||
|
||||
Handler 应返回 `*errors.AppError`,由全局 ErrorHandler 统一处理:
|
||||
|
||||
```go
|
||||
func (h *UserHandler) Create(c *fiber.Ctx) error {
|
||||
userID := middleware.GetUserIDFromContext(c.UserContext())
|
||||
if userID == 0 {
|
||||
// 返回 AppError,不要自己构造 JSON
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
// ... 业务逻辑
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 性能优化
|
||||
|
||||
- Token 验证操作已由 Redis 优化,平均耗时 < 5ms
|
||||
- 避免在循环中重复验证 Token
|
||||
- 使用批量操作减少 Redis 调用
|
||||
|
||||
### 4. 日志记录
|
||||
|
||||
记录关键认证事件:
|
||||
|
||||
```go
|
||||
import "go.uber.org/zap"
|
||||
|
||||
// 登录成功
|
||||
logger.Info("用户登录成功",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("username", username),
|
||||
zap.String("ip", clientIP),
|
||||
zap.String("device", device),
|
||||
)
|
||||
|
||||
// 登录失败
|
||||
logger.Warn("登录失败",
|
||||
zap.String("username", username),
|
||||
zap.String("ip", clientIP),
|
||||
zap.String("reason", "密码错误"),
|
||||
)
|
||||
|
||||
// Token 撤销
|
||||
logger.Info("Token 已撤销",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("reason", "修改密码"),
|
||||
)
|
||||
```
|
||||
|
||||
### 5. 测试覆盖
|
||||
|
||||
确保以下场景有测试覆盖:
|
||||
|
||||
- [x] 登录成功
|
||||
- [x] 登录失败(密码错误、账号禁用)
|
||||
- [x] Token 验证成功
|
||||
- [x] Token 过期处理
|
||||
- [x] Token 刷新
|
||||
- [x] 修改密码后 Token 失效
|
||||
- [x] 并发访问
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [API 文档](api/auth.md) - 完整的 API 接口说明
|
||||
- [架构说明](auth-architecture.md) - 认证系统架构设计
|
||||
- [错误处理指南](003-error-handling/使用指南.md) - 统一错误处理
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**最后更新**: 2026-01-15
|
||||
**维护者**: 君鸿卡管系统开发团队
|
||||
Reference in New Issue
Block a user