主要变更: - 新增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模式)
506 lines
13 KiB
Markdown
506 lines
13 KiB
Markdown
# 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
|
||
**维护者**: 君鸿卡管系统开发团队
|