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:
@@ -22,54 +22,86 @@ type Service struct {
|
||||
accountStore *postgres.AccountStore
|
||||
roleStore *postgres.RoleStore
|
||||
accountRoleStore *postgres.AccountRoleStore
|
||||
shopStore middleware.ShopStoreInterface
|
||||
enterpriseStore middleware.EnterpriseStoreInterface
|
||||
auditService AuditServiceInterface
|
||||
}
|
||||
|
||||
type AuditServiceInterface interface {
|
||||
LogOperation(ctx context.Context, log *model.AccountOperationLog)
|
||||
}
|
||||
|
||||
// New 创建账号服务
|
||||
func New(accountStore *postgres.AccountStore, roleStore *postgres.RoleStore, accountRoleStore *postgres.AccountRoleStore) *Service {
|
||||
func New(
|
||||
accountStore *postgres.AccountStore,
|
||||
roleStore *postgres.RoleStore,
|
||||
accountRoleStore *postgres.AccountRoleStore,
|
||||
shopStore middleware.ShopStoreInterface,
|
||||
enterpriseStore middleware.EnterpriseStoreInterface,
|
||||
auditService AuditServiceInterface,
|
||||
) *Service {
|
||||
return &Service{
|
||||
accountStore: accountStore,
|
||||
roleStore: roleStore,
|
||||
accountRoleStore: accountRoleStore,
|
||||
shopStore: shopStore,
|
||||
enterpriseStore: enterpriseStore,
|
||||
auditService: auditService,
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建账号
|
||||
func (s *Service) Create(ctx context.Context, req *dto.CreateAccountRequest) (*model.Account, error) {
|
||||
// 获取当前用户 ID
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
// 验证代理账号必须提供 shop_id
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
|
||||
if userType == constants.UserTypeEnterprise {
|
||||
return nil, errors.New(errors.CodeForbidden, "企业账号不允许创建账号")
|
||||
}
|
||||
|
||||
if userType == constants.UserTypeAgent && req.UserType == constants.UserTypePlatform {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限创建平台账号")
|
||||
}
|
||||
|
||||
if req.UserType == constants.UserTypeAgent && req.ShopID == nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "代理账号必须提供店铺ID")
|
||||
}
|
||||
|
||||
// 验证企业账号必须提供 enterprise_id
|
||||
if req.UserType == constants.UserTypeEnterprise && req.EnterpriseID == nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "企业账号必须提供企业ID")
|
||||
}
|
||||
|
||||
// 检查用户名唯一性
|
||||
if req.UserType == constants.UserTypeAgent && req.ShopID != nil {
|
||||
if err := middleware.CanManageShop(ctx, *req.ShopID, s.shopStore); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if req.UserType == constants.UserTypeEnterprise && req.EnterpriseID != nil {
|
||||
if err := middleware.CanManageEnterprise(ctx, *req.EnterpriseID, s.enterpriseStore, s.shopStore); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
existing, err := s.accountStore.GetByUsername(ctx, req.Username)
|
||||
if err == nil && existing != nil {
|
||||
return nil, errors.New(errors.CodeUsernameExists, "用户名已存在")
|
||||
}
|
||||
|
||||
// 检查手机号唯一性
|
||||
existing, err = s.accountStore.GetByPhone(ctx, req.Phone)
|
||||
if err == nil && existing != nil {
|
||||
return nil, errors.New(errors.CodePhoneExists, "手机号已存在")
|
||||
}
|
||||
|
||||
// bcrypt 哈希密码
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
|
||||
}
|
||||
|
||||
// 创建账号
|
||||
account := &model.Account{
|
||||
Username: req.Username,
|
||||
Phone: req.Phone,
|
||||
@@ -84,8 +116,40 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAccountRequest) (*m
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建账号失败")
|
||||
}
|
||||
|
||||
// TODO: 清除店铺的下级 ID 缓存(需要在 Service 层处理)
|
||||
// 由于账号层级关系改为通过 Shop 表维护,这里的缓存清理逻辑已废弃
|
||||
currentAccount, _ := s.accountStore.GetByID(ctx, currentUserID)
|
||||
operatorName := ""
|
||||
if currentAccount != nil {
|
||||
operatorName = currentAccount.Username
|
||||
}
|
||||
|
||||
afterData := model.JSONB{
|
||||
"id": account.ID,
|
||||
"username": account.Username,
|
||||
"phone": account.Phone,
|
||||
"user_type": account.UserType,
|
||||
"shop_id": account.ShopID,
|
||||
"enterprise_id": account.EnterpriseID,
|
||||
"status": account.Status,
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestIDFromContext(ctx)
|
||||
ipAddress := middleware.GetIPFromContext(ctx)
|
||||
userAgent := middleware.GetUserAgentFromContext(ctx)
|
||||
|
||||
s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||
OperatorID: currentUserID,
|
||||
OperatorType: userType,
|
||||
OperatorName: operatorName,
|
||||
TargetAccountID: &account.ID,
|
||||
TargetUsername: &account.Username,
|
||||
TargetUserType: &account.UserType,
|
||||
OperationType: "create",
|
||||
OperationDesc: fmt.Sprintf("创建账号: %s", account.Username),
|
||||
AfterData: afterData,
|
||||
RequestID: requestID,
|
||||
IPAddress: ipAddress,
|
||||
UserAgent: userAgent,
|
||||
})
|
||||
|
||||
return account, nil
|
||||
}
|
||||
@@ -104,24 +168,37 @@ func (s *Service) Get(ctx context.Context, id uint) (*model.Account, error) {
|
||||
|
||||
// Update 更新账号
|
||||
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateAccountRequest) (*model.Account, error) {
|
||||
// 获取当前用户 ID
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
// 获取现有账号
|
||||
account, err := s.accountStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeAccountNotFound, "账号不存在")
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取账号失败")
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
|
||||
if userType == constants.UserTypeAgent {
|
||||
if account.ShopID == nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该账号")
|
||||
}
|
||||
if err := middleware.CanManageShop(ctx, *account.ShopID, s.shopStore); err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
}
|
||||
|
||||
beforeData := model.JSONB{
|
||||
"username": account.Username,
|
||||
"phone": account.Phone,
|
||||
"status": account.Status,
|
||||
}
|
||||
|
||||
if req.Username != nil {
|
||||
// 检查新用户名唯一性
|
||||
existing, err := s.accountStore.GetByUsername(ctx, *req.Username)
|
||||
if err == nil && existing != nil && existing.ID != id {
|
||||
return nil, errors.New(errors.CodeUsernameExists, "用户名已存在")
|
||||
@@ -130,7 +207,6 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateAccountReq
|
||||
}
|
||||
|
||||
if req.Phone != nil {
|
||||
// 检查新手机号唯一性
|
||||
existing, err := s.accountStore.GetByPhone(ctx, *req.Phone)
|
||||
if err == nil && existing != nil && existing.ID != id {
|
||||
return nil, errors.New(errors.CodePhoneExists, "手机号已存在")
|
||||
@@ -156,26 +232,102 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateAccountReq
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "更新账号失败")
|
||||
}
|
||||
|
||||
currentAccount, _ := s.accountStore.GetByID(ctx, currentUserID)
|
||||
operatorName := ""
|
||||
if currentAccount != nil {
|
||||
operatorName = currentAccount.Username
|
||||
}
|
||||
|
||||
afterData := model.JSONB{
|
||||
"username": account.Username,
|
||||
"phone": account.Phone,
|
||||
"status": account.Status,
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestIDFromContext(ctx)
|
||||
ipAddress := middleware.GetIPFromContext(ctx)
|
||||
userAgent := middleware.GetUserAgentFromContext(ctx)
|
||||
|
||||
s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||
OperatorID: currentUserID,
|
||||
OperatorType: userType,
|
||||
OperatorName: operatorName,
|
||||
TargetAccountID: &account.ID,
|
||||
TargetUsername: &account.Username,
|
||||
TargetUserType: &account.UserType,
|
||||
OperationType: "update",
|
||||
OperationDesc: fmt.Sprintf("更新账号: %s", account.Username),
|
||||
BeforeData: beforeData,
|
||||
AfterData: afterData,
|
||||
RequestID: requestID,
|
||||
IPAddress: ipAddress,
|
||||
UserAgent: userAgent,
|
||||
})
|
||||
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// Delete 软删除账号
|
||||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
// 检查账号存在
|
||||
_, err := s.accountStore.GetByID(ctx, id)
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
account, err := s.accountStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeAccountNotFound, "账号不存在")
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取账号失败")
|
||||
}
|
||||
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
|
||||
if userType == constants.UserTypeAgent {
|
||||
if account.ShopID == nil {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该账号")
|
||||
}
|
||||
if err := middleware.CanManageShop(ctx, *account.ShopID, s.shopStore); err != nil {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
}
|
||||
|
||||
beforeData := model.JSONB{
|
||||
"id": account.ID,
|
||||
"username": account.Username,
|
||||
"phone": account.Phone,
|
||||
"status": account.Status,
|
||||
}
|
||||
|
||||
if err := s.accountStore.Delete(ctx, id); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "删除账号失败")
|
||||
}
|
||||
|
||||
// 账号删除后不需要清理缓存
|
||||
// 数据权限过滤现在基于店铺层级,店铺相关的缓存清理由 ShopService 负责
|
||||
currentAccount, _ := s.accountStore.GetByID(ctx, currentUserID)
|
||||
operatorName := ""
|
||||
if currentAccount != nil {
|
||||
operatorName = currentAccount.Username
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestIDFromContext(ctx)
|
||||
ipAddress := middleware.GetIPFromContext(ctx)
|
||||
userAgent := middleware.GetUserAgentFromContext(ctx)
|
||||
|
||||
s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||
OperatorID: currentUserID,
|
||||
OperatorType: userType,
|
||||
OperatorName: operatorName,
|
||||
TargetAccountID: &account.ID,
|
||||
TargetUsername: &account.Username,
|
||||
TargetUserType: &account.UserType,
|
||||
OperationType: "delete",
|
||||
OperationDesc: fmt.Sprintf("删除账号: %s", account.Username),
|
||||
BeforeData: beforeData,
|
||||
RequestID: requestID,
|
||||
IPAddress: ipAddress,
|
||||
UserAgent: userAgent,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -221,12 +373,22 @@ func (s *Service) AssignRoles(ctx context.Context, accountID uint, roleIDs []uin
|
||||
account, err := s.accountStore.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeAccountNotFound, "账号不存在")
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取账号失败")
|
||||
}
|
||||
|
||||
// 超级管理员禁止分配角色
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
|
||||
if userType == constants.UserTypeAgent {
|
||||
if account.ShopID == nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该账号")
|
||||
}
|
||||
if err := middleware.CanManageShop(ctx, *account.ShopID, s.shopStore); err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
}
|
||||
|
||||
if account.UserType == constants.UserTypeSuperAdmin {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "超级管理员不允许分配角色")
|
||||
}
|
||||
@@ -295,6 +457,35 @@ func (s *Service) AssignRoles(ctx context.Context, accountID uint, roleIDs []uin
|
||||
ars = append(ars, ar)
|
||||
}
|
||||
|
||||
currentAccount, _ := s.accountStore.GetByID(ctx, currentUserID)
|
||||
operatorName := ""
|
||||
if currentAccount != nil {
|
||||
operatorName = currentAccount.Username
|
||||
}
|
||||
|
||||
afterData := model.JSONB{
|
||||
"role_ids": roleIDs,
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestIDFromContext(ctx)
|
||||
ipAddress := middleware.GetIPFromContext(ctx)
|
||||
userAgent := middleware.GetUserAgentFromContext(ctx)
|
||||
|
||||
s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||
OperatorID: currentUserID,
|
||||
OperatorType: userType,
|
||||
OperatorName: operatorName,
|
||||
TargetAccountID: &account.ID,
|
||||
TargetUsername: &account.Username,
|
||||
TargetUserType: &account.UserType,
|
||||
OperationType: "assign_roles",
|
||||
OperationDesc: fmt.Sprintf("为账号 %s 分配角色", account.Username),
|
||||
AfterData: afterData,
|
||||
RequestID: requestID,
|
||||
IPAddress: ipAddress,
|
||||
UserAgent: userAgent,
|
||||
})
|
||||
|
||||
return ars, nil
|
||||
}
|
||||
|
||||
@@ -325,20 +516,63 @@ func (s *Service) GetRoles(ctx context.Context, accountID uint) ([]*model.Role,
|
||||
|
||||
// RemoveRole 移除账号的角色
|
||||
func (s *Service) RemoveRole(ctx context.Context, accountID, roleID uint) error {
|
||||
// 检查账号存在
|
||||
_, err := s.accountStore.GetByID(ctx, accountID)
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
account, err := s.accountStore.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeAccountNotFound, "账号不存在")
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取账号失败")
|
||||
}
|
||||
|
||||
// 删除关联
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
|
||||
if userType == constants.UserTypeAgent {
|
||||
if account.ShopID == nil {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该账号")
|
||||
}
|
||||
if err := middleware.CanManageShop(ctx, *account.ShopID, s.shopStore); err != nil {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.accountRoleStore.Delete(ctx, accountID, roleID); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "删除账号-角色关联失败")
|
||||
}
|
||||
|
||||
currentAccount, _ := s.accountStore.GetByID(ctx, currentUserID)
|
||||
operatorName := ""
|
||||
if currentAccount != nil {
|
||||
operatorName = currentAccount.Username
|
||||
}
|
||||
|
||||
afterData := model.JSONB{
|
||||
"removed_role_id": roleID,
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestIDFromContext(ctx)
|
||||
ipAddress := middleware.GetIPFromContext(ctx)
|
||||
userAgent := middleware.GetUserAgentFromContext(ctx)
|
||||
|
||||
s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||
OperatorID: currentUserID,
|
||||
OperatorType: userType,
|
||||
OperatorName: operatorName,
|
||||
TargetAccountID: &account.ID,
|
||||
TargetUsername: &account.Username,
|
||||
TargetUserType: &account.UserType,
|
||||
OperationType: "remove_role",
|
||||
OperationDesc: fmt.Sprintf("移除账号 %s 的角色", account.Username),
|
||||
AfterData: afterData,
|
||||
RequestID: requestID,
|
||||
IPAddress: ipAddress,
|
||||
UserAgent: userAgent,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
3640
internal/service/account/service_test.go
Normal file
3640
internal/service/account/service_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user