refactor(account): 统一账号管理API、完善权限检查和操作审计
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m17s
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:
405
tests/integration/account_audit_test.go
Normal file
405
tests/integration/account_audit_test.go
Normal 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_id,Handler 用 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 类型的审计日志")
|
||||
}
|
||||
Reference in New Issue
Block a user