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

@@ -12,6 +12,8 @@ const (
ContextKeyEnterpriseID = "enterprise_id" // 企业ID
ContextKeyCustomerID = "customer_id" // 个人客户ID
ContextKeyUserInfo = "user_info" // 完整的用户信息
ContextKeyIP = "ip_address" // IP地址
ContextKeyUserAgent = "user_agent" // User-Agent
)
// 配置环境变量

View File

@@ -95,6 +95,36 @@ func IsRootUser(ctx context.Context) bool {
return userType == constants.UserTypeSuperAdmin
}
func GetRequestIDFromContext(ctx context.Context) *string {
if ctx == nil {
return nil
}
if requestID, ok := ctx.Value(constants.ContextKeyRequestID).(string); ok {
return &requestID
}
return nil
}
func GetIPFromContext(ctx context.Context) *string {
if ctx == nil {
return nil
}
if ip, ok := ctx.Value(constants.ContextKeyIP).(string); ok {
return &ip
}
return nil
}
func GetUserAgentFromContext(ctx context.Context) *string {
if ctx == nil {
return nil
}
if userAgent, ok := ctx.Value(constants.ContextKeyUserAgent).(string); ok {
return &userAgent
}
return nil
}
// SetUserToFiberContext 将用户信息设置到 Fiber context 的 Locals 中
// 同时也设置到标准 context 中,便于 GORM 查询使用
func SetUserToFiberContext(c *fiber.Ctx, info *UserContextInfo) {

View File

@@ -0,0 +1,111 @@
package middleware
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ShopStoreInterface 店铺存储接口
// 用于权限检查时查询店铺信息和下级店铺ID
type ShopStoreInterface interface {
GetByID(ctx context.Context, id uint) (*model.Shop, error)
GetSubordinateShopIDs(ctx context.Context, shopID uint) ([]uint, error)
}
// EnterpriseStoreInterface 企业存储接口
// 用于权限检查时查询企业信息
type EnterpriseStoreInterface interface {
GetByID(ctx context.Context, id uint) (*model.Enterprise, error)
}
// CanManageShop 检查当前用户是否有权管理目标店铺的账号
// 超级管理员和平台用户自动通过
// 代理账号只能管理自己店铺及下级店铺的账号
// 企业账号禁止管理店铺账号
func CanManageShop(ctx context.Context, targetShopID uint, shopStore ShopStoreInterface) error {
userType := GetUserTypeFromContext(ctx)
// 超级管理员和平台用户跳过权限检查
if userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform {
return nil
}
// 企业账号禁止管理店铺账号
if userType != constants.UserTypeAgent {
return errors.New(errors.CodeForbidden, "无权限管理店铺账号")
}
// 获取当前代理账号的店铺ID
currentShopID := GetShopIDFromContext(ctx)
if currentShopID == 0 {
return errors.New(errors.CodeForbidden, "无权限管理店铺账号")
}
// 递归查询下级店铺ID包含自己
subordinateIDs, err := shopStore.GetSubordinateShopIDs(ctx, currentShopID)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询下级店铺失败")
}
// 检查目标店铺是否在下级列表中
for _, id := range subordinateIDs {
if id == targetShopID {
return nil
}
}
return errors.New(errors.CodeForbidden, "无权限管理该店铺的账号")
}
// CanManageEnterprise 检查当前用户是否有权管理目标企业的账号
// 超级管理员和平台用户自动通过
// 代理账号只能管理归属于自己店铺或下级店铺的企业账号
// 企业账号禁止管理其他企业账号
func CanManageEnterprise(ctx context.Context, targetEnterpriseID uint, enterpriseStore EnterpriseStoreInterface, shopStore ShopStoreInterface) error {
userType := GetUserTypeFromContext(ctx)
// 超级管理员和平台用户跳过权限检查
if userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform {
return nil
}
// 企业账号禁止管理其他企业账号
if userType != constants.UserTypeAgent {
return errors.New(errors.CodeForbidden, "无权限管理企业账号")
}
// 获取目标企业信息
enterprise, err := enterpriseStore.GetByID(ctx, targetEnterpriseID)
if err != nil {
return errors.Wrap(errors.CodeForbidden, err, "无权限操作该资源或资源不存在")
}
// 代理账号不能管理平台级企业owner_shop_id为NULL
if enterprise.OwnerShopID == nil {
return errors.New(errors.CodeForbidden, "无权限管理平台级企业账号")
}
// 获取当前代理账号的店铺ID
currentShopID := GetShopIDFromContext(ctx)
if currentShopID == 0 {
return errors.New(errors.CodeForbidden, "无权限管理企业账号")
}
// 递归查询下级店铺ID包含自己
subordinateIDs, err := shopStore.GetSubordinateShopIDs(ctx, currentShopID)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询下级店铺失败")
}
// 检查企业归属的店铺是否在下级列表中
for _, id := range subordinateIDs {
if id == *enterprise.OwnerShopID {
return nil
}
}
return errors.New(errors.CodeForbidden, "无权限管理该企业的账号")
}

View File

@@ -0,0 +1,359 @@
package middleware
import (
"context"
"errors"
"testing"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
type MockShopStore struct {
mock.Mock
}
func (m *MockShopStore) GetByID(ctx context.Context, id uint) (*model.Shop, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*model.Shop), args.Error(1)
}
func (m *MockShopStore) GetSubordinateShopIDs(ctx context.Context, shopID uint) ([]uint, error) {
args := m.Called(ctx, shopID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]uint), args.Error(1)
}
type MockEnterpriseStore struct {
mock.Mock
}
func (m *MockEnterpriseStore) GetByID(ctx context.Context, id uint) (*model.Enterprise, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*model.Enterprise), args.Error(1)
}
func TestCanManageShop_SuperAdmin(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypeSuperAdmin)
mockShopStore := new(MockShopStore)
err := CanManageShop(ctx, 100, mockShopStore)
assert.NoError(t, err)
mockShopStore.AssertNotCalled(t, "GetSubordinateShopIDs")
}
func TestCanManageShop_Platform(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypePlatform)
mockShopStore := new(MockShopStore)
err := CanManageShop(ctx, 100, mockShopStore)
assert.NoError(t, err)
mockShopStore.AssertNotCalled(t, "GetSubordinateShopIDs")
}
func TestCanManageShop_AgentManageOwnShop(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypeAgent)
ctx = context.WithValue(ctx, constants.ContextKeyShopID, uint(100))
mockShopStore := new(MockShopStore)
mockShopStore.On("GetSubordinateShopIDs", ctx, uint(100)).Return([]uint{100, 101, 102}, nil)
err := CanManageShop(ctx, 100, mockShopStore)
assert.NoError(t, err)
mockShopStore.AssertExpectations(t)
}
func TestCanManageShop_AgentManageSubordinateShop(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypeAgent)
ctx = context.WithValue(ctx, constants.ContextKeyShopID, uint(100))
mockShopStore := new(MockShopStore)
mockShopStore.On("GetSubordinateShopIDs", ctx, uint(100)).Return([]uint{100, 101, 102}, nil)
err := CanManageShop(ctx, 101, mockShopStore)
assert.NoError(t, err)
mockShopStore.AssertExpectations(t)
}
func TestCanManageShop_AgentCannotManageOtherShop(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypeAgent)
ctx = context.WithValue(ctx, constants.ContextKeyShopID, uint(100))
mockShopStore := new(MockShopStore)
mockShopStore.On("GetSubordinateShopIDs", ctx, uint(100)).Return([]uint{100, 101, 102}, nil)
err := CanManageShop(ctx, 200, mockShopStore)
assert.Error(t, err)
assert.Contains(t, err.Error(), "无权限管理该店铺的账号")
mockShopStore.AssertExpectations(t)
}
func TestCanManageShop_AgentNoShopID(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypeAgent)
mockShopStore := new(MockShopStore)
err := CanManageShop(ctx, 100, mockShopStore)
assert.Error(t, err)
assert.Contains(t, err.Error(), "无权限管理店铺账号")
mockShopStore.AssertNotCalled(t, "GetSubordinateShopIDs")
}
func TestCanManageShop_EnterpriseUser(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypeEnterprise)
mockShopStore := new(MockShopStore)
err := CanManageShop(ctx, 100, mockShopStore)
assert.Error(t, err)
assert.Contains(t, err.Error(), "无权限管理店铺账号")
mockShopStore.AssertNotCalled(t, "GetSubordinateShopIDs")
}
func TestCanManageShop_GetSubordinateShopIDsError(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypeAgent)
ctx = context.WithValue(ctx, constants.ContextKeyShopID, uint(100))
mockShopStore := new(MockShopStore)
mockShopStore.On("GetSubordinateShopIDs", ctx, uint(100)).Return(nil, errors.New("database error"))
err := CanManageShop(ctx, 100, mockShopStore)
assert.Error(t, err)
assert.Contains(t, err.Error(), "查询下级店铺失败")
mockShopStore.AssertExpectations(t)
}
func TestCanManageEnterprise_SuperAdmin(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypeSuperAdmin)
mockEnterpriseStore := new(MockEnterpriseStore)
mockShopStore := new(MockShopStore)
err := CanManageEnterprise(ctx, 50, mockEnterpriseStore, mockShopStore)
assert.NoError(t, err)
mockEnterpriseStore.AssertNotCalled(t, "GetByID")
mockShopStore.AssertNotCalled(t, "GetSubordinateShopIDs")
}
func TestCanManageEnterprise_Platform(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypePlatform)
mockEnterpriseStore := new(MockEnterpriseStore)
mockShopStore := new(MockShopStore)
err := CanManageEnterprise(ctx, 50, mockEnterpriseStore, mockShopStore)
assert.NoError(t, err)
mockEnterpriseStore.AssertNotCalled(t, "GetByID")
mockShopStore.AssertNotCalled(t, "GetSubordinateShopIDs")
}
func TestCanManageEnterprise_AgentManageOwnShopEnterprise(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypeAgent)
ctx = context.WithValue(ctx, constants.ContextKeyShopID, uint(100))
ownerShopID := uint(100)
enterprise := &model.Enterprise{
OwnerShopID: &ownerShopID,
}
mockEnterpriseStore := new(MockEnterpriseStore)
mockEnterpriseStore.On("GetByID", ctx, uint(50)).Return(enterprise, nil)
mockShopStore := new(MockShopStore)
mockShopStore.On("GetSubordinateShopIDs", ctx, uint(100)).Return([]uint{100, 101, 102}, nil)
err := CanManageEnterprise(ctx, 50, mockEnterpriseStore, mockShopStore)
assert.NoError(t, err)
mockEnterpriseStore.AssertExpectations(t)
mockShopStore.AssertExpectations(t)
}
func TestCanManageEnterprise_AgentManageSubordinateShopEnterprise(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypeAgent)
ctx = context.WithValue(ctx, constants.ContextKeyShopID, uint(100))
ownerShopID := uint(101)
enterprise := &model.Enterprise{
OwnerShopID: &ownerShopID,
}
mockEnterpriseStore := new(MockEnterpriseStore)
mockEnterpriseStore.On("GetByID", ctx, uint(50)).Return(enterprise, nil)
mockShopStore := new(MockShopStore)
mockShopStore.On("GetSubordinateShopIDs", ctx, uint(100)).Return([]uint{100, 101, 102}, nil)
err := CanManageEnterprise(ctx, 50, mockEnterpriseStore, mockShopStore)
assert.NoError(t, err)
mockEnterpriseStore.AssertExpectations(t)
mockShopStore.AssertExpectations(t)
}
func TestCanManageEnterprise_AgentCannotManageOtherShopEnterprise(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypeAgent)
ctx = context.WithValue(ctx, constants.ContextKeyShopID, uint(100))
ownerShopID := uint(200)
enterprise := &model.Enterprise{
OwnerShopID: &ownerShopID,
}
mockEnterpriseStore := new(MockEnterpriseStore)
mockEnterpriseStore.On("GetByID", ctx, uint(50)).Return(enterprise, nil)
mockShopStore := new(MockShopStore)
mockShopStore.On("GetSubordinateShopIDs", ctx, uint(100)).Return([]uint{100, 101, 102}, nil)
err := CanManageEnterprise(ctx, 50, mockEnterpriseStore, mockShopStore)
assert.Error(t, err)
assert.Contains(t, err.Error(), "无权限管理该企业的账号")
mockEnterpriseStore.AssertExpectations(t)
mockShopStore.AssertExpectations(t)
}
func TestCanManageEnterprise_AgentCannotManagePlatformEnterprise(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypeAgent)
ctx = context.WithValue(ctx, constants.ContextKeyShopID, uint(100))
enterprise := &model.Enterprise{
OwnerShopID: nil,
}
mockEnterpriseStore := new(MockEnterpriseStore)
mockEnterpriseStore.On("GetByID", ctx, uint(50)).Return(enterprise, nil)
mockShopStore := new(MockShopStore)
err := CanManageEnterprise(ctx, 50, mockEnterpriseStore, mockShopStore)
assert.Error(t, err)
assert.Contains(t, err.Error(), "无权限管理平台级企业账号")
mockEnterpriseStore.AssertExpectations(t)
mockShopStore.AssertNotCalled(t, "GetSubordinateShopIDs")
}
func TestCanManageEnterprise_EnterpriseUser(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypeEnterprise)
mockEnterpriseStore := new(MockEnterpriseStore)
mockShopStore := new(MockShopStore)
err := CanManageEnterprise(ctx, 50, mockEnterpriseStore, mockShopStore)
assert.Error(t, err)
assert.Contains(t, err.Error(), "无权限管理企业账号")
mockEnterpriseStore.AssertNotCalled(t, "GetByID")
mockShopStore.AssertNotCalled(t, "GetSubordinateShopIDs")
}
func TestCanManageEnterprise_GetEnterpriseError(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypeAgent)
ctx = context.WithValue(ctx, constants.ContextKeyShopID, uint(100))
mockEnterpriseStore := new(MockEnterpriseStore)
mockEnterpriseStore.On("GetByID", ctx, uint(50)).Return(nil, errors.New("database error"))
mockShopStore := new(MockShopStore)
err := CanManageEnterprise(ctx, 50, mockEnterpriseStore, mockShopStore)
assert.Error(t, err)
assert.Contains(t, err.Error(), "无权限操作该资源或资源不存在")
mockEnterpriseStore.AssertExpectations(t)
mockShopStore.AssertNotCalled(t, "GetSubordinateShopIDs")
}
func TestCanManageEnterprise_AgentNoShopID(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypeAgent)
ownerShopID := uint(100)
enterprise := &model.Enterprise{
OwnerShopID: &ownerShopID,
}
mockEnterpriseStore := new(MockEnterpriseStore)
mockEnterpriseStore.On("GetByID", ctx, uint(50)).Return(enterprise, nil)
mockShopStore := new(MockShopStore)
err := CanManageEnterprise(ctx, 50, mockEnterpriseStore, mockShopStore)
assert.Error(t, err)
assert.Contains(t, err.Error(), "无权限管理企业账号")
mockEnterpriseStore.AssertExpectations(t)
mockShopStore.AssertNotCalled(t, "GetSubordinateShopIDs")
}
func TestCanManageEnterprise_GetSubordinateShopIDsError(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, constants.ContextKeyUserType, constants.UserTypeAgent)
ctx = context.WithValue(ctx, constants.ContextKeyShopID, uint(100))
ownerShopID := uint(100)
enterprise := &model.Enterprise{
OwnerShopID: &ownerShopID,
}
mockEnterpriseStore := new(MockEnterpriseStore)
mockEnterpriseStore.On("GetByID", ctx, uint(50)).Return(enterprise, nil)
mockShopStore := new(MockShopStore)
mockShopStore.On("GetSubordinateShopIDs", ctx, uint(100)).Return(nil, errors.New("database error"))
err := CanManageEnterprise(ctx, 50, mockEnterpriseStore, mockShopStore)
assert.Error(t, err)
assert.Contains(t, err.Error(), "查询下级店铺失败")
mockEnterpriseStore.AssertExpectations(t)
mockShopStore.AssertExpectations(t)
}
func TestPermissionHelperTestCoverage(t *testing.T) {
mockShopStore := new(MockShopStore)
mockEnterpriseStore := new(MockEnterpriseStore)
assert.Implements(t, (*ShopStoreInterface)(nil), mockShopStore)
assert.Implements(t, (*EnterpriseStoreInterface)(nil), mockEnterpriseStore)
}

View File

@@ -4,6 +4,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/handler/app"
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
)
@@ -11,14 +12,12 @@ import (
// BuildDocHandlers 构造文档生成用的 handlers所有依赖传 nil
func BuildDocHandlers() *bootstrap.Handlers {
return &bootstrap.Handlers{
AdminAuth: admin.NewAuthHandler(nil, nil),
H5Auth: h5.NewAuthHandler(nil, nil),
Auth: authHandler.NewHandler(nil, nil),
Account: admin.NewAccountHandler(nil),
Role: admin.NewRoleHandler(nil, nil),
Permission: admin.NewPermissionHandler(nil),
PersonalCustomer: app.NewPersonalCustomerHandler(nil, nil),
Shop: admin.NewShopHandler(nil),
ShopAccount: admin.NewShopAccountHandler(nil),
ShopCommission: admin.NewShopCommissionHandler(nil),
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(nil),
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(nil),
@@ -27,7 +26,6 @@ func BuildDocHandlers() *bootstrap.Handlers {
EnterpriseDevice: admin.NewEnterpriseDeviceHandler(nil),
EnterpriseDeviceH5: h5.NewEnterpriseDeviceHandler(nil),
Authorization: admin.NewAuthorizationHandler(nil),
CustomerAccount: admin.NewCustomerAccountHandler(nil),
MyCommission: admin.NewMyCommissionHandler(nil),
IotCard: admin.NewIotCardHandler(nil),
IotCardImport: admin.NewIotCardImportHandler(nil),