diff --git a/internal/app/app.go b/internal/app/app.go index 556f3c5..3364d4b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -258,6 +258,8 @@ func (a *Application) autoMigrate(db *gorm.DB) error { &statisticsEntities.StatisticsDashboard{}, &statisticsEntities.StatisticsReport{}, &securityEntities.SuspiciousIPRecord{}, + &securityEntities.IPBlacklistEntry{}, + &securityEntities.BlacklistHitRecord{}, // api &apiEntities.ApiUser{}, diff --git a/internal/application/api/api_application_service.go b/internal/application/api/api_application_service.go index 521135d..aa3603a 100644 --- a/internal/application/api/api_application_service.go +++ b/internal/application/api/api_application_service.go @@ -21,6 +21,8 @@ import ( product_entities "hyapi-server/internal/domains/product/entities" product_services "hyapi-server/internal/domains/product/services" user_repositories "hyapi-server/internal/domains/user/repositories" + securityEntities "hyapi-server/internal/domains/security/entities" + securityServices "hyapi-server/internal/domains/security/services" task_entities "hyapi-server/internal/infrastructure/task/entities" "hyapi-server/internal/infrastructure/task/interfaces" "hyapi-server/internal/shared/crypto" @@ -83,6 +85,7 @@ type ApiApplicationServiceImpl struct { contractInfoService user_repositories.ContractInfoRepository productManagementService *product_services.ProductManagementService userRepo user_repositories.UserRepository + blacklistService *securityServices.BlacklistService txManager *database.TransactionManager config *config.Config logger *zap.Logger @@ -112,6 +115,7 @@ func NewApiApplicationService( subscriptionService *product_services.ProductSubscriptionService, exportManager *export.ExportManager, balanceAlertService finance_services.BalanceAlertService, + blacklistService *securityServices.BlacklistService, ) ApiApplicationService { service := &ApiApplicationServiceImpl{ apiCallService: apiCallService, @@ -121,6 +125,7 @@ func NewApiApplicationService( apiCallRepository: apiCallRepository, productManagementService: productManagementService, userRepo: userRepo, + blacklistService: blacklistService, txManager: txManager, config: config, logger: logger, @@ -218,6 +223,24 @@ func (s *ApiApplicationServiceImpl) validateApiCall(ctx context.Context, cmd *co return nil, ErrFrozenAccount } + if platformUser, err := s.userRepo.GetByID(ctx, apiUser.UserId); err == nil { + if platformUser.IsOnBlacklist() { + s.logger.Error("账号已列入黑名单", zap.String("userId", apiUser.UserId)) + if s.blacklistService != nil { + s.blacklistService.RecordHit(&securityEntities.BlacklistHitRecord{ + HitType: securityEntities.HitTypeUserAPI, + UserID: apiUser.UserId, + Phone: platformUser.Phone, + IP: cmd.ClientIP, + Path: cmd.ApiName, + Method: "POST", + Reason: platformUser.BlacklistReason, + }) + } + return nil, ErrBlacklistedAccount + } + } + // 验证产品是否启用 if !product.IsEnabled { s.logger.Error("产品未启用", zap.String("product_code", product.Code)) @@ -440,6 +463,9 @@ func (s *ApiApplicationServiceImpl) asyncRecordFailure(ctx context.Context, apiC errorMsg = err.Error() case errors.Is(err, ErrFrozenAccount): errorType = entities.ApiCallErrorFrozenAccount + case errors.Is(err, ErrBlacklistedAccount): + errorType = entities.ApiCallErrorFrozenAccount + errorMsg = err.Error() case errors.Is(err, ErrInvalidIP): errorType = entities.ApiCallErrorInvalidIP case errors.Is(err, ErrArrears): diff --git a/internal/application/api/errors.go b/internal/application/api/errors.go index 629634e..92a7f3a 100644 --- a/internal/application/api/errors.go +++ b/internal/application/api/errors.go @@ -12,8 +12,9 @@ var ( ErrInvalidIP = errors.New("未经授权的IP") ErrMissingAccessId = errors.New("缺少Access-Id") ErrInvalidAccessId = errors.New("未经授权的AccessId") - ErrFrozenAccount = errors.New("账户已冻结") - ErrArrears = errors.New("账户余额不足,无法请求") + ErrFrozenAccount = errors.New("账户已冻结") + ErrBlacklistedAccount = errors.New("账号已被列入黑名单") + ErrArrears = errors.New("账户余额不足,无法请求") ErrInsufficientBalance = errors.New("钱包余额不足") ErrProductNotFound = errors.New("产品不存在") ErrProductDisabled = errors.New("产品已停用") @@ -34,8 +35,9 @@ var ErrorCodeMap = map[error]int{ ErrInvalidIP: 1004, ErrMissingAccessId: 1005, ErrInvalidAccessId: 1006, - ErrFrozenAccount: 1007, - ErrArrears: 1007, + ErrFrozenAccount: 1007, + ErrBlacklistedAccount: 1007, + ErrArrears: 1007, ErrInsufficientBalance: 1007, ErrProductNotFound: 1008, ErrProductDisabled: 1008, diff --git a/internal/application/user/dto/commands/blacklist_commands.go b/internal/application/user/dto/commands/blacklist_commands.go new file mode 100644 index 0000000..acbb95a --- /dev/null +++ b/internal/application/user/dto/commands/blacklist_commands.go @@ -0,0 +1,15 @@ +package commands + +// BlacklistUserCommand 管理员将用户列入黑名单 +type BlacklistUserCommand struct { + UserID string `json:"-"` + OperatorUserID string `json:"-"` + Reason string `json:"reason" binding:"required"` + Source string `json:"source"` // self / police / test,默认 self +} + +// UnblacklistUserCommand 管理员将用户移出黑名单 +type UnblacklistUserCommand struct { + UserID string `json:"-"` + OperatorUserID string `json:"-"` +} diff --git a/internal/application/user/dto/queries/list_users_query.go b/internal/application/user/dto/queries/list_users_query.go index c994a68..578c717 100644 --- a/internal/application/user/dto/queries/list_users_query.go +++ b/internal/application/user/dto/queries/list_users_query.go @@ -8,24 +8,26 @@ type ListUsersQuery struct { PageSize int `json:"page_size" validate:"min=1,max=100"` Phone string `json:"phone"` UserType string `json:"user_type"` // 用户类型: user/admin - IsActive *bool `json:"is_active"` // 是否激活 - IsCertified *bool `json:"is_certified"` // 是否已认证 - CompanyName string `json:"company_name"` // 企业名称 - StartDate string `json:"start_date"` - EndDate string `json:"end_date"` + IsActive *bool `json:"is_active"` // 是否激活 + IsCertified *bool `json:"is_certified"` // 是否已认证 + IsBlacklisted *bool `json:"is_blacklisted"` // 是否黑名单 + CompanyName string `json:"company_name"` // 企业名称 + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` } // ToDomainQuery 转换为领域查询对象 func (q *ListUsersQuery) ToDomainQuery() *queries.ListUsersQuery { return &queries.ListUsersQuery{ - Page: q.Page, - PageSize: q.PageSize, - Phone: q.Phone, - UserType: q.UserType, - IsActive: q.IsActive, - IsCertified: q.IsCertified, - CompanyName: q.CompanyName, - StartDate: q.StartDate, - EndDate: q.EndDate, + Page: q.Page, + PageSize: q.PageSize, + Phone: q.Phone, + UserType: q.UserType, + IsActive: q.IsActive, + IsCertified: q.IsCertified, + IsBlacklisted: q.IsBlacklisted, + CompanyName: q.CompanyName, + StartDate: q.StartDate, + EndDate: q.EndDate, } } diff --git a/internal/application/user/dto/responses/user_list_response.go b/internal/application/user/dto/responses/user_list_response.go index 43ab67d..43ec8a8 100644 --- a/internal/application/user/dto/responses/user_list_response.go +++ b/internal/application/user/dto/responses/user_list_response.go @@ -4,22 +4,23 @@ import "time" // UserListItem 用户列表项 type UserListItem struct { - ID string `json:"id"` - Phone string `json:"phone"` - UserType string `json:"user_type"` - Username string `json:"username"` - IsActive bool `json:"is_active"` - IsCertified bool `json:"is_certified"` - LoginCount int `json:"login_count"` - LastLoginAt *time.Time `json:"last_login_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - // 企业信息 + ID string `json:"id"` + Phone string `json:"phone"` + UserType string `json:"user_type"` + Username string `json:"username"` + IsActive bool `json:"is_active"` + IsCertified bool `json:"is_certified"` + IsBlacklisted bool `json:"is_blacklisted"` + BlacklistReason string `json:"blacklist_reason,omitempty"` + BlacklistSource string `json:"blacklist_source,omitempty"` + BlacklistedAt *time.Time `json:"blacklisted_at,omitempty"` + LoginCount int `json:"login_count"` + LastLoginAt *time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + EnterpriseInfo *EnterpriseInfoItem `json:"enterprise_info,omitempty"` - - // 钱包信息 - WalletBalance string `json:"wallet_balance,omitempty"` + WalletBalance string `json:"wallet_balance,omitempty"` } // EnterpriseInfoItem 企业信息项 diff --git a/internal/application/user/dto/responses/user_responses.go b/internal/application/user/dto/responses/user_responses.go index e6f62ee..807256e 100644 --- a/internal/application/user/dto/responses/user_responses.go +++ b/internal/application/user/dto/responses/user_responses.go @@ -43,8 +43,12 @@ type UserProfileResponse struct { Phone string `json:"phone" example:"13800138000"` Username string `json:"username,omitempty" example:"admin"` UserType string `json:"user_type" example:"user"` - IsActive bool `json:"is_active" example:"true"` - LastLoginAt *time.Time `json:"last_login_at,omitempty" example:"2024-01-01T00:00:00Z"` + IsActive bool `json:"is_active" example:"true"` + IsBlacklisted bool `json:"is_blacklisted" example:"false"` + BlacklistReason string `json:"blacklist_reason,omitempty"` + BlacklistSource string `json:"blacklist_source,omitempty"` + BlacklistedAt *time.Time `json:"blacklisted_at,omitempty"` + LastLoginAt *time.Time `json:"last_login_at,omitempty" example:"2024-01-01T00:00:00Z"` LoginCount int `json:"login_count" example:"10"` Permissions []string `json:"permissions,omitempty" example:"['user:read','user:write']"` EnterpriseInfo *EnterpriseInfoResponse `json:"enterprise_info,omitempty"` diff --git a/internal/application/user/user_application_service.go b/internal/application/user/user_application_service.go index df95495..b298d95 100644 --- a/internal/application/user/user_application_service.go +++ b/internal/application/user/user_application_service.go @@ -22,4 +22,6 @@ type UserApplicationService interface { ListUsers(ctx context.Context, query *queries.ListUsersQuery) (*responses.UserListResponse, error) GetUserDetail(ctx context.Context, userID string) (*responses.UserDetailResponse, error) GetUserStats(ctx context.Context) (*responses.UserStatsResponse, error) + BlacklistUser(ctx context.Context, cmd *commands.BlacklistUserCommand) error + UnblacklistUser(ctx context.Context, cmd *commands.UnblacklistUserCommand) error } diff --git a/internal/application/user/user_application_service_impl.go b/internal/application/user/user_application_service_impl.go index 3d5f2b9..99cedaf 100644 --- a/internal/application/user/user_application_service_impl.go +++ b/internal/application/user/user_application_service_impl.go @@ -9,6 +9,7 @@ import ( "hyapi-server/internal/application/user/dto/commands" "hyapi-server/internal/application/user/dto/queries" "hyapi-server/internal/application/user/dto/responses" + api_service "hyapi-server/internal/domains/api/services" finance_service "hyapi-server/internal/domains/finance/services" "hyapi-server/internal/domains/user/entities" "hyapi-server/internal/domains/user/events" @@ -25,6 +26,7 @@ type UserApplicationServiceImpl struct { smsCodeService *user_service.SMSCodeService walletService finance_service.WalletAggregateService contractService user_service.ContractAggregateService + apiUserService api_service.ApiUserAggregateService eventBus interfaces.EventBus jwtAuth *middleware.JWTAuthMiddleware logger *zap.Logger @@ -37,6 +39,7 @@ func NewUserApplicationService( smsCodeService *user_service.SMSCodeService, walletService finance_service.WalletAggregateService, contractService user_service.ContractAggregateService, + apiUserService api_service.ApiUserAggregateService, eventBus interfaces.EventBus, jwtAuth *middleware.JWTAuthMiddleware, logger *zap.Logger, @@ -47,6 +50,7 @@ func NewUserApplicationService( smsCodeService: smsCodeService, walletService: walletService, contractService: contractService, + apiUserService: apiUserService, eventBus: eventBus, jwtAuth: jwtAuth, logger: logger, @@ -316,16 +320,20 @@ func (s *UserApplicationServiceImpl) ListUsers(ctx context.Context, query *queri items := make([]*responses.UserListItem, 0, len(users)) for _, user := range users { item := &responses.UserListItem{ - ID: user.ID, - Phone: user.Phone, - UserType: user.UserType, - Username: user.Username, - IsActive: user.Active, - IsCertified: user.IsCertified, - LoginCount: user.LoginCount, - LastLoginAt: user.LastLoginAt, - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, + ID: user.ID, + Phone: user.Phone, + UserType: user.UserType, + Username: user.Username, + IsActive: user.Active, + IsCertified: user.IsCertified, + IsBlacklisted: user.IsBlacklisted, + BlacklistReason: user.BlacklistReason, + BlacklistSource: user.BlacklistSource, + BlacklistedAt: user.BlacklistedAt, + LoginCount: user.LoginCount, + LastLoginAt: user.LastLoginAt, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, } // 添加企业信息 @@ -388,16 +396,20 @@ func (s *UserApplicationServiceImpl) GetUserDetail(ctx context.Context, userID s // 2. 构建响应数据 item := &responses.UserListItem{ - ID: user.ID, - Phone: user.Phone, - UserType: user.UserType, - Username: user.Username, - IsActive: user.Active, - IsCertified: user.IsCertified, - LoginCount: user.LoginCount, - LastLoginAt: user.LastLoginAt, - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, + ID: user.ID, + Phone: user.Phone, + UserType: user.UserType, + Username: user.Username, + IsActive: user.Active, + IsCertified: user.IsCertified, + IsBlacklisted: user.IsBlacklisted, + BlacklistReason: user.BlacklistReason, + BlacklistSource: user.BlacklistSource, + BlacklistedAt: user.BlacklistedAt, + LoginCount: user.LoginCount, + LastLoginAt: user.LastLoginAt, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, } // 添加企业信息 @@ -459,3 +471,64 @@ func (s *UserApplicationServiceImpl) GetUserStats(ctx context.Context) (*respons CertifiedUsers: stats.CertifiedUsers, }, nil } + +// BlacklistUser 将用户列入黑名单,并同步冻结 API 账号 +func (s *UserApplicationServiceImpl) BlacklistUser(ctx context.Context, cmd *commands.BlacklistUserCommand) error { + user, err := s.userAggregateService.GetUserByID(ctx, cmd.UserID) + if err != nil { + return fmt.Errorf("用户不存在") + } + + source := cmd.Source + if source == "" { + source = "self" + } + if err := user.AddToBlacklist(cmd.Reason, source, cmd.OperatorUserID); err != nil { + return err + } + if err := s.userAggregateService.SaveUser(ctx, user); err != nil { + return fmt.Errorf("保存黑名单状态失败: %w", err) + } + + // 同步冻结 API 访问(无 API 账号时忽略) + if err := s.apiUserService.FreezeApiUser(ctx, cmd.UserID); err != nil { + s.logger.Warn("冻结 API 账号失败(可能尚未开通)", + zap.String("user_id", cmd.UserID), + zap.Error(err)) + } + + s.logger.Info("用户已列入黑名单", + zap.String("user_id", cmd.UserID), + zap.String("operator", cmd.OperatorUserID), + zap.String("reason", cmd.Reason), + zap.Time("blacklisted_at", *user.BlacklistedAt), + ) + return nil +} + +// UnblacklistUser 移出黑名单并解冻 API +func (s *UserApplicationServiceImpl) UnblacklistUser(ctx context.Context, cmd *commands.UnblacklistUserCommand) error { + user, err := s.userAggregateService.GetUserByID(ctx, cmd.UserID) + if err != nil { + return fmt.Errorf("用户不存在") + } + + if err := user.RemoveFromBlacklist(); err != nil { + return err + } + if err := s.userAggregateService.SaveUser(ctx, user); err != nil { + return fmt.Errorf("移出黑名单失败: %w", err) + } + + if err := s.apiUserService.UnfreezeApiUser(ctx, cmd.UserID); err != nil { + s.logger.Warn("解冻 API 账号失败(可能尚未开通)", + zap.String("user_id", cmd.UserID), + zap.Error(err)) + } + + s.logger.Info("用户已移出黑名单", + zap.String("user_id", cmd.UserID), + zap.String("operator", cmd.OperatorUserID), + ) + return nil +} diff --git a/internal/container/container.go b/internal/container/container.go index daaf778..6f80a8c 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -28,6 +28,7 @@ import ( domain_product_repo "hyapi-server/internal/domains/product/repositories" product_service "hyapi-server/internal/domains/product/services" statistics_service "hyapi-server/internal/domains/statistics/services" + security_service "hyapi-server/internal/domains/security/services" user_service "hyapi-server/internal/domains/user/services" "hyapi-server/internal/infrastructure/cache" "hyapi-server/internal/infrastructure/database" @@ -534,6 +535,7 @@ func NewContainer() *Container { middleware.NewOptionalAuthMiddleware, middleware.NewAdminAuthMiddleware, middleware.NewDomainAuthMiddleware, + middleware.NewManagedIPBlacklistMiddleware, middleware.NewTraceIDMiddleware, middleware.NewErrorTrackingMiddleware, NewRequestBodyLoggerMiddlewareWrapper, @@ -759,6 +761,7 @@ func NewContainer() *Container { // 领域服务 fx.Provide( + security_service.NewBlacklistService, fx.Annotate( user_service.NewUserAggregateService, ), @@ -874,6 +877,7 @@ func NewContainer() *Container { subscriptionService *product_services.ProductSubscriptionService, exportManager *export.ExportManager, balanceAlertService finance_services.BalanceAlertService, + blacklistService *security_service.BlacklistService, ) api_app.ApiApplicationService { return api_app.NewApiApplicationService( apiCallService, @@ -892,6 +896,7 @@ func NewContainer() *Container { subscriptionService, exportManager, balanceAlertService, + blacklistService, ) }, fx.As(new(api_app.ApiApplicationService)), @@ -1335,6 +1340,7 @@ func NewContainer() *Container { handlers.NewStatisticsHandler, // 管理员安全HTTP处理器 handlers.NewAdminSecurityHandler, + handlers.NewAdminBlacklistHandler, // 文章HTTP处理器 func( appService article.ArticleApplicationService, @@ -1428,6 +1434,7 @@ func NewContainer() *Container { routes.NewStatisticsRoutes, // 管理员安全路由 routes.NewAdminSecurityRoutes, + routes.NewAdminBlacklistRoutes, // PDFG路由 routes.NewPDFGRoutes, // 企业报告页面路由 @@ -1509,6 +1516,7 @@ func RegisterMiddlewares( responseTime *middleware.ResponseTimeMiddleware, cors *middleware.CORSMiddleware, rateLimit *middleware.RateLimitMiddleware, + managedIPBlacklist *middleware.ManagedIPBlacklistMiddleware, dailyRateLimit *middleware.DailyRateLimitMiddleware, requestLogger *middleware.RequestLoggerMiddleware, traceIDMiddleware *middleware.TraceIDMiddleware, @@ -1523,6 +1531,7 @@ func RegisterMiddlewares( router.RegisterMiddleware(responseTime) router.RegisterMiddleware(cors) router.RegisterMiddleware(rateLimit) + router.RegisterMiddleware(managedIPBlacklist) router.RegisterMiddleware(dailyRateLimit) router.RegisterMiddleware(requestLogger) router.RegisterMiddleware(traceIDMiddleware) @@ -1547,6 +1556,7 @@ func RegisterRoutes( apiRoutes *routes.ApiRoutes, statisticsRoutes *routes.StatisticsRoutes, adminSecurityRoutes *routes.AdminSecurityRoutes, + adminBlacklistRoutes *routes.AdminBlacklistRoutes, pdfgRoutes *routes.PDFGRoutes, qyglReportRoutes *routes.QYGLReportRoutes, jwtAuth *middleware.JWTAuthMiddleware, @@ -1574,6 +1584,7 @@ func RegisterRoutes( announcementRoutes.Register(router) statisticsRoutes.Register(router) adminSecurityRoutes.Register(router) + adminBlacklistRoutes.Register(router) pdfgRoutes.Register(router) qyglReportRoutes.Register(router) diff --git a/internal/domains/api/dto/api_request_dto.go b/internal/domains/api/dto/api_request_dto.go index c52998d..f9b67d1 100644 --- a/internal/domains/api/dto/api_request_dto.go +++ b/internal/domains/api/dto/api_request_dto.go @@ -130,6 +130,12 @@ type QYGL7HBNReq struct { type QCXGA8V3Req struct { VinCode string `json:"vin_code" validate:"required"` } +type QCXGH3R8Req struct { + VinCode string `json:"vin_code" validate:"required"` +} +type QCXGK5M2Req struct { + VinCode string `json:"vin_code" validate:"required"` +} type QCXGCP77Req struct { VinCode string `json:"vin_code" validate:"required"` } @@ -148,8 +154,8 @@ type QCXG1S2LReq struct { } type YYSYS7Y1Req struct { MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"` - Name string `json:"name" validate:"required,min=1,validName"` - IDCard string `json:"id_card" validate:"required,validIDCard"` + Name string `json:"name" validate:"omitempty,min=1,validName"` + IDCard string `json:"id_card" validate:"omitempty,validIDCard"` } type JRZQDMLOReq struct { diff --git a/internal/domains/api/services/api_request_service.go b/internal/domains/api/services/api_request_service.go index aa3b9df..aa2ca90 100644 --- a/internal/domains/api/services/api_request_service.go +++ b/internal/domains/api/services/api_request_service.go @@ -255,6 +255,8 @@ func registerAllProcessors(combService *comb.CombService) { "QCXGIJY3": qcxg.ProcessQCXGIJY3Request, // 名下车辆诺尔 "QCXG6U5G": qcxg.ProcessQCXG6U5GRequest, // 车辆核验 "QCXG7K2N": qcxg.ProcessQCXG7K2NRequest, // 人车核验加强版 + "QCXGH3R8": qcxg.ProcessQCXGH3R8Request, // 车辆注册日期查询(VIN) + "QCXGK5M2": qcxg.ProcessQCXGK5M2Request, // 车辆投保日期与城市查询(VIN) // DWBG系列处理器 - 多维报告 diff --git a/internal/domains/api/services/form_config_service.go b/internal/domains/api/services/form_config_service.go index 4819f12..0da19f2 100644 --- a/internal/domains/api/services/form_config_service.go +++ b/internal/domains/api/services/form_config_service.go @@ -302,6 +302,8 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string "FLXGC4CT": &dto.FLXGC4CTReq{}, //汇博-个人涉诉 "QYGLLUCM": &dto.QYGLLUCMReq{}, //汇博-企业涉诉 "QCXGA8V3": &dto.QCXGA8V3Req{}, //全国车辆配置查验(车五项+使用性质+承保) + "QCXGH3R8": &dto.QCXGH3R8Req{}, //车辆注册日期查询(VIN) + "QCXGK5M2": &dto.QCXGK5M2Req{}, //车辆投保日期与城市查询(VIN) "QCXGCP77": &dto.QCXGCP77Req{}, //全国车辆配置查验(车辆详情) "QCXGX2X6": &dto.QCXGX2X6Req{}, //行驶证信息核验V2 "QCXG1S2L": &dto.QCXG1S2LReq{}, //汽车车辆五项 diff --git a/internal/domains/api/services/processors/qcxg/a8v3_shared.go b/internal/domains/api/services/processors/qcxg/a8v3_shared.go new file mode 100644 index 0000000..cafd310 --- /dev/null +++ b/internal/domains/api/services/processors/qcxg/a8v3_shared.go @@ -0,0 +1,67 @@ +package qcxg + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "hyapi-server/internal/domains/api/dto" + "hyapi-server/internal/domains/api/services/processors" +) + +// a8v3RawResult QCXGA8V3 上游原始字段 +type a8v3RawResult struct { + Usage string + RegisterDate string + InsuranceMonth string + VehicleModel string + Brand string + PlateNo string + EngineNo string + Vin string +} + +func callA8V3ByVin(ctx context.Context, vinCode string, deps *processors.ProcessorDependencies) (*a8v3RawResult, error) { + params, err := json.Marshal(dto.QCXGA8V3Req{VinCode: vinCode}) + if err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + respBytes, err := ProcessQCXGA8V3Request(ctx, params, deps) + if err != nil { + return nil, err + } + + return parseA8V3Result(respBytes) +} + +func parseA8V3Result(respBytes []byte) (*a8v3RawResult, error) { + var raw map[string]interface{} + if err := json.Unmarshal(respBytes, &raw); err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + return &a8v3RawResult{ + Usage: asString(raw["usage"]), + RegisterDate: asString(raw["registerDate"]), + InsuranceMonth: asString(raw["insuranceMonth"]), + VehicleModel: asString(raw["clxh"]), + Brand: asString(raw["brand"]), + PlateNo: asString(raw["cph"]), + EngineNo: asString(raw["fdjh"]), + Vin: asString(raw["vin"]), + }, nil +} + +func asString(v interface{}) string { + if v == nil { + return "" + } + switch t := v.(type) { + case string: + return t + default: + return fmt.Sprint(t) + } +} diff --git a/internal/domains/api/services/processors/qcxg/car_plate.go b/internal/domains/api/services/processors/qcxg/car_plate.go new file mode 100644 index 0000000..2083e84 --- /dev/null +++ b/internal/domains/api/services/processors/qcxg/car_plate.go @@ -0,0 +1,43 @@ +package qcxg + +import ( + _ "embed" + "encoding/json" + "strings" + "unicode/utf8" +) + +//go:embed data/car_plate.json +var carPlateJSON []byte + +var carPlateCityMap map[string]string + +func init() { + carPlateCityMap = make(map[string]string) + _ = json.Unmarshal(carPlateJSON, &carPlateCityMap) +} + +// lookupCityByPlateNo 根据车牌号前缀(省简称+字母)查城市,未匹配返回 "-" +func lookupCityByPlateNo(plateNo string) string { + prefix := extractPlatePrefix(plateNo) + if prefix == "" { + return "-" + } + if city, ok := carPlateCityMap[prefix]; ok && city != "" { + return city + } + return "-" +} + +// extractPlatePrefix 取车牌前两个字符作为字典键,如 "鲁A12345" -> "鲁A" +func extractPlatePrefix(plateNo string) string { + plateNo = strings.TrimSpace(plateNo) + if plateNo == "" { + return "" + } + if utf8.RuneCountInString(plateNo) < 2 { + return "" + } + runes := []rune(plateNo) + return string(runes[:2]) +} diff --git a/internal/domains/api/services/processors/qcxg/car_plate_test.go b/internal/domains/api/services/processors/qcxg/car_plate_test.go new file mode 100644 index 0000000..9db364a --- /dev/null +++ b/internal/domains/api/services/processors/qcxg/car_plate_test.go @@ -0,0 +1,59 @@ +package qcxg + +import "testing" + +func TestLookupCityByPlateNo(t *testing.T) { + tests := []struct { + name string + plateNo string + want string + }{ + {name: "济南", plateNo: "鲁A12345", want: "济南市"}, + {name: "青岛增补", plateNo: "鲁U88888", want: "青岛市(增补号段)"}, + {name: "北京", plateNo: "京A00001", want: "北京市"}, + {name: "空车牌", plateNo: "", want: "-"}, + {name: "单字", plateNo: "鲁", want: "-"}, + {name: "未知前缀", plateNo: "XX12345", want: "-"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := lookupCityByPlateNo(tt.plateNo); got != tt.want { + t.Fatalf("lookupCityByPlateNo(%q) = %q, want %q", tt.plateNo, got, tt.want) + } + }) + } +} + +func TestParseA8V3Result(t *testing.T) { + raw := []byte(`{ + "fdjh": "UJ91220946", + "clxh": "LZW1029PYA", + "usage": "非营业", + "insuranceMonth": "2025-02", + "cph": "鲁A12345", + "vin": "LZABCDEF00", + "brand": "五菱牌", + "registerDate": "2018-12" + }`) + + got, err := parseA8V3Result(raw) + if err != nil { + t.Fatalf("parseA8V3Result error: %v", err) + } + if got.RegisterDate != "2018-12" { + t.Fatalf("RegisterDate = %q", got.RegisterDate) + } + if got.InsuranceMonth != "2025-02" { + t.Fatalf("InsuranceMonth = %q", got.InsuranceMonth) + } + if got.PlateNo != "鲁A12345" { + t.Fatalf("PlateNo = %q", got.PlateNo) + } + if got.VehicleModel != "LZW1029PYA" { + t.Fatalf("VehicleModel = %q", got.VehicleModel) + } + if got.EngineNo != "UJ91220946" { + t.Fatalf("EngineNo = %q", got.EngineNo) + } +} diff --git a/internal/domains/api/services/processors/qcxg/data/car_plate.json b/internal/domains/api/services/processors/qcxg/data/car_plate.json new file mode 100644 index 0000000..ca9212c --- /dev/null +++ b/internal/domains/api/services/processors/qcxg/data/car_plate.json @@ -0,0 +1,420 @@ +{ + "京A": "北京市", + "京B": "北京市(出租车)", + "京C": "北京市", + "京E": "北京市", + "京F": "北京市", + "京G": "北京市(远郊区县)", + "京H": "北京市", + "京J": "北京市", + "京K": "北京市", + "京L": "北京市", + "京M": "北京市", + "京N": "北京市", + "京P": "北京市", + "京Q": "北京市", + "京Y": "北京市(远郊区县)", + "津A": "天津市", + "津B": "天津市", + "津C": "天津市", + "津D": "天津市", + "津E": "天津市(出租车)", + "津F": "天津市", + "津G": "天津市", + "津H": "天津市", + "津J": "天津市", + "津K": "天津市", + "津L": "天津市", + "津M": "天津市", + "津N": "天津市", + "津P": "天津市", + "津Q": "天津市", + "津R": "天津市", + "沪A": "上海市", + "沪B": "上海市", + "沪C": "上海市(远郊区县,禁入外环内)", + "沪D": "上海市", + "沪E": "上海市", + "沪F": "上海市", + "沪G": "上海市", + "沪H": "上海市", + "沪J": "上海市", + "沪K": "上海市", + "沪L": "上海市", + "沪M": "上海市", + "沪N": "上海市", + "沪R": "上海市(崇明、长兴、横沙)", + "渝A": "重庆市(主城九区)", + "渝B": "重庆市(主城区增补)", + "渝C": "重庆市(永川区、江津区、合川区、潼南区、铜梁区、璧山区、大足区、綦江区、荣昌区)", + "渝D": "重庆市(主城区增补)", + "渝F": "重庆市(万州区、开州区、梁平区、城口县、巫山县、巫溪县、忠县、云阳县、奉节县)", + "渝G": "重庆市(涪陵区、南川区、垫江县、丰都县、武隆区)", + "渝H": "重庆市(黔江区、石柱土家族自治县、秀山土家族苗族自治县、酉阳土家族苗族自治县、彭水苗族土家族自治县)", + "冀A": "石家庄市", + "冀B": "唐山市", + "冀C": "秦皇岛市", + "冀D": "邯郸市", + "冀E": "邢台市", + "冀F": "保定市", + "冀G": "张家口市", + "冀H": "承德市", + "冀J": "沧州市", + "冀R": "廊坊市", + "冀T": "衡水市", + "晋A": "太原市", + "晋B": "大同市", + "晋C": "阳泉市", + "晋D": "长治市", + "晋E": "晋城市", + "晋F": "朔州市", + "晋H": "忻州市", + "晋J": "吕梁市", + "晋K": "晋中市", + "晋L": "临汾市", + "晋M": "运城市", + "蒙A": "呼和浩特市", + "蒙B": "包头市", + "蒙C": "乌海市", + "蒙D": "赤峰市", + "蒙E": "呼伦贝尔市", + "蒙F": "兴安盟", + "蒙G": "通辽市", + "蒙H": "锡林郭勒盟", + "蒙J": "乌兰察布市", + "蒙K": "鄂尔多斯市", + "蒙L": "巴彦淖尔市", + "蒙M": "阿拉善盟", + "辽A": "沈阳市", + "辽B": "大连市", + "辽C": "鞍山市", + "辽D": "抚顺市", + "辽E": "本溪市", + "辽F": "丹东市", + "辽G": "锦州市", + "辽H": "营口市", + "辽J": "阜新市", + "辽K": "辽阳市", + "辽L": "盘锦市", + "辽M": "铁岭市", + "辽N": "朝阳市", + "辽P": "葫芦岛市", + "吉A": "长春市", + "吉B": "吉林市", + "吉C": "四平市", + "吉D": "辽源市", + "吉E": "通化市", + "吉F": "白山市", + "吉G": "白城市", + "吉H": "延边朝鲜族自治州", + "吉J": "松原市", + "吉K": "长白山保护开发区", + "黑A": "哈尔滨市", + "黑B": "齐齐哈尔市", + "黑C": "牡丹江市", + "黑D": "佳木斯市", + "黑E": "大庆市", + "黑F": "伊春市", + "黑G": "鸡西市", + "黑H": "鹤岗市", + "黑J": "双鸭山市", + "黑K": "七台河市", + "黑L": "哈尔滨市(原松花江地区)", + "黑M": "绥化市", + "黑N": "黑河市", + "黑P": "大兴安岭地区", + "黑R": "黑龙江省农垦系统", + "苏A": "南京市", + "苏B": "无锡市", + "苏C": "徐州市", + "苏D": "常州市", + "苏E": "苏州市", + "苏U": "苏州市(增补号段)", + "苏F": "南通市", + "苏G": "连云港市", + "苏H": "淮安市", + "苏J": "盐城市", + "苏K": "扬州市", + "苏L": "镇江市", + "苏M": "泰州市", + "苏N": "宿迁市", + "浙A": "杭州市", + "浙M": "杭州市(增补号段)", + "浙B": "宁波市", + "浙C": "温州市", + "浙D": "绍兴市", + "浙E": "湖州市", + "浙F": "嘉兴市", + "浙G": "金华市", + "浙H": "衢州市", + "浙J": "台州市", + "浙K": "丽水市", + "浙L": "舟山市", + "皖A": "合肥市", + "皖B": "芜湖市", + "皖C": "蚌埠市", + "皖D": "淮南市", + "皖E": "马鞍山市", + "皖F": "淮北市", + "皖G": "铜陵市", + "皖H": "安庆市", + "皖J": "黄山市", + "皖K": "阜阳市", + "皖L": "宿州市", + "皖M": "滁州市", + "皖N": "六安市", + "皖P": "宣城市", + "皖Q": "巢湖市(已撤销,存量继续使用)", + "皖R": "池州市", + "皖S": "亳州市", + "闽A": "福州市", + "闽B": "莆田市", + "闽C": "泉州市", + "闽D": "厦门市", + "闽E": "漳州市", + "闽F": "龙岩市", + "闽G": "三明市", + "闽H": "南平市", + "闽J": "宁德市", + "闽K": "平潭综合实验区", + "赣A": "南昌市", + "赣M": "南昌市(省直系统)", + "赣B": "赣州市", + "赣C": "宜春市", + "赣D": "吉安市", + "赣E": "上饶市", + "赣F": "抚州市", + "赣G": "九江市", + "赣H": "景德镇市", + "赣J": "萍乡市", + "赣K": "新余市", + "赣L": "鹰潭市", + "鲁A": "济南市", + "鲁S": "济南市(原莱芜市,存量继续使用)", + "鲁B": "青岛市", + "鲁U": "青岛市(增补号段)", + "鲁C": "淄博市", + "鲁D": "枣庄市", + "鲁E": "东营市", + "鲁F": "烟台市", + "鲁Y": "烟台市(增补号段)", + "鲁G": "潍坊市", + "鲁V": "潍坊市(增补号段)", + "鲁H": "济宁市", + "鲁J": "泰安市", + "鲁K": "威海市", + "鲁L": "日照市", + "鲁M": "滨州市", + "鲁N": "德州市", + "鲁P": "聊城市", + "鲁Q": "临沂市", + "鲁R": "菏泽市", + "豫A": "郑州市", + "豫V": "郑州市(增补号段)", + "豫B": "开封市", + "豫C": "洛阳市", + "豫D": "平顶山市", + "豫E": "安阳市", + "豫F": "鹤壁市", + "豫G": "新乡市", + "豫H": "焦作市", + "豫J": "濮阳市", + "豫K": "许昌市", + "豫L": "漯河市", + "豫M": "三门峡市", + "豫N": "商丘市", + "豫P": "周口市", + "豫Q": "驻马店市", + "豫R": "南阳市", + "豫S": "信阳市", + "豫U": "济源市", + "鄂A": "武汉市", + "鄂W": "武汉市(增补号段)", + "鄂B": "黄石市", + "鄂C": "十堰市", + "鄂D": "荆州市", + "鄂E": "宜昌市", + "鄂F": "襄阳市", + "鄂G": "鄂州市", + "鄂H": "荆门市", + "鄂J": "黄冈市", + "鄂K": "孝感市", + "鄂L": "咸宁市", + "鄂M": "仙桃市", + "鄂N": "潜江市", + "鄂P": "神农架林区", + "鄂Q": "恩施土家族苗族自治州", + "鄂R": "天门市", + "鄂S": "随州市", + "湘A": "长沙市", + "湘B": "株洲市", + "湘C": "湘潭市", + "湘D": "衡阳市", + "湘E": "邵阳市", + "湘F": "岳阳市", + "湘G": "张家界市", + "湘H": "益阳市", + "湘J": "常德市", + "湘K": "娄底市", + "湘L": "郴州市", + "湘M": "永州市", + "湘N": "怀化市", + "湘U": "湘西土家族苗族自治州", + "粤A": "广州市", + "粤B": "深圳市", + "粤C": "珠海市", + "粤D": "汕头市", + "粤E": "佛山市", + "粤X": "佛山市(顺德区,存量继续使用)", + "粤Y": "佛山市(南海区,存量继续使用)", + "粤F": "韶关市", + "粤G": "湛江市", + "粤H": "肇庆市", + "粤J": "江门市", + "粤K": "茂名市", + "粤L": "惠州市", + "粤M": "梅州市", + "粤N": "汕尾市", + "粤P": "河源市", + "粤Q": "阳江市", + "粤R": "清远市", + "粤S": "东莞市", + "粤T": "中山市", + "粤U": "潮州市", + "粤V": "揭阳市", + "粤W": "云浮市", + "桂A": "南宁市", + "桂B": "柳州市", + "桂C": "桂林市", + "桂H": "桂林市(原桂林地区)", + "桂D": "梧州市", + "桂E": "北海市", + "桂F": "崇左市", + "桂G": "来宾市", + "桂J": "贺州市", + "桂K": "玉林市", + "桂L": "百色市", + "桂M": "河池市", + "桂N": "钦州市", + "桂P": "防城港市", + "桂R": "贵港市", + "琼A": "海口市", + "琼B": "三亚市", + "琼C": "琼北地区(文昌市、琼海市、万宁市、定安县、屯昌县、澄迈县、临高县)", + "琼D": "琼南地区(五指山市、东方市、白沙黎族自治县、昌江黎族自治县、乐东黎族自治县、陵水黎族自治县、保亭黎族苗族自治县、琼中黎族苗族自治县)", + "琼E": "洋浦经济开发区", + "琼F": "儋州市", + "川A": "成都市", + "川G": "成都市(增补号段)", + "川B": "绵阳市", + "川C": "自贡市", + "川D": "攀枝花市", + "川E": "泸州市", + "川F": "德阳市", + "川H": "广元市", + "川J": "遂宁市", + "川K": "内江市", + "川L": "乐山市", + "川M": "资阳市", + "川Q": "宜宾市", + "川R": "南充市", + "川S": "达州市", + "川T": "雅安市", + "川U": "阿坝藏族羌族自治州", + "川V": "甘孜藏族自治州", + "川W": "凉山彝族自治州", + "川X": "广安市", + "川Y": "巴中市", + "川Z": "眉山市", + "贵A": "贵阳市", + "贵B": "六盘水市", + "贵C": "遵义市", + "贵D": "铜仁市", + "贵E": "黔西南布依族苗族自治州", + "贵F": "毕节市", + "贵G": "安顺市", + "贵H": "黔东南苗族侗族自治州", + "贵J": "黔南布依族苗族自治州", + "云A": "昆明市", + "云B": "东川市(已撤销,存量继续使用)", + "云C": "昭通市", + "云D": "曲靖市", + "云E": "楚雄彝族自治州", + "云F": "玉溪市", + "云G": "红河哈尼族彝族自治州", + "云H": "文山壮族苗族自治州", + "云J": "普洱市", + "云K": "西双版纳傣族自治州", + "云L": "大理白族自治州", + "云M": "保山市", + "云N": "德宏傣族景颇族自治州", + "云P": "丽江市", + "云Q": "怒江傈僳族自治州", + "云R": "迪庆藏族自治州", + "云S": "临沧市", + "藏A": "拉萨市", + "藏B": "昌都市", + "藏C": "山南市", + "藏D": "日喀则市", + "藏E": "那曲市", + "藏F": "阿里地区", + "藏G": "林芝市", + "藏H": "驻四川省天全县车辆管理所", + "藏J": "驻青海省格尔木市车辆管理所", + "陕A": "西安市", + "陕U": "西安市(增补号段)", + "陕B": "铜川市", + "陕C": "宝鸡市", + "陕D": "咸阳市", + "陕E": "渭南市", + "陕F": "汉中市", + "陕G": "安康市", + "陕H": "商洛市", + "陕J": "延安市", + "陕K": "榆林市", + "陕V": "杨凌农业高新技术产业示范区", + "甘A": "兰州市", + "甘B": "嘉峪关市", + "甘C": "金昌市", + "甘D": "白银市", + "甘E": "天水市", + "甘F": "酒泉市", + "甘G": "张掖市", + "甘H": "武威市", + "甘J": "定西市", + "甘K": "陇南市", + "甘L": "平凉市", + "甘M": "庆阳市", + "甘N": "临夏回族自治州", + "甘P": "甘南藏族自治州", + "青A": "西宁市", + "青B": "海东市", + "青C": "海北藏族自治州", + "青D": "黄南藏族自治州", + "青E": "海南藏族自治州", + "青F": "果洛藏族自治州", + "青G": "玉树藏族自治州", + "青H": "海西蒙古族藏族自治州", + "宁A": "银川市", + "宁B": "石嘴山市", + "宁C": "吴忠市", + "宁D": "固原市", + "宁E": "中卫市", + "新A": "乌鲁木齐市", + "新B": "昌吉回族自治州", + "新C": "石河子市", + "新D": "奎屯市", + "新E": "博尔塔拉蒙古自治州", + "新F": "伊犁哈萨克自治州", + "新G": "塔城地区", + "新H": "阿勒泰地区", + "新J": "克拉玛依市", + "新K": "吐鲁番市", + "新L": "哈密市", + "新M": "巴音郭楞蒙古自治州", + "新N": "阿克苏地区", + "新P": "克孜勒苏柯尔克孜自治州", + "新Q": "喀什地区", + "新R": "和田地区", + "新S": "昆玉市" +} \ No newline at end of file diff --git a/internal/domains/api/services/processors/qcxg/qcxgh3r8_processor.go b/internal/domains/api/services/processors/qcxg/qcxgh3r8_processor.go new file mode 100644 index 0000000..0da402b --- /dev/null +++ b/internal/domains/api/services/processors/qcxg/qcxgh3r8_processor.go @@ -0,0 +1,36 @@ +package qcxg + +import ( + "context" + "encoding/json" + "errors" + + "hyapi-server/internal/domains/api/dto" + "hyapi-server/internal/domains/api/services/processors" +) + +// ProcessQCXGH3R8Request QCXGH3R8 车辆注册日期查询(VIN) +// 复用 QCXGA8V3,仅返回 registerDate。 +func ProcessQCXGH3R8Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.QCXGH3R8Req + if err := json.Unmarshal(params, ¶msDto); err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + if err := deps.Validator.ValidateStruct(paramsDto); err != nil { + return nil, errors.Join(processors.ErrInvalidParam, err) + } + + result, err := callA8V3ByVin(ctx, paramsDto.VinCode, deps) + if err != nil { + return nil, err + } + + respBytes, err := json.Marshal(map[string]string{ + "registerDate": result.RegisterDate, + }) + if err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + return respBytes, nil +} diff --git a/internal/domains/api/services/processors/qcxg/qcxgk5m2_processor.go b/internal/domains/api/services/processors/qcxg/qcxgk5m2_processor.go new file mode 100644 index 0000000..dabc650 --- /dev/null +++ b/internal/domains/api/services/processors/qcxg/qcxgk5m2_processor.go @@ -0,0 +1,37 @@ +package qcxg + +import ( + "context" + "encoding/json" + "errors" + + "hyapi-server/internal/domains/api/dto" + "hyapi-server/internal/domains/api/services/processors" +) + +// ProcessQCXGK5M2Request QCXGK5M2 车辆投保日期与城市查询(VIN) +// 复用 QCXGA8V3,返回 insuranceMonth,并根据车牌前缀字典解析 city。 +func ProcessQCXGK5M2Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.QCXGK5M2Req + if err := json.Unmarshal(params, ¶msDto); err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + if err := deps.Validator.ValidateStruct(paramsDto); err != nil { + return nil, errors.Join(processors.ErrInvalidParam, err) + } + + result, err := callA8V3ByVin(ctx, paramsDto.VinCode, deps) + if err != nil { + return nil, err + } + + respBytes, err := json.Marshal(map[string]string{ + "insuranceMonth": result.InsuranceMonth, + "city": lookupCityByPlateNo(result.PlateNo), + }) + if err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + return respBytes, nil +} diff --git a/internal/domains/api/services/processors/yysy/yysys7y1_processor.go b/internal/domains/api/services/processors/yysy/yysys7y1_processor.go index 9fcdf72..8575902 100644 --- a/internal/domains/api/services/processors/yysy/yysys7y1_processor.go +++ b/internal/domains/api/services/processors/yysy/yysys7y1_processor.go @@ -21,12 +21,16 @@ func ProcessYYSYS7Y1Request(ctx context.Context, params []byte, deps *processors return nil, errors.Join(processors.ErrInvalidParam, err) } - // 构建数据宝入参:phone、name、idCard 需传 MD5 密文 + // 构建数据宝入参:phone、name、idCard 需传 MD5 密文(name、idCard 选填) reqParams := map[string]interface{}{ - "key": "40130990be30f9fea6422168d138ff6c", - "phone": shujubao.MD5Encrypt(paramsDto.MobileNo), - "name": shujubao.MD5Encrypt(paramsDto.Name), - "idCard": shujubao.MD5Encrypt(paramsDto.IDCard), + "key": "40130990be30f9fea6422168d138ff6c", + "phone": shujubao.MD5Encrypt(paramsDto.MobileNo), + } + if paramsDto.Name != "" { + reqParams["name"] = shujubao.MD5Encrypt(paramsDto.Name) + } + if paramsDto.IDCard != "" { + reqParams["idCard"] = shujubao.MD5Encrypt(paramsDto.IDCard) } // 最终请求 URL = https://api.chinadatapay.com/communication + 拼接接口地址值,如 personal/197 diff --git a/internal/domains/security/entities/blacklist.go b/internal/domains/security/entities/blacklist.go new file mode 100644 index 0000000..5afa2a8 --- /dev/null +++ b/internal/domains/security/entities/blacklist.go @@ -0,0 +1,46 @@ +package entities + +import "time" + +// IPBlacklistEntry 管理端维护的 IP 黑名单 +type IPBlacklistEntry struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + IP string `gorm:"type:varchar(64);not null;uniqueIndex" json:"ip"` + Reason string `gorm:"type:varchar(500);not null;default:''" json:"reason"` + Source string `gorm:"type:varchar(50);not null;default:'self'" json:"source"` + CreatedBy string `gorm:"type:varchar(36);not null;default:''" json:"created_by"` + CreatedAt time.Time `gorm:"autoCreateTime;index" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + DeletedAt *time.Time `gorm:"index" json:"-"` // 软删用手动字段,便于简单实现 + IsActive bool `gorm:"not null;default:true;index" json:"is_active"` +} + +func (IPBlacklistEntry) TableName() string { + return "ip_blacklist_entries" +} + +// HitType 拦截类型 +const ( + HitTypeUserLogin = "user_login" + HitTypeUserJWT = "user_jwt" + HitTypeUserAPI = "user_api" + HitTypeIP = "ip" +) + +// BlacklistHitRecord 黑名单拦截记录 +type BlacklistHitRecord struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + HitType string `gorm:"type:varchar(32);not null;index:idx_hit_type_created,priority:1" json:"hit_type"` + UserID string `gorm:"type:varchar(36);index" json:"user_id,omitempty"` + Phone string `gorm:"type:varchar(20);index" json:"phone,omitempty"` + IP string `gorm:"type:varchar(64);index" json:"ip,omitempty"` + Path string `gorm:"type:varchar(255);not null;default:''" json:"path"` + Method string `gorm:"type:varchar(16);not null;default:''" json:"method"` + Reason string `gorm:"type:varchar(500);not null;default:''" json:"reason"` + UserAgent string `gorm:"type:varchar(512);not null;default:''" json:"user_agent"` + CreatedAt time.Time `gorm:"autoCreateTime;index:idx_hit_type_created,priority:2;index" json:"created_at"` +} + +func (BlacklistHitRecord) TableName() string { + return "blacklist_hit_records" +} diff --git a/internal/domains/security/services/blacklist_service.go b/internal/domains/security/services/blacklist_service.go new file mode 100644 index 0000000..f6c4f57 --- /dev/null +++ b/internal/domains/security/services/blacklist_service.go @@ -0,0 +1,228 @@ +package services + +import ( + "context" + "fmt" + "net" + "strings" + "sync" + "time" + + "hyapi-server/internal/domains/security/entities" + + "go.uber.org/zap" + "gorm.io/gorm" +) + +// BlacklistService 黑名单管理与拦截记录 +type BlacklistService struct { + db *gorm.DB + logger *zap.Logger + + mu sync.RWMutex + ipCache map[string]entities.IPBlacklistEntry + cacheAt time.Time + cacheTTL time.Duration +} + +func NewBlacklistService(db *gorm.DB, logger *zap.Logger) *BlacklistService { + return &BlacklistService{ + db: db, + logger: logger, + ipCache: map[string]entities.IPBlacklistEntry{}, + cacheTTL: 30 * time.Second, + } +} + +func (s *BlacklistService) InvalidateIPCache() { + s.mu.Lock() + defer s.mu.Unlock() + s.ipCache = map[string]entities.IPBlacklistEntry{} + s.cacheAt = time.Time{} +} + +func (s *BlacklistService) refreshIPCache(ctx context.Context) error { + s.mu.RLock() + fresh := time.Since(s.cacheAt) < s.cacheTTL && s.cacheAt.Unix() > 0 + s.mu.RUnlock() + if fresh { + return nil + } + + var list []entities.IPBlacklistEntry + if err := s.db.WithContext(ctx).Where("is_active = ?", true).Find(&list).Error; err != nil { + return err + } + m := make(map[string]entities.IPBlacklistEntry, len(list)) + for _, e := range list { + m[strings.TrimSpace(e.IP)] = e + } + s.mu.Lock() + s.ipCache = m + s.cacheAt = time.Now() + s.mu.Unlock() + return nil +} + +// IsIPBlocked 检查 IP 是否在管理端黑名单(支持精确 IP) +func (s *BlacklistService) IsIPBlocked(ctx context.Context, ip string) (bool, *entities.IPBlacklistEntry) { + ip = strings.TrimSpace(ip) + if ip == "" { + return false, nil + } + _ = s.refreshIPCache(ctx) + s.mu.RLock() + defer s.mu.RUnlock() + if e, ok := s.ipCache[ip]; ok { + cp := e + return true, &cp + } + // 简易通配:缓存里带 * 的模式 + for pattern, e := range s.ipCache { + if matchIPPattern(ip, pattern) { + cp := e + return true, &cp + } + } + return false, nil +} + +func matchIPPattern(ip, pattern string) bool { + if !strings.Contains(pattern, "*") { + return ip == pattern + } + parts := strings.Split(pattern, ".") + clientParts := strings.Split(ip, ".") + if len(parts) != 4 || len(clientParts) != 4 { + return false + } + for i := range parts { + if parts[i] != "*" && parts[i] != clientParts[i] { + return false + } + } + return true +} + +// RecordHit 异步写入拦截记录 +func (s *BlacklistService) RecordHit(hit *entities.BlacklistHitRecord) { + if hit == nil { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := s.db.WithContext(ctx).Create(hit).Error; err != nil { + s.logger.Warn("写入黑名单拦截记录失败", zap.Error(err)) + } + }() +} + +func (s *BlacklistService) ListIP(ctx context.Context, page, pageSize int, keyword string) ([]entities.IPBlacklistEntry, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + q := s.db.WithContext(ctx).Model(&entities.IPBlacklistEntry{}).Where("is_active = ?", true) + if keyword != "" { + q = q.Where("ip LIKE ? OR reason LIKE ?", "%"+keyword+"%", "%"+keyword+"%") + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var list []entities.IPBlacklistEntry + err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error + return list, total, err +} + +func (s *BlacklistService) AddIP(ctx context.Context, ip, reason, source, createdBy string) (*entities.IPBlacklistEntry, error) { + ip = strings.TrimSpace(ip) + if ip == "" { + return nil, fmt.Errorf("IP不能为空") + } + if strings.Contains(ip, "*") { + // 允许简单通配,跳过 net.ParseIP + } else if net.ParseIP(ip) == nil { + return nil, fmt.Errorf("IP格式无效") + } + if reason == "" { + return nil, fmt.Errorf("原因不能为空") + } + if source == "" { + source = "self" + } + + var existing entities.IPBlacklistEntry + err := s.db.WithContext(ctx).Where("ip = ?", ip).First(&existing).Error + if err == nil { + if existing.IsActive { + return nil, fmt.Errorf("该IP已在黑名单中") + } + existing.IsActive = true + existing.Reason = reason + existing.Source = source + existing.CreatedBy = createdBy + if err := s.db.WithContext(ctx).Save(&existing).Error; err != nil { + return nil, err + } + s.InvalidateIPCache() + return &existing, nil + } + if err != gorm.ErrRecordNotFound { + return nil, err + } + + entry := &entities.IPBlacklistEntry{ + IP: ip, + Reason: reason, + Source: source, + CreatedBy: createdBy, + IsActive: true, + } + if err := s.db.WithContext(ctx).Create(entry).Error; err != nil { + return nil, err + } + s.InvalidateIPCache() + return entry, nil +} + +func (s *BlacklistService) RemoveIP(ctx context.Context, id uint64) error { + res := s.db.WithContext(ctx).Model(&entities.IPBlacklistEntry{}). + Where("id = ? AND is_active = ?", id, true). + Updates(map[string]interface{}{"is_active": false}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return fmt.Errorf("记录不存在或已移除") + } + s.InvalidateIPCache() + return nil +} + +func (s *BlacklistService) ListHits(ctx context.Context, page, pageSize int, hitType, keyword string) ([]entities.BlacklistHitRecord, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + q := s.db.WithContext(ctx).Model(&entities.BlacklistHitRecord{}) + if hitType != "" { + q = q.Where("hit_type = ?", hitType) + } + if keyword != "" { + like := "%" + keyword + "%" + q = q.Where("phone LIKE ? OR ip LIKE ? OR user_id LIKE ? OR path LIKE ? OR reason LIKE ?", like, like, like, like, like) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var list []entities.BlacklistHitRecord + err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error + return list, total, err +} diff --git a/internal/domains/user/entities/user.go b/internal/domains/user/entities/user.go index c99bd45..30c2c7a 100644 --- a/internal/domains/user/entities/user.go +++ b/internal/domains/user/entities/user.go @@ -39,6 +39,13 @@ type User struct { LoginCount int `gorm:"default:0" json:"login_count" comment:"登录次数统计"` Permissions string `gorm:"type:text" json:"permissions" comment:"权限列表(JSON格式存储)"` + // 用户黑名单(网安合规:纳入后拦截登录与 API) + IsBlacklisted bool `gorm:"column:is_blacklisted;default:false;index" json:"is_blacklisted" comment:"是否已列入黑名单"` + BlacklistReason string `gorm:"column:blacklist_reason;type:varchar(500)" json:"blacklist_reason,omitempty" comment:"纳入黑名单原因"` + BlacklistSource string `gorm:"column:blacklist_source;type:varchar(50)" json:"blacklist_source,omitempty" comment:"来源:self_发现/police_公安通报/test"` + BlacklistedAt *time.Time `gorm:"column:blacklisted_at" json:"blacklisted_at,omitempty" comment:"黑名单生效时间"` + BlacklistedBy string `gorm:"column:blacklisted_by;type:varchar(36)" json:"blacklisted_by,omitempty" comment:"操作人用户ID"` + // 时间戳字段 CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" comment:"创建时间"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" comment:"更新时间"` @@ -457,7 +464,7 @@ func (u *User) ResetPassword(newPassword, confirmPassword string) error { // CanLogin 检查用户是否可以登录 func (u *User) CanLogin() bool { // 检查用户是否被删除 - if !u.DeletedAt.Time.IsZero() { + if u.IsDeleted() { return false } @@ -466,14 +473,62 @@ func (u *User) CanLogin() bool { return false } - // 如果是管理员,检查是否激活 - if u.IsAdmin() && !u.Active { + // 黑名单用户禁止登录 + if u.IsBlacklisted { + return false + } + + // 未激活禁止登录(普通用户与管理员一致) + if !u.Active { return false } return true } +// IsOnBlacklist 是否在黑名单中 +func (u *User) IsOnBlacklist() bool { + return u.IsBlacklisted +} + +// AddToBlacklist 纳入黑名单(立即生效) +func (u *User) AddToBlacklist(reason, source, operatorUserID string) error { + if u.IsAdmin() { + return fmt.Errorf("不能将管理员列入黑名单") + } + if u.IsBlacklisted { + return fmt.Errorf("用户已在黑名单中") + } + if reason == "" { + return NewValidationError("纳入原因不能为空") + } + if source == "" { + source = "self" + } + now := time.Now() + u.IsBlacklisted = true + u.BlacklistReason = reason + u.BlacklistSource = source + u.BlacklistedAt = &now + u.BlacklistedBy = operatorUserID + u.Active = false + return nil +} + +// RemoveFromBlacklist 移出黑名单并恢复激活 +func (u *User) RemoveFromBlacklist() error { + if !u.IsBlacklisted { + return fmt.Errorf("用户不在黑名单中") + } + u.IsBlacklisted = false + u.BlacklistReason = "" + u.BlacklistSource = "" + u.BlacklistedAt = nil + u.BlacklistedBy = "" + u.Active = true + return nil +} + // IsActive 检查用户是否处于活跃状态 func (u *User) IsActive() bool { return u.DeletedAt.Time.IsZero() diff --git a/internal/domains/user/repositories/queries/user_queries.go b/internal/domains/user/repositories/queries/user_queries.go index 7c75578..e9b08f2 100644 --- a/internal/domains/user/repositories/queries/user_queries.go +++ b/internal/domains/user/repositories/queries/user_queries.go @@ -6,11 +6,12 @@ type ListUsersQuery struct { PageSize int `json:"page_size"` Phone string `json:"phone"` UserType string `json:"user_type"` // 用户类型: user/admin - IsActive *bool `json:"is_active"` // 是否激活 - IsCertified *bool `json:"is_certified"` // 是否已认证 - CompanyName string `json:"company_name"` // 企业名称 - StartDate string `json:"start_date"` - EndDate string `json:"end_date"` + IsActive *bool `json:"is_active"` // 是否激活 + IsCertified *bool `json:"is_certified"` // 是否已认证 + IsBlacklisted *bool `json:"is_blacklisted"` // 是否黑名单 + CompanyName string `json:"company_name"` // 企业名称 + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` } // ListSMSCodesQuery 短信验证码列表查询参数 diff --git a/internal/domains/user/services/user_auth_service.go b/internal/domains/user/services/user_auth_service.go index 42276ef..2dab9e4 100644 --- a/internal/domains/user/services/user_auth_service.go +++ b/internal/domains/user/services/user_auth_service.go @@ -8,23 +8,27 @@ import ( "hyapi-server/internal/domains/user/entities" "hyapi-server/internal/domains/user/repositories" + securityEntities "hyapi-server/internal/domains/security/entities" + securityServices "hyapi-server/internal/domains/security/services" ) // UserAuthService 用户认证领域服务 -// 负责用户认证相关的业务逻辑,包括密码验证、登录状态管理等 type UserAuthService struct { - userRepo repositories.UserRepository - logger *zap.Logger + userRepo repositories.UserRepository + blacklist *securityServices.BlacklistService + logger *zap.Logger } // NewUserAuthService 创建用户认证领域服务 func NewUserAuthService( userRepo repositories.UserRepository, + blacklist *securityServices.BlacklistService, logger *zap.Logger, ) *UserAuthService { return &UserAuthService{ - userRepo: userRepo, - logger: logger, + userRepo: userRepo, + blacklist: blacklist, + logger: logger, } } @@ -35,6 +39,17 @@ func (s *UserAuthService) ValidatePassword(ctx context.Context, phone, password return nil, fmt.Errorf("用户名或密码错误") } + if user.IsOnBlacklist() { + if s.blacklist != nil { + s.blacklist.RecordHit(&securityEntities.BlacklistHitRecord{ + HitType: securityEntities.HitTypeUserLogin, + UserID: user.ID, + Phone: user.Phone, + Reason: user.BlacklistReason, + }) + } + return nil, fmt.Errorf("账号已被列入黑名单,无法登录") + } if !user.CanLogin() { return nil, fmt.Errorf("用户状态异常,无法登录") } @@ -54,6 +69,17 @@ func (s *UserAuthService) ValidateUserLogin(ctx context.Context, phone string) ( return nil, fmt.Errorf("用户不存在") } + if user.IsOnBlacklist() { + if s.blacklist != nil { + s.blacklist.RecordHit(&securityEntities.BlacklistHitRecord{ + HitType: securityEntities.HitTypeUserLogin, + UserID: user.ID, + Phone: user.Phone, + Reason: user.BlacklistReason, + }) + } + return nil, fmt.Errorf("账号已被列入黑名单,无法登录") + } if !user.CanLogin() { return nil, fmt.Errorf("用户状态异常,无法登录") } diff --git a/internal/infrastructure/database/repositories/user/gorm_user_repository.go b/internal/infrastructure/database/repositories/user/gorm_user_repository.go index 7cd154c..08a3f8a 100644 --- a/internal/infrastructure/database/repositories/user/gorm_user_repository.go +++ b/internal/infrastructure/database/repositories/user/gorm_user_repository.go @@ -213,6 +213,9 @@ func (r *GormUserRepository) ListUsers(ctx context.Context, query *queries.ListU if query.IsCertified != nil { db = db.Where("users.is_certified = ?", *query.IsCertified) } + if query.IsBlacklisted != nil { + db = db.Where("users.is_blacklisted = ?", *query.IsBlacklisted) + } if query.CompanyName != "" { db = db.Joins("LEFT JOIN enterprise_infos ON users.id = enterprise_infos.user_id"). Where("enterprise_infos.company_name LIKE ?", "%"+query.CompanyName+"%") diff --git a/internal/infrastructure/http/handlers/admin_blacklist_handler.go b/internal/infrastructure/http/handlers/admin_blacklist_handler.go new file mode 100644 index 0000000..73aef95 --- /dev/null +++ b/internal/infrastructure/http/handlers/admin_blacklist_handler.go @@ -0,0 +1,223 @@ +package handlers + +import ( + "strconv" + "strings" + + "hyapi-server/internal/application/user" + "hyapi-server/internal/application/user/dto/commands" + "hyapi-server/internal/application/user/dto/queries" + securityServices "hyapi-server/internal/domains/security/services" + "hyapi-server/internal/shared/interfaces" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// AdminBlacklistHandler 管理端黑名单 +type AdminBlacklistHandler struct { + userApp user.UserApplicationService + blacklist *securityServices.BlacklistService + response interfaces.ResponseBuilder + logger *zap.Logger +} + +func NewAdminBlacklistHandler( + userApp user.UserApplicationService, + blacklist *securityServices.BlacklistService, + response interfaces.ResponseBuilder, + logger *zap.Logger, +) *AdminBlacklistHandler { + return &AdminBlacklistHandler{ + userApp: userApp, + blacklist: blacklist, + response: response, + logger: logger, + } +} + +func (h *AdminBlacklistHandler) operatorID(c *gin.Context) string { + if v, ok := c.Get("user_id"); ok { + if id, ok := v.(string); ok { + return id + } + } + return "" +} + +func (h *AdminBlacklistHandler) pageParams(c *gin.Context) (int, int) { + page, pageSize := 1, 20 + if v := c.Query("page"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + page = n + } + } + if v := c.Query("page_size"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 { + pageSize = n + } + } + return page, pageSize +} + +// ListUsers 用户黑名单列表 +func (h *AdminBlacklistHandler) ListUsers(c *gin.Context) { + page, pageSize := h.pageParams(c) + flag := true + q := &queries.ListUsersQuery{ + Page: page, + PageSize: pageSize, + Phone: c.Query("phone"), + IsBlacklisted: &flag, + } + resp, err := h.userApp.ListUsers(c.Request.Context(), q) + if err != nil { + h.logger.Error("查询用户黑名单失败", zap.Error(err)) + h.response.BadRequest(c, "查询失败") + return + } + h.response.Success(c, resp, "ok") +} + +// AddUser 按手机号或用户ID加入黑名单 +func (h *AdminBlacklistHandler) AddUser(c *gin.Context) { + op := h.operatorID(c) + if op == "" { + h.response.Unauthorized(c, "未登录") + return + } + var body struct { + UserID string `json:"user_id"` + Phone string `json:"phone"` + Reason string `json:"reason" binding:"required"` + Source string `json:"source"` + } + if err := c.ShouldBindJSON(&body); err != nil { + h.response.BadRequest(c, "请填写纳入原因") + return + } + + userID := strings.TrimSpace(body.UserID) + if userID == "" { + phone := strings.TrimSpace(body.Phone) + if phone == "" { + h.response.BadRequest(c, "请提供用户ID或手机号") + return + } + list, err := h.userApp.ListUsers(c.Request.Context(), &queries.ListUsersQuery{ + Page: 1, + PageSize: 50, + Phone: phone, + }) + if err != nil || list == nil || len(list.Items) == 0 { + h.response.BadRequest(c, "未找到该手机号用户") + return + } + for _, item := range list.Items { + if item.Phone == phone { + userID = item.ID + break + } + } + if userID == "" { + userID = list.Items[0].ID + } + } + + cmd := &commands.BlacklistUserCommand{ + UserID: userID, + OperatorUserID: op, + Reason: body.Reason, + Source: body.Source, + } + if err := h.userApp.BlacklistUser(c.Request.Context(), cmd); err != nil { + h.response.BadRequest(c, err.Error()) + return + } + h.response.Success(c, gin.H{"user_id": userID}, "已列入用户黑名单") +} + +// RemoveUser 移出用户黑名单 +func (h *AdminBlacklistHandler) RemoveUser(c *gin.Context) { + op := h.operatorID(c) + userID := c.Param("user_id") + if userID == "" { + h.response.BadRequest(c, "用户ID不能为空") + return + } + if err := h.userApp.UnblacklistUser(c.Request.Context(), &commands.UnblacklistUserCommand{ + UserID: userID, + OperatorUserID: op, + }); err != nil { + h.response.BadRequest(c, err.Error()) + return + } + h.response.Success(c, nil, "已移出用户黑名单") +} + +// ListIPs IP 黑名单 +func (h *AdminBlacklistHandler) ListIPs(c *gin.Context) { + page, pageSize := h.pageParams(c) + list, total, err := h.blacklist.ListIP(c.Request.Context(), page, pageSize, c.Query("keyword")) + if err != nil { + h.response.BadRequest(c, "查询失败") + return + } + h.response.Success(c, gin.H{ + "items": list, + "total": total, + "page": page, + "size": pageSize, + }, "ok") +} + +// AddIP 添加 IP 黑名单 +func (h *AdminBlacklistHandler) AddIP(c *gin.Context) { + op := h.operatorID(c) + var body struct { + IP string `json:"ip" binding:"required"` + Reason string `json:"reason" binding:"required"` + Source string `json:"source"` + } + if err := c.ShouldBindJSON(&body); err != nil { + h.response.BadRequest(c, "请填写IP和原因") + return + } + entry, err := h.blacklist.AddIP(c.Request.Context(), body.IP, body.Reason, body.Source, op) + if err != nil { + h.response.BadRequest(c, err.Error()) + return + } + h.response.Success(c, entry, "已加入IP黑名单") +} + +// RemoveIP 移除 IP 黑名单 +func (h *AdminBlacklistHandler) RemoveIP(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + h.response.BadRequest(c, "无效ID") + return + } + if err := h.blacklist.RemoveIP(c.Request.Context(), id); err != nil { + h.response.BadRequest(c, err.Error()) + return + } + h.response.Success(c, nil, "已移除IP黑名单") +} + +// ListHits 拦截记录 +func (h *AdminBlacklistHandler) ListHits(c *gin.Context) { + page, pageSize := h.pageParams(c) + list, total, err := h.blacklist.ListHits(c.Request.Context(), page, pageSize, c.Query("hit_type"), c.Query("keyword")) + if err != nil { + h.response.BadRequest(c, "查询失败") + return + } + h.response.Success(c, gin.H{ + "items": list, + "total": total, + "page": page, + "size": pageSize, + }, "ok") +} diff --git a/internal/infrastructure/http/handlers/user_handler.go b/internal/infrastructure/http/handlers/user_handler.go index 34815e0..3667e2a 100644 --- a/internal/infrastructure/http/handlers/user_handler.go +++ b/internal/infrastructure/http/handlers/user_handler.go @@ -445,6 +445,12 @@ func (h *UserHandler) ListUsers(c *gin.Context) { } } + if isBlacklisted := c.Query("is_blacklisted"); isBlacklisted != "" { + if blacklisted, err := strconv.ParseBool(isBlacklisted); err == nil { + query.IsBlacklisted = &blacklisted + } + } + // 调用应用服务 resp, err := h.appService.ListUsers(c.Request.Context(), query) if err != nil { @@ -520,6 +526,71 @@ func (h *UserHandler) GetUserStats(c *gin.Context) { h.response.Success(c, resp, "获取用户统计信息成功") } +// BlacklistUser 管理员将用户列入黑名单 +func (h *UserHandler) BlacklistUser(c *gin.Context) { + operatorID := h.getCurrentUserID(c) + if operatorID == "" { + h.response.Unauthorized(c, "用户未登录") + return + } + + targetUserID := c.Param("user_id") + if targetUserID == "" { + h.response.BadRequest(c, "用户ID不能为空") + return + } + + var body struct { + Reason string `json:"reason" binding:"required"` + Source string `json:"source"` + } + if err := c.ShouldBindJSON(&body); err != nil { + h.response.BadRequest(c, "请填写纳入原因") + return + } + + cmd := &commands.BlacklistUserCommand{ + UserID: targetUserID, + OperatorUserID: operatorID, + Reason: body.Reason, + Source: body.Source, + } + if err := h.appService.BlacklistUser(c.Request.Context(), cmd); err != nil { + h.logger.Error("列入黑名单失败", zap.Error(err), zap.String("user_id", targetUserID)) + h.response.BadRequest(c, err.Error()) + return + } + + h.response.Success(c, nil, "已列入黑名单") +} + +// UnblacklistUser 管理员将用户移出黑名单 +func (h *UserHandler) UnblacklistUser(c *gin.Context) { + operatorID := h.getCurrentUserID(c) + if operatorID == "" { + h.response.Unauthorized(c, "用户未登录") + return + } + + targetUserID := c.Param("user_id") + if targetUserID == "" { + h.response.BadRequest(c, "用户ID不能为空") + return + } + + cmd := &commands.UnblacklistUserCommand{ + UserID: targetUserID, + OperatorUserID: operatorID, + } + if err := h.appService.UnblacklistUser(c.Request.Context(), cmd); err != nil { + h.logger.Error("移出黑名单失败", zap.Error(err), zap.String("user_id", targetUserID)) + h.response.BadRequest(c, err.Error()) + return + } + + h.response.Success(c, nil, "已移出黑名单") +} + // getCurrentUserID 获取当前用户ID func (h *UserHandler) getCurrentUserID(c *gin.Context) string { if userID, exists := c.Get("user_id"); exists { diff --git a/internal/infrastructure/http/routes/admin_blacklist_routes.go b/internal/infrastructure/http/routes/admin_blacklist_routes.go new file mode 100644 index 0000000..a5dd60d --- /dev/null +++ b/internal/infrastructure/http/routes/admin_blacklist_routes.go @@ -0,0 +1,42 @@ +package routes + +import ( + "hyapi-server/internal/infrastructure/http/handlers" + sharedhttp "hyapi-server/internal/shared/http" + "hyapi-server/internal/shared/middleware" + + "go.uber.org/zap" +) + +// AdminBlacklistRoutes 管理端黑名单路由 +type AdminBlacklistRoutes struct { + handler *handlers.AdminBlacklistHandler + admin *middleware.AdminAuthMiddleware + logger *zap.Logger +} + +func NewAdminBlacklistRoutes( + handler *handlers.AdminBlacklistHandler, + admin *middleware.AdminAuthMiddleware, + logger *zap.Logger, +) *AdminBlacklistRoutes { + return &AdminBlacklistRoutes{handler: handler, admin: admin, logger: logger} +} + +func (r *AdminBlacklistRoutes) Register(router *sharedhttp.GinRouter) { + engine := router.GetEngine() + g := engine.Group("/api/v1/admin/blacklist") + g.Use(r.admin.Handle()) + { + g.GET("/users", r.handler.ListUsers) + g.POST("/users", r.handler.AddUser) + g.DELETE("/users/:user_id", r.handler.RemoveUser) + + g.GET("/ips", r.handler.ListIPs) + g.POST("/ips", r.handler.AddIP) + g.DELETE("/ips/:id", r.handler.RemoveIP) + + g.GET("/hits", r.handler.ListHits) + } + r.logger.Info("管理员黑名单路由注册完成") +} diff --git a/internal/infrastructure/http/routes/user_routes.go b/internal/infrastructure/http/routes/user_routes.go index d986b26..f83d1a8 100644 --- a/internal/infrastructure/http/routes/user_routes.go +++ b/internal/infrastructure/http/routes/user_routes.go @@ -57,8 +57,10 @@ func (r *UserRoutes) Register(router *sharedhttp.GinRouter) { adminGroup.Use(r.adminAuthMiddleware.Handle()) { adminGroup.GET("/list", r.handler.ListUsers) // 管理员查看用户列表 + adminGroup.GET("/stats", r.handler.GetUserStats) // 管理员获取用户统计信息(须在 :user_id 前) + adminGroup.POST("/:user_id/blacklist", r.handler.BlacklistUser) + adminGroup.DELETE("/:user_id/blacklist", r.handler.UnblacklistUser) adminGroup.GET("/:user_id", r.handler.GetUserDetail) // 管理员获取用户详情 - adminGroup.GET("/stats", r.handler.GetUserStats) // 管理员获取用户统计信息 } } diff --git a/internal/shared/middleware/auth.go b/internal/shared/middleware/auth.go index 9176afa..20b0f6d 100644 --- a/internal/shared/middleware/auth.go +++ b/internal/shared/middleware/auth.go @@ -6,6 +6,9 @@ import ( "time" "hyapi-server/internal/config" + securityEntities "hyapi-server/internal/domains/security/entities" + securityServices "hyapi-server/internal/domains/security/services" + user_repositories "hyapi-server/internal/domains/user/repositories" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" @@ -14,15 +17,24 @@ import ( // JWTAuthMiddleware JWT认证中间件 type JWTAuthMiddleware struct { - config *config.Config - logger *zap.Logger + config *config.Config + logger *zap.Logger + userRepo user_repositories.UserRepository + blacklist *securityServices.BlacklistService } // NewJWTAuthMiddleware 创建JWT认证中间件 -func NewJWTAuthMiddleware(cfg *config.Config, logger *zap.Logger) *JWTAuthMiddleware { +func NewJWTAuthMiddleware( + cfg *config.Config, + logger *zap.Logger, + userRepo user_repositories.UserRepository, + blacklist *securityServices.BlacklistService, +) *JWTAuthMiddleware { return &JWTAuthMiddleware{ - config: cfg, - logger: logger, + config: cfg, + logger: logger, + userRepo: userRepo, + blacklist: blacklist, } } @@ -75,6 +87,10 @@ func (m *JWTAuthMiddleware) Handle() gin.HandlerFunc { return } + if !m.ensureUserAllowed(c, claims.UserID) { + return + } + // 将用户信息添加到上下文 c.Set("user_id", claims.UserID) c.Set("username", claims.Username) @@ -124,6 +140,52 @@ func (m *JWTAuthMiddleware) validateToken(tokenString string) (*JWTClaims, error return claims, nil } +// ensureUserAllowed 复查用户是否仍可访问(黑名单即时生效) +func (m *JWTAuthMiddleware) ensureUserAllowed(c *gin.Context, userID string) bool { + if m.userRepo == nil || userID == "" { + return true + } + user, err := m.userRepo.GetByID(c.Request.Context(), userID) + if err != nil { + m.logger.Warn("查询用户状态失败", zap.String("user_id", userID), zap.Error(err)) + m.respondUnauthorized(c, "用户状态无效") + return false + } + if user.IsOnBlacklist() { + if m.blacklist != nil { + m.blacklist.RecordHit(&securityEntities.BlacklistHitRecord{ + HitType: securityEntities.HitTypeUserJWT, + UserID: userID, + Phone: user.Phone, + IP: c.ClientIP(), + Path: c.Request.URL.Path, + Method: c.Request.Method, + Reason: user.BlacklistReason, + UserAgent: c.GetHeader("User-Agent"), + }) + } + m.respondForbidden(c, "账号已被列入黑名单") + return false + } + if !user.CanLogin() { + m.respondForbidden(c, "账号不可用") + return false + } + return true +} + +// respondForbidden 禁止访问 +func (m *JWTAuthMiddleware) respondForbidden(c *gin.Context, message string) { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "访问被拒绝", + "error": message, + "request_id": c.GetString("request_id"), + "timestamp": time.Now().Unix(), + }) + c.Abort() +} + // respondUnauthorized 返回未授权响应 func (m *JWTAuthMiddleware) respondUnauthorized(c *gin.Context, message string) { c.JSON(http.StatusUnauthorized, gin.H{ @@ -331,6 +393,10 @@ func (m *AdminAuthMiddleware) Handle() gin.HandlerFunc { return } + if !m.jwtAuth.ensureUserAllowed(c, claims.UserID) { + return + } + // 检查用户类型是否为管理员 if claims.UserType != "admin" { m.respondForbidden(c, "需要管理员权限") diff --git a/internal/shared/middleware/managed_ip_blacklist.go b/internal/shared/middleware/managed_ip_blacklist.go new file mode 100644 index 0000000..7fe8e11 --- /dev/null +++ b/internal/shared/middleware/managed_ip_blacklist.go @@ -0,0 +1,66 @@ +package middleware + +import ( + "net/http" + "time" + + securityEntities "hyapi-server/internal/domains/security/entities" + securityServices "hyapi-server/internal/domains/security/services" + "hyapi-server/internal/shared/interfaces" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// ManagedIPBlacklistMiddleware 管理端维护的 IP 黑名单拦截 +type ManagedIPBlacklistMiddleware struct { + blacklist *securityServices.BlacklistService + response interfaces.ResponseBuilder + logger *zap.Logger +} + +func NewManagedIPBlacklistMiddleware( + blacklist *securityServices.BlacklistService, + response interfaces.ResponseBuilder, + logger *zap.Logger, +) *ManagedIPBlacklistMiddleware { + return &ManagedIPBlacklistMiddleware{ + blacklist: blacklist, + response: response, + logger: logger, + } +} + +func (m *ManagedIPBlacklistMiddleware) GetName() string { return "managed_ip_blacklist" } +func (m *ManagedIPBlacklistMiddleware) GetPriority() int { return 25 } +func (m *ManagedIPBlacklistMiddleware) IsGlobal() bool { return true } + +func (m *ManagedIPBlacklistMiddleware) Handle() gin.HandlerFunc { + return func(c *gin.Context) { + ip := c.ClientIP() + blocked, entry := m.blacklist.IsIPBlocked(c.Request.Context(), ip) + if !blocked { + c.Next() + return + } + reason := "IP在黑名单中" + if entry != nil && entry.Reason != "" { + reason = entry.Reason + } + m.blacklist.RecordHit(&securityEntities.BlacklistHitRecord{ + HitType: securityEntities.HitTypeIP, + IP: ip, + Path: c.Request.URL.Path, + Method: c.Request.Method, + Reason: reason, + UserAgent: c.GetHeader("User-Agent"), + }) + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "访问被拒绝", + "error": "该IP已被列入黑名单", + "request_id": c.GetString("request_id"), + "timestamp": time.Now().Unix(), + }) + } +}