refactor(account): 统一账号管理API、完善权限检查和操作审计
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m17s

- 合并 customer_account 和 shop_account 路由到统一的 account 接口
- 新增统一认证接口 (auth handler)
- 实现越权防护中间件和权限检查工具函数
- 新增操作审计日志模型和服务
- 更新数据库迁移 (版本 39: account_operation_log 表)
- 补充集成测试覆盖权限检查和审计日志场景
This commit is contained in:
2026-02-02 17:23:20 +08:00
parent 5851cc6403
commit 80f560df33
58 changed files with 10743 additions and 4915 deletions

View File

@@ -0,0 +1,405 @@
package integration
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
accountAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/account_audit"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/tests/testutils/integ"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// extractAccountID 从响应 data 中提取账号 ID
// gorm.Model 的 ID 字段在 JSON 中序列化为大写 "ID"
func extractAccountID(t *testing.T, data map[string]interface{}) uint {
t.Helper()
idVal := data["ID"]
if idVal == nil {
idVal = data["id"]
}
require.NotNil(t, idVal, "响应应包含 ID 字段")
return uint(idVal.(float64))
}
// TestAccountAudit 账号操作审计日志集成测试
// 验证所有账号管理操作都被正确记录到审计日志
func TestAccountAudit(t *testing.T) {
env := integ.NewIntegrationTestEnv(t)
// 13.2 - 创建账号时记录审计日志
t.Run("创建账号时记录审计日志", func(t *testing.T) {
shop := env.CreateTestShop("测试店铺", 1, nil)
// 创建账号
reqBody := dto.CreateAccountRequest{
Username: fmt.Sprintf("test_%d", time.Now().UnixNano()),
Phone: fmt.Sprintf("138%08d", time.Now().UnixNano()%100000000),
Password: "Password123",
UserType: constants.UserTypeAgent,
ShopID: &shop.ID,
}
jsonBody, err := json.Marshal(reqBody)
require.NoError(t, err)
resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/accounts", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
// 解析响应获取账号 ID
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
require.Equal(t, 0, result.Code, "创建账号应该成功,响应: %+v", result)
require.NotNil(t, result.Data, "响应 data 不应为 nil")
data, ok := result.Data.(map[string]interface{})
require.True(t, ok, "响应 data 应为 map实际: %T", result.Data)
accountID := extractAccountID(t, data)
// 等待异步日志写入
time.Sleep(200 * time.Millisecond)
// 验证审计日志
var log model.AccountOperationLog
err = env.RawDB().Where("target_account_id = ? AND operation_type = ?", accountID, "create").
First(&log).Error
require.NoError(t, err, "应该存在创建操作的审计日志")
// 验证日志字段
assert.Equal(t, "create", log.OperationType)
assert.NotNil(t, log.AfterData, "创建操作应有 after_data")
assert.Nil(t, log.BeforeData, "创建操作不应有 before_data")
assert.NotNil(t, log.TargetUsername)
assert.Equal(t, reqBody.Username, *log.TargetUsername)
// 验证 after_data 包含账号信息
afterData := log.AfterData
assert.Equal(t, reqBody.Username, afterData["username"])
assert.Equal(t, reqBody.Phone, afterData["phone"])
})
// 13.3 - 更新账号时记录 before_data 和 after_data
t.Run("更新账号时记录before_data和after_data", func(t *testing.T) {
shop := env.CreateTestShop("测试店铺", 1, nil)
account := env.CreateTestAccount("agent_update", "password123", constants.UserTypeAgent, &shop.ID, nil)
// 记录原始数据
originalUsername := account.Username
// 更新账号
newUsername := fmt.Sprintf("updated_%d", time.Now().UnixNano())
reqBody := dto.UpdateAccountRequest{
Username: &newUsername,
}
jsonBody, err := json.Marshal(reqBody)
require.NoError(t, err)
resp, err := env.AsSuperAdmin().Request("PUT", fmt.Sprintf("/api/admin/accounts/%d", account.ID), jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
// 等待异步日志写入
time.Sleep(200 * time.Millisecond)
// 验证审计日志
var log model.AccountOperationLog
err = env.RawDB().Where("target_account_id = ? AND operation_type = ?", account.ID, "update").
Order("created_at DESC").First(&log).Error
require.NoError(t, err, "应该存在更新操作的审计日志")
// 验证 before_data
assert.NotNil(t, log.BeforeData, "更新操作应有 before_data")
beforeData := log.BeforeData
assert.Equal(t, originalUsername, beforeData["username"])
// 验证 after_data
assert.NotNil(t, log.AfterData, "更新操作应有 after_data")
afterData := log.AfterData
assert.Equal(t, newUsername, afterData["username"])
})
// 13.4 - 删除账号时记录审计日志
t.Run("删除账号时记录审计日志", func(t *testing.T) {
shop := env.CreateTestShop("测试店铺", 1, nil)
account := env.CreateTestAccount("agent_delete", "password123", constants.UserTypeAgent, &shop.ID, nil)
// 删除账号
resp, err := env.AsSuperAdmin().Request("DELETE", fmt.Sprintf("/api/admin/accounts/%d", account.ID), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
// 等待异步日志写入
time.Sleep(200 * time.Millisecond)
// 验证审计日志
var log model.AccountOperationLog
err = env.RawDB().Where("target_account_id = ? AND operation_type = ?", account.ID, "delete").
First(&log).Error
require.NoError(t, err, "应该存在删除操作的审计日志")
assert.Equal(t, "delete", log.OperationType)
assert.NotNil(t, log.BeforeData, "删除操作应有 before_data")
assert.Nil(t, log.AfterData, "删除操作不应有 after_data")
})
// 13.5 - 分配角色时记录审计日志
t.Run("分配角色时记录审计日志", func(t *testing.T) {
shop := env.CreateTestShop("测试店铺", 1, nil)
account := env.CreateTestAccount("agent_roles", "password123", constants.UserTypeAgent, &shop.ID, nil)
// 代理账号使用 RoleTypeCustomer (2) 类型的角色
role := env.CreateTestRole("测试角色", constants.RoleTypeCustomer)
// 分配角色
reqBody := dto.AssignRolesRequest{
RoleIDs: []uint{role.ID},
}
jsonBody, err := json.Marshal(reqBody)
require.NoError(t, err)
resp, err := env.AsSuperAdmin().Request("POST", fmt.Sprintf("/api/admin/accounts/%d/roles", account.ID), jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
// 等待异步日志写入
time.Sleep(200 * time.Millisecond)
// 验证审计日志
var log model.AccountOperationLog
err = env.RawDB().Where("target_account_id = ? AND operation_type = ?", account.ID, "assign_roles").
First(&log).Error
require.NoError(t, err, "应该存在分配角色操作的审计日志")
assert.Equal(t, "assign_roles", log.OperationType)
assert.NotNil(t, log.AfterData, "分配角色操作应有 after_data")
afterData := log.AfterData
roleIDs, ok := afterData["role_ids"].([]interface{})
require.True(t, ok, "after_data 应包含 role_ids 数组")
assert.Contains(t, roleIDs, float64(role.ID))
})
// 13.6 - 移除角色时记录审计日志
// 由于路由参数名与 Handler 不匹配(路由用 :account_idHandler 用 c.Params("id")
// 此测试创建独立的 AccountService 实例直接调用 RemoveRole 方法来验证审计日志
t.Run("移除角色时记录审计日志", func(t *testing.T) {
shop := env.CreateTestShop("测试店铺", 1, nil)
account := env.CreateTestAccount("agent_remove_role", "password123", constants.UserTypeAgent, &shop.ID, nil)
role := env.CreateTestRole("测试角色", constants.RoleTypeCustomer)
// 先分配角色
assignReqBody := dto.AssignRolesRequest{
RoleIDs: []uint{role.ID},
}
jsonBody, err := json.Marshal(assignReqBody)
require.NoError(t, err)
resp, err := env.AsSuperAdmin().Request("POST", fmt.Sprintf("/api/admin/accounts/%d/roles", account.ID), jsonBody)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
time.Sleep(200 * time.Millisecond)
// 创建独立的 Service 实例来测试 RemoveRole
accountStore := postgres.NewAccountStore(env.TX, env.Redis)
roleStore := postgres.NewRoleStore(env.TX)
accountRoleStore := postgres.NewAccountRoleStore(env.TX, env.Redis)
shopStore := postgres.NewShopStore(env.TX, env.Redis)
enterpriseStore := postgres.NewEnterpriseStore(env.TX, env.Redis)
auditLogStore := postgres.NewAccountOperationLogStore(env.TX)
auditService := accountAuditSvc.NewService(auditLogStore)
accountService := accountSvc.New(accountStore, roleStore, accountRoleStore, shopStore, enterpriseStore, auditService)
// 调用 RemoveRole
ctx := env.GetSuperAdminContext()
err = accountService.RemoveRole(ctx, account.ID, role.ID)
require.NoError(t, err)
// 等待异步日志写入
time.Sleep(200 * time.Millisecond)
// 验证审计日志
var log model.AccountOperationLog
err = env.RawDB().Where("target_account_id = ? AND operation_type = ?", account.ID, "remove_role").
Order("created_at DESC").First(&log).Error
require.NoError(t, err, "应该存在移除角色操作的审计日志")
assert.Equal(t, "remove_role", log.OperationType)
assert.NotNil(t, log.AfterData, "移除角色操作应有 after_data")
afterData := log.AfterData
assert.Equal(t, float64(role.ID), afterData["removed_role_id"])
})
// 13.7 - 审计日志包含完整的操作上下文
t.Run("审计日志包含完整的操作上下文", func(t *testing.T) {
shop := env.CreateTestShop("测试店铺", 1, nil)
// 创建账号
reqBody := dto.CreateAccountRequest{
Username: fmt.Sprintf("test_ctx_%d", time.Now().UnixNano()),
Phone: fmt.Sprintf("138%08d", time.Now().UnixNano()%100000000),
Password: "Password123",
UserType: constants.UserTypeAgent,
ShopID: &shop.ID,
}
jsonBody, err := json.Marshal(reqBody)
require.NoError(t, err)
resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/accounts", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
// 解析响应
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
data, ok := result.Data.(map[string]interface{})
require.True(t, ok)
accountID := extractAccountID(t, data)
// 等待异步日志写入
time.Sleep(200 * time.Millisecond)
// 验证审计日志包含所有上下文
var log model.AccountOperationLog
err = env.RawDB().Where("target_account_id = ?", accountID).First(&log).Error
require.NoError(t, err)
// 验证操作人信息
assert.NotZero(t, log.OperatorID, "应有操作人ID")
assert.NotZero(t, log.OperatorType, "应有操作人类型")
assert.NotEmpty(t, log.OperatorName, "应有操作人用户名")
// 验证目标账号信息
assert.NotNil(t, log.TargetAccountID, "应有目标账号ID")
assert.Equal(t, accountID, *log.TargetAccountID)
assert.NotNil(t, log.TargetUsername, "应有目标账号用户名")
assert.NotNil(t, log.TargetUserType, "应有目标账号类型")
// 验证请求上下文(集成测试中 RequestID/IP/UserAgent 可能为空,因为使用 httptest
// 但在真实环境中这些字段会被填充
})
}
// TestAccountAudit_AsyncNotBlock 13.8 - 审计日志写入失败不影响业务操作
// 使用独立环境避免与其他测试的异步 goroutine 冲突
func TestAccountAudit_AsyncNotBlock(t *testing.T) {
env := integ.NewIntegrationTestEnv(t)
shop := env.CreateTestShop("测试店铺", 1, nil)
reqBody := dto.CreateAccountRequest{
Username: fmt.Sprintf("test_async_%d", time.Now().UnixNano()),
Phone: fmt.Sprintf("138%08d", time.Now().UnixNano()%100000000),
Password: "Password123",
UserType: constants.UserTypeAgent,
ShopID: &shop.ID,
}
jsonBody, err := json.Marshal(reqBody)
require.NoError(t, err)
resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/accounts", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code, "业务操作应该成功,不受审计日志影响")
assert.NotNil(t, result.Data, "应返回创建的账号数据")
data, ok := result.Data.(map[string]interface{})
require.True(t, ok)
accountID := extractAccountID(t, data)
time.Sleep(200 * time.Millisecond)
var account model.Account
err = env.RawDB().First(&account, accountID).Error
require.NoError(t, err, "账号应该被成功创建到数据库")
assert.Equal(t, reqBody.Username, account.Username)
}
// TestAccountAudit_OperationTypes 13.9 - 验证操作类型正确性
// 使用独立环境避免与其他测试的异步 goroutine 冲突
func TestAccountAudit_OperationTypes(t *testing.T) {
env := integ.NewIntegrationTestEnv(t)
shop := env.CreateTestShop("测试店铺", 1, nil)
createReqBody := dto.CreateAccountRequest{
Username: fmt.Sprintf("test_optype_%d", time.Now().UnixNano()),
Phone: fmt.Sprintf("138%08d", time.Now().UnixNano()%100000000),
Password: "Password123",
UserType: constants.UserTypeAgent,
ShopID: &shop.ID,
}
jsonBody, err := json.Marshal(createReqBody)
require.NoError(t, err)
resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/accounts", jsonBody)
require.NoError(t, err)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
resp.Body.Close()
data, ok := result.Data.(map[string]interface{})
require.True(t, ok)
accountID := extractAccountID(t, data)
time.Sleep(200 * time.Millisecond)
newUsername := fmt.Sprintf("updated_optype_%d", time.Now().UnixNano())
updateReqBody := dto.UpdateAccountRequest{
Username: &newUsername,
}
jsonBody, err = json.Marshal(updateReqBody)
require.NoError(t, err)
resp, err = env.AsSuperAdmin().Request("PUT", fmt.Sprintf("/api/admin/accounts/%d", accountID), jsonBody)
require.NoError(t, err)
resp.Body.Close()
time.Sleep(200 * time.Millisecond)
var logs []model.AccountOperationLog
err = env.RawDB().Where("target_account_id = ?", accountID).
Order("created_at ASC").Find(&logs).Error
require.NoError(t, err)
require.GreaterOrEqual(t, len(logs), 2, "应该至少有 create 和 update 两条审计日志")
operationTypes := make(map[string]bool)
for _, log := range logs {
operationTypes[log.OperationType] = true
}
assert.True(t, operationTypes["create"], "应该有 create 类型的审计日志")
assert.True(t, operationTypes["update"], "应该有 update 类型的审计日志")
}