diff --git a/cmd/server/wire.go b/cmd/server/wire.go index f19c4a7..a58c7a7 100644 --- a/cmd/server/wire.go +++ b/cmd/server/wire.go @@ -50,6 +50,8 @@ func ProvideRouter( callHandler *handler.CallHandler, reportHandler *handler.ReportHandler, adminReportHandler *handler.AdminReportHandler, + verificationHandler *handler.VerificationHandler, + adminVerificationHandler *handler.AdminVerificationHandler, logService *service.LogService, activityService service.UserActivityService, casbinService service.CasbinService, @@ -82,6 +84,8 @@ func ProvideRouter( callHandler, reportHandler, adminReportHandler, + verificationHandler, + adminVerificationHandler, logService, activityService, casbinService, diff --git a/cmd/server/wire_gen.go b/cmd/server/wire_gen.go index 62ca35d..af64fec 100644 --- a/cmd/server/wire_gen.go +++ b/cmd/server/wire_gen.go @@ -127,8 +127,13 @@ func InitializeApp() (*App, error) { reportHandler := handler.NewReportHandler(reportService) adminReportService := wire.ProvideAdminReportService(reportRepository, postRepository, commentRepository, messageRepository, userRepository, systemNotificationRepository, pushService, transactionManager, operationLogService) adminReportHandler := handler.NewAdminReportHandler(adminReportService) + verificationRepository := repository.NewVerificationRepository(db) + verificationService := wire.ProvideVerificationService(verificationRepository, userRepository) + verificationHandler := handler.NewVerificationHandler(verificationService) + adminVerificationService := wire.ProvideAdminVerificationService(verificationRepository, userRepository) + adminVerificationHandler := wire.ProvideAdminVerificationHandler(adminVerificationService, userRepository) wsHandler := wire.ProvideWSHandler(hub, chatService, groupService, jwtService, callService) - router := ProvideRouter(userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, logService, userActivityService, casbinService, wsHandler) + router := ProvideRouter(userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, verificationHandler, adminVerificationHandler, logService, userActivityService, casbinService, wsHandler) hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, cache) app := NewApp(config, db, router, pushService, hotRankWorker, server, logger) return app, nil @@ -164,6 +169,8 @@ func ProvideRouter( callHandler *handler.CallHandler, reportHandler *handler.ReportHandler, adminReportHandler *handler.AdminReportHandler, + verificationHandler *handler.VerificationHandler, + adminVerificationHandler *handler.AdminVerificationHandler, logService *service.LogService, activityService service.UserActivityService, casbinService service.CasbinService, @@ -196,6 +203,8 @@ func ProvideRouter( callHandler, reportHandler, adminReportHandler, + verificationHandler, + adminVerificationHandler, logService, activityService, casbinService, diff --git a/go.sum b/go.sum index 0bf4349..2154d41 100644 --- a/go.sum +++ b/go.sum @@ -115,6 +115,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/internal/dto/dto.go b/internal/dto/dto.go index 012188c..715097d 100644 --- a/internal/dto/dto.go +++ b/internal/dto/dto.go @@ -1290,3 +1290,116 @@ type GroupAnnouncementCursorPageResponse struct { PrevCursor string `json:"prev_cursor,omitempty"` HasMore bool `json:"has_more"` } + +// ==================== Verification DTOs ==================== + +// VerificationStatus 审核状态 +type VerificationStatusDTO string + +const ( + VerificationStatusNotSubmittedDTO VerificationStatusDTO = "not_submitted" + VerificationStatusPendingDTO VerificationStatusDTO = "pending" + VerificationStatusApprovedDTO VerificationStatusDTO = "approved" + VerificationStatusRejectedDTO VerificationStatusDTO = "rejected" +) + +// UserIdentityDTO 用户身份类型 +type UserIdentityDTO string + +const ( + UserIdentityGeneralDTO UserIdentityDTO = "general" + UserIdentityStudentDTO UserIdentityDTO = "student" + UserIdentityTeacherDTO UserIdentityDTO = "teacher" + UserIdentityStaffDTO UserIdentityDTO = "staff" + UserIdentityAlumniDTO UserIdentityDTO = "alumni" + UserIdentityExternalDTO UserIdentityDTO = "external" +) + +// SubmitVerificationRequest 提交认证申请请求 +type SubmitVerificationRequest struct { + Identity UserIdentityDTO `json:"identity" binding:"required,oneof=student teacher staff alumni"` + RealName string `json:"real_name" binding:"required,max=100"` + StudentID string `json:"student_id" binding:"omitempty,max=50"` + EmployeeID string `json:"employee_id" binding:"omitempty,max=50"` + Department string `json:"department" binding:"omitempty,max=200"` + ProofMaterials string `json:"proof_materials" binding:"required"` +} + +// VerificationRecordResponse 认证记录响应 +type VerificationRecordResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Identity string `json:"identity"` + Status string `json:"status"` + RealName string `json:"real_name"` + StudentID string `json:"student_id,omitempty"` + EmployeeID string `json:"employee_id,omitempty"` + Department string `json:"department,omitempty"` + ProofMaterials string `json:"proof_materials"` + ReviewedAt string `json:"reviewed_at,omitempty"` + ReviewedBy string `json:"reviewed_by,omitempty"` + RejectReason string `json:"reject_reason,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// VerificationStatusResponse 认证状态响应 +type VerificationStatusResponse struct { + Identity string `json:"identity"` + VerificationStatus string `json:"verification_status"` + HasPendingRequest bool `json:"has_pending_request"` + LatestRecord *VerificationRecordResponse `json:"latest_record,omitempty"` +} + +// AdminVerificationListRequest 管理端认证列表请求 +type AdminVerificationListRequest struct { + Page int `form:"page" binding:"omitempty,min=1"` + PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` + Status string `form:"status" binding:"omitempty,oneof=pending approved rejected"` + Keyword string `form:"keyword" binding:"omitempty,max=100"` +} + +// AdminVerificationListResponse 管理端认证列表响应 +type AdminVerificationListResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Identity string `json:"identity"` + Status string `json:"status"` + RealName string `json:"real_name"` + StudentID string `json:"student_id,omitempty"` + EmployeeID string `json:"employee_id,omitempty"` + Department string `json:"department,omitempty"` + CreatedAt string `json:"created_at"` + ReviewedAt string `json:"reviewed_at,omitempty"` + ReviewerName string `json:"reviewer_name,omitempty"` +} + +// AdminVerificationDetailResponse 管理端认证详情响应 +type AdminVerificationDetailResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Identity string `json:"identity"` + Status string `json:"status"` + RealName string `json:"real_name"` + StudentID string `json:"student_id,omitempty"` + EmployeeID string `json:"employee_id,omitempty"` + Department string `json:"department,omitempty"` + ProofMaterials string `json:"proof_materials"` + CreatedAt string `json:"created_at"` + ReviewedAt string `json:"reviewed_at,omitempty"` + ReviewedBy string `json:"reviewed_by,omitempty"` + ReviewerName string `json:"reviewer_name,omitempty"` + RejectReason string `json:"reject_reason,omitempty"` +} + +// AdminReviewVerificationRequest 管理端审核认证请求 +type AdminReviewVerificationRequest struct { + Approve bool `json:"approve" binding:"required"` + RejectReason string `json:"reject_reason" binding:"omitempty,max=500"` +} diff --git a/internal/errors/app_errors.go b/internal/errors/app_errors.go index 82e9b57..48694f7 100644 --- a/internal/errors/app_errors.go +++ b/internal/errors/app_errors.go @@ -110,23 +110,31 @@ var ( ErrCallAlreadyAnswered = &AppError{Code: "CALL_ALREADY_ANSWERED", Message: "通话已在其他设备应答"} // 消息相关错误 - ErrConversationNotFound = &AppError{Code: "CONVERSATION_NOT_FOUND", Message: "会话不存在或无权限"} - ErrNotConversationMember = &AppError{Code: "NOT_CONVERSATION_MEMBER", Message: "不是会话参与者"} - ErrMessageNotFound = &AppError{Code: "MESSAGE_NOT_FOUND", Message: "消息不存在"} - ErrMessageAlreadyRecalled = &AppError{Code: "MESSAGE_ALREADY_RECALLED", Message: "消息已撤回"} - ErrMessageRecallTimeout = &AppError{Code: "MESSAGE_RECALL_TIMEOUT", Message: "消息撤回超时(2分钟)"} - ErrCannotSendMessage = &AppError{Code: "CANNOT_SEND_MESSAGE", Message: "无法发送消息"} - ErrCannotSendImage = &AppError{Code: "CANNOT_SEND_IMAGE", Message: "对方未关注你,暂不支持发送图片"} - ErrMessageLimitReached = &AppError{Code: "MESSAGE_LIMIT_REACHED", Message: "对方未关注你前,仅允许发送一条消息"} - ErrQRCodeSessionExpired = &AppError{Code: "QRCODE_SESSION_EXPIRED", Message: "二维码已过期"} - ErrQRCodeAlreadyScanned = &AppError{Code: "QRCODE_ALREADY_SCANNED", Message: "二维码已被扫描"} - ErrQRCodeInvalidStatus = &AppError{Code: "QRCODE_INVALID_STATUS", Message: "二维码状态无效"} - ErrPushNotImplemented = &AppError{Code: "PUSH_NOT_IMPLEMENTED", Message: "推送服务未实现"} - ErrPushQueueFull = &AppError{Code: "PUSH_QUEUE_FULL", Message: "推送队列已满"} - ErrUserOffline = &AppError{Code: "USER_OFFLINE", Message: "用户不在线"} + ErrConversationNotFound = &AppError{Code: "CONVERSATION_NOT_FOUND", Message: "会话不存在或无权限"} + ErrNotConversationMember = &AppError{Code: "NOT_CONVERSATION_MEMBER", Message: "不是会话参与者"} + ErrMessageNotFound = &AppError{Code: "MESSAGE_NOT_FOUND", Message: "消息不存在"} + ErrMessageAlreadyRecalled = &AppError{Code: "MESSAGE_ALREADY_RECALLED", Message: "消息已撤回"} + ErrMessageRecallTimeout = &AppError{Code: "MESSAGE_RECALL_TIMEOUT", Message: "消息撤回超时(2分钟)"} + ErrCannotSendMessage = &AppError{Code: "CANNOT_SEND_MESSAGE", Message: "无法发送消息"} + ErrCannotSendImage = &AppError{Code: "CANNOT_SEND_IMAGE", Message: "对方未关注你,暂不支持发送图片"} + ErrMessageLimitReached = &AppError{Code: "MESSAGE_LIMIT_REACHED", Message: "对方未关注你前,仅允许发送一条消息"} + ErrQRCodeSessionExpired = &AppError{Code: "QRCODE_SESSION_EXPIRED", Message: "二维码已过期"} + ErrQRCodeAlreadyScanned = &AppError{Code: "QRCODE_ALREADY_SCANNED", Message: "二维码已被扫描"} + ErrQRCodeInvalidStatus = &AppError{Code: "QRCODE_INVALID_STATUS", Message: "二维码状态无效"} + ErrPushNotImplemented = &AppError{Code: "PUSH_NOT_IMPLEMENTED", Message: "推送服务未实现"} + ErrPushQueueFull = &AppError{Code: "PUSH_QUEUE_FULL", Message: "推送队列已满"} + ErrUserOffline = &AppError{Code: "USER_OFFLINE", Message: "用户不在线"} // 学习资料相关错误 ErrSubjectHasMaterials = &AppError{Code: "SUBJECT_HAS_MATERIALS", Message: "学科下存在资料,无法删除"} + + // 身份认证相关错误 + ErrVerificationPending = &AppError{Code: "VERIFICATION_PENDING", Message: "已有待审核的认证申请,请等待审核"} + ErrVerificationNotFound = &AppError{Code: "VERIFICATION_NOT_FOUND", Message: "认证记录不存在"} + ErrInvalidIdentity = &AppError{Code: "INVALID_IDENTITY", Message: "无效的身份类型"} + ErrAlreadyVerified = &AppError{Code: "ALREADY_VERIFIED", Message: "已完成身份认证,无需重复申请"} + ErrVerificationAlreadyHandled = &AppError{Code: "VERIFICATION_ALREADY_HANDLED", Message: "该认证申请已被处理"} + ErrVerificationRequired = &AppError{Code: "VERIFICATION_REQUIRED", Message: "需要完成身份认证"} ) // Wrap 包装错误 diff --git a/internal/handler/admin_verification_handler.go b/internal/handler/admin_verification_handler.go new file mode 100644 index 0000000..9e8bd8a --- /dev/null +++ b/internal/handler/admin_verification_handler.go @@ -0,0 +1,187 @@ +package handler + +import ( + "strconv" + + "carrot_bbs/internal/dto" + "carrot_bbs/internal/model" + "carrot_bbs/internal/pkg/response" + "carrot_bbs/internal/repository" + "carrot_bbs/internal/service" + + "github.com/gin-gonic/gin" +) + +type AdminVerificationHandler struct { + adminVerificationService service.AdminVerificationService + userRepo repository.UserRepository +} + +func NewAdminVerificationHandler( + adminVerificationService service.AdminVerificationService, + userRepo repository.UserRepository, +) *AdminVerificationHandler { + return &AdminVerificationHandler{ + adminVerificationService: adminVerificationService, + userRepo: userRepo, + } +} + +func (h *AdminVerificationHandler) ListVerifications(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + status := c.Query("status") + keyword := c.Query("keyword") + + if page <= 0 { + page = 1 + } + if pageSize <= 0 || pageSize > 100 { + pageSize = 20 + } + + if status != "" && status != string(model.VerificationStatusPending) && + status != string(model.VerificationStatusApproved) && + status != string(model.VerificationStatusRejected) { + response.BadRequest(c, "无效的状态值") + return + } + + records, total, err := h.adminVerificationService.ListVerifications(c.Request.Context(), page, pageSize, status, keyword) + if err != nil { + response.InternalServerError(c, "获取认证列表失败") + return + } + + responses := make([]*dto.AdminVerificationListResponse, len(records)) + for i, record := range records { + responses[i] = h.convertToAdminListResponse(record) + } + + response.Paginated(c, responses, total, page, pageSize) +} + +func (h *AdminVerificationHandler) GetVerificationDetail(c *gin.Context) { + id := c.Param("id") + if id == "" { + response.BadRequest(c, "认证ID不能为空") + return + } + + record, err := h.adminVerificationService.GetVerificationDetail(c.Request.Context(), id) + if err != nil { + response.NotFound(c, "认证记录不存在") + return + } + + response.Success(c, h.convertToAdminDetailResponse(record)) +} + +func (h *AdminVerificationHandler) ReviewVerification(c *gin.Context) { + reviewerID, exists := c.Get("user_id") + if !exists { + response.Unauthorized(c, "未授权") + return + } + + id := c.Param("id") + if id == "" { + response.BadRequest(c, "认证ID不能为空") + return + } + + var req dto.AdminReviewVerificationRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请求参数错误: "+err.Error()) + return + } + + if !req.Approve && req.RejectReason == "" { + response.BadRequest(c, "拒绝时必须填写拒绝原因") + return + } + + record, err := h.adminVerificationService.ReviewVerification( + c.Request.Context(), + reviewerID.(string), + id, + req.Approve, + req.RejectReason, + ) + if err != nil { + response.HandleError(c, err, "审核失败") + return + } + + response.Success(c, h.convertToAdminDetailResponse(record)) +} + +func (h *AdminVerificationHandler) convertToAdminListResponse(record *model.VerificationRecord) *dto.AdminVerificationListResponse { + resp := &dto.AdminVerificationListResponse{ + ID: record.ID, + UserID: record.UserID, + Identity: string(record.Identity), + Status: string(record.Status), + RealName: record.RealName, + StudentID: record.StudentID, + EmployeeID: record.EmployeeID, + Department: record.Department, + CreatedAt: dto.FormatTime(record.CreatedAt), + } + + user, err := h.userRepo.GetByID(record.UserID) + if err == nil && user != nil { + resp.Username = user.Username + resp.Nickname = user.Nickname + resp.Avatar = user.Avatar + } + + if record.ReviewedAt != nil { + resp.ReviewedAt = dto.FormatTime(*record.ReviewedAt) + if record.ReviewedBy != nil { + reviewer, err := h.userRepo.GetByID(*record.ReviewedBy) + if err == nil && reviewer != nil { + resp.ReviewerName = reviewer.Nickname + } + } + } + + return resp +} + +func (h *AdminVerificationHandler) convertToAdminDetailResponse(record *model.VerificationRecord) *dto.AdminVerificationDetailResponse { + resp := &dto.AdminVerificationDetailResponse{ + ID: record.ID, + UserID: record.UserID, + Identity: string(record.Identity), + Status: string(record.Status), + RealName: record.RealName, + StudentID: record.StudentID, + EmployeeID: record.EmployeeID, + Department: record.Department, + ProofMaterials: record.ProofMaterials, + CreatedAt: dto.FormatTime(record.CreatedAt), + } + + user, err := h.userRepo.GetByID(record.UserID) + if err == nil && user != nil { + resp.Username = user.Username + resp.Nickname = user.Nickname + resp.Avatar = user.Avatar + } + + if record.ReviewedAt != nil { + resp.ReviewedAt = dto.FormatTime(*record.ReviewedAt) + } + if record.ReviewedBy != nil { + resp.ReviewedBy = *record.ReviewedBy + if reviewer, err := h.userRepo.GetByID(*record.ReviewedBy); err == nil && reviewer != nil { + resp.ReviewerName = reviewer.Nickname + } + } + if record.RejectReason != "" { + resp.RejectReason = record.RejectReason + } + + return resp +} diff --git a/internal/handler/verification_handler.go b/internal/handler/verification_handler.go new file mode 100644 index 0000000..835f4cc --- /dev/null +++ b/internal/handler/verification_handler.go @@ -0,0 +1,137 @@ +package handler + +import ( + "strconv" + + "carrot_bbs/internal/dto" + "carrot_bbs/internal/model" + "carrot_bbs/internal/pkg/response" + "carrot_bbs/internal/service" + + "github.com/gin-gonic/gin" +) + +type VerificationHandler struct { + verificationService service.VerificationService +} + +func NewVerificationHandler(verificationService service.VerificationService) *VerificationHandler { + return &VerificationHandler{ + verificationService: verificationService, + } +} + +func (h *VerificationHandler) SubmitVerification(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Unauthorized(c, "未授权") + return + } + + var req dto.SubmitVerificationRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请求参数错误: "+err.Error()) + return + } + + record, err := h.verificationService.SubmitVerification( + c.Request.Context(), + userID.(string), + model.UserIdentity(req.Identity), + req.RealName, + req.StudentID, + req.EmployeeID, + req.Department, + req.ProofMaterials, + ) + if err != nil { + response.HandleError(c, err, "提交认证申请失败") + return + } + + response.Success(c, convertVerificationRecordToResponse(record)) +} + +func (h *VerificationHandler) GetVerificationStatus(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Unauthorized(c, "未授权") + return + } + + status, err := h.verificationService.GetVerificationStatus(c.Request.Context(), userID.(string)) + if err != nil { + response.HandleError(c, err, "获取认证状态失败") + return + } + + response.Success(c, &dto.VerificationStatusResponse{ + Identity: status.Identity, + VerificationStatus: status.VerificationStatus, + HasPendingRequest: status.HasPendingRequest, + LatestRecord: convertVerificationRecordToResponse(status.LatestRecord), + }) +} + +func (h *VerificationHandler) GetVerificationRecords(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Unauthorized(c, "未授权") + return + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) + + if page <= 0 { + page = 1 + } + if pageSize <= 0 || pageSize > 50 { + pageSize = 10 + } + + records, total, err := h.verificationService.GetVerificationRecords(c.Request.Context(), userID.(string), page, pageSize) + if err != nil { + response.HandleError(c, err, "获取认证记录失败") + return + } + + responses := make([]*dto.VerificationRecordResponse, len(records)) + for i, record := range records { + responses[i] = convertVerificationRecordToResponse(record) + } + + response.Paginated(c, responses, total, page, pageSize) +} + +func convertVerificationRecordToResponse(record *model.VerificationRecord) *dto.VerificationRecordResponse { + if record == nil { + return nil + } + + resp := &dto.VerificationRecordResponse{ + ID: record.ID, + UserID: record.UserID, + Identity: string(record.Identity), + Status: string(record.Status), + RealName: record.RealName, + StudentID: record.StudentID, + EmployeeID: record.EmployeeID, + Department: record.Department, + ProofMaterials: record.ProofMaterials, + CreatedAt: dto.FormatTime(record.CreatedAt), + UpdatedAt: dto.FormatTime(record.UpdatedAt), + } + + if record.ReviewedAt != nil { + resp.ReviewedAt = dto.FormatTime(*record.ReviewedAt) + } + if record.ReviewedBy != nil { + resp.ReviewedBy = *record.ReviewedBy + } + if record.RejectReason != "" { + resp.RejectReason = record.RejectReason + } + + return resp +} diff --git a/internal/middleware/verification.go b/internal/middleware/verification.go new file mode 100644 index 0000000..c495e6c --- /dev/null +++ b/internal/middleware/verification.go @@ -0,0 +1,35 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + + "carrot_bbs/internal/model" + "carrot_bbs/internal/pkg/response" + "carrot_bbs/internal/repository" +) + +func RequireVerification(userRepo repository.UserRepository) gin.HandlerFunc { + return func(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + response.Unauthorized(c, "未授权") + c.Abort() + return + } + + user, err := userRepo.GetByID(userID.(string)) + if err != nil { + response.HandleError(c, err, "获取用户信息失败") + c.Abort() + return + } + + if user.VerificationStatus != model.VerificationStatusApproved { + response.Forbidden(c, "需要完成身份认证") + c.Abort() + return + } + + c.Next() + } +} diff --git a/internal/model/init.go b/internal/model/init.go index 74f172a..fc9db10 100644 --- a/internal/model/init.go +++ b/internal/model/init.go @@ -170,6 +170,9 @@ func autoMigrate(db *gorm.DB) error { // 通话相关 &CallSession{}, &CallParticipant{}, + + // 身份认证相关 + &VerificationRecord{}, ) if err != nil { return err diff --git a/internal/model/user.go b/internal/model/user.go index a557103..05d2b80 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -16,6 +16,28 @@ const ( UserStatusInactive UserStatus = "inactive" ) +// UserIdentity 用户身份类型 +type UserIdentity string + +const ( + UserIdentityGeneral UserIdentity = "general" // 普通用户 + UserIdentityStudent UserIdentity = "student" // 学生 + UserIdentityTeacher UserIdentity = "teacher" // 教师 + UserIdentityStaff UserIdentity = "staff" // 职工 + UserIdentityAlumni UserIdentity = "alumni" // 校友 + UserIdentityExternal UserIdentity = "external" // 外部人员 +) + +// VerificationStatus 审核状态 +type VerificationStatus string + +const ( + VerificationStatusNotSubmitted VerificationStatus = "not_submitted" // 未提交 + VerificationStatusPending VerificationStatus = "pending" // 审核中 + VerificationStatusApproved VerificationStatus = "approved" // 已通过 + VerificationStatusRejected VerificationStatus = "rejected" // 已拒绝 +) + // User 用户实体 type User struct { ID string `json:"id" gorm:"type:varchar(36);primaryKey"` @@ -42,6 +64,10 @@ type User struct { FollowersCount int `json:"followers_count" gorm:"default:0"` FollowingCount int `json:"following_count" gorm:"default:0"` + // 身份认证 + Identity UserIdentity `json:"identity" gorm:"type:varchar(20);default:general"` + VerificationStatus VerificationStatus `json:"verification_status" gorm:"type:varchar(20);default:not_submitted"` + // 状态 Status UserStatus `json:"status" gorm:"type:varchar(20);default:active"` LastLoginAt *time.Time `json:"last_login_at" gorm:"type:timestamp"` diff --git a/internal/model/verification.go b/internal/model/verification.go new file mode 100644 index 0000000..b4eb2a1 --- /dev/null +++ b/internal/model/verification.go @@ -0,0 +1,40 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type VerificationRecord struct { + ID string `json:"id" gorm:"type:varchar(36);primaryKey"` + UserID string `json:"user_id" gorm:"type:varchar(36);index;not null"` + Identity UserIdentity `json:"identity" gorm:"type:varchar(20);not null"` + Status VerificationStatus `json:"status" gorm:"type:varchar(20);default:pending"` + + RealName string `json:"real_name" gorm:"type:varchar(100)"` + StudentID string `json:"student_id" gorm:"type:varchar(50)"` + EmployeeID string `json:"employee_id" gorm:"type:varchar(50)"` + Department string `json:"department" gorm:"type:varchar(200)"` + ProofMaterials string `json:"proof_materials" gorm:"type:text"` + + ReviewedAt *time.Time `json:"reviewed_at" gorm:"type:timestamp"` + ReviewedBy *string `json:"reviewed_by" gorm:"type:varchar(36)"` + RejectReason string `json:"reject_reason" gorm:"type:text"` + + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +func (v *VerificationRecord) BeforeCreate(tx *gorm.DB) error { + if v.ID == "" { + v.ID = uuid.New().String() + } + return nil +} + +func (VerificationRecord) TableName() string { + return "verification_records" +} diff --git a/internal/repository/verification_repo.go b/internal/repository/verification_repo.go new file mode 100644 index 0000000..100cba6 --- /dev/null +++ b/internal/repository/verification_repo.go @@ -0,0 +1,106 @@ +package repository + +import ( + "carrot_bbs/internal/model" + + "gorm.io/gorm" +) + +type VerificationRepository interface { + Create(record *model.VerificationRecord) error + GetByID(id string) (*model.VerificationRecord, error) + GetLatestByUserID(userID string) (*model.VerificationRecord, error) + GetPendingByUserID(userID string) (*model.VerificationRecord, error) + List(page, pageSize int, status, keyword string) ([]*model.VerificationRecord, int64, error) + Update(record *model.VerificationRecord) error + GetRecordsByUserID(userID string, page, pageSize int) ([]*model.VerificationRecord, int64, error) +} + +type verificationRepository struct { + db *gorm.DB +} + +func NewVerificationRepository(db *gorm.DB) VerificationRepository { + return &verificationRepository{db: db} +} + +func (r *verificationRepository) Create(record *model.VerificationRecord) error { + return r.db.Create(record).Error +} + +func (r *verificationRepository) GetByID(id string) (*model.VerificationRecord, error) { + var record model.VerificationRecord + err := r.db.First(&record, "id = ?", id).Error + if err != nil { + return nil, err + } + return &record, nil +} + +func (r *verificationRepository) GetLatestByUserID(userID string) (*model.VerificationRecord, error) { + var record model.VerificationRecord + err := r.db.Where("user_id = ?", userID). + Order("created_at DESC"). + First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + +func (r *verificationRepository) GetPendingByUserID(userID string) (*model.VerificationRecord, error) { + var record model.VerificationRecord + err := r.db.Where("user_id = ? AND status = ?", userID, model.VerificationStatusPending). + First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + +func (r *verificationRepository) List(page, pageSize int, status, keyword string) ([]*model.VerificationRecord, int64, error) { + var records []*model.VerificationRecord + var total int64 + + query := r.db.Model(&model.VerificationRecord{}) + + if status != "" { + query = query.Where("verification_records.status = ?", status) + } + + if keyword != "" { + query = query.Joins("JOIN users ON users.id = verification_records.user_id"). + Where("users.username LIKE ? OR users.nickname LIKE ? OR verification_records.real_name LIKE ?", + "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + } + + query.Count(&total) + + offset := (page - 1) * pageSize + err := query.Order("created_at DESC"). + Offset(offset). + Limit(pageSize). + Find(&records).Error + + return records, total, err +} + +func (r *verificationRepository) Update(record *model.VerificationRecord) error { + return r.db.Save(record).Error +} + +func (r *verificationRepository) GetRecordsByUserID(userID string, page, pageSize int) ([]*model.VerificationRecord, int64, error) { + var records []*model.VerificationRecord + var total int64 + + query := r.db.Model(&model.VerificationRecord{}).Where("user_id = ?", userID) + query.Count(&total) + + offset := (page - 1) * pageSize + err := query.Order("created_at DESC"). + Offset(offset). + Limit(pageSize). + Find(&records).Error + + return records, total, err +} diff --git a/internal/router/router.go b/internal/router/router.go index ac6e377..72ef2f3 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -11,36 +11,38 @@ import ( // Router 路由配置 type Router struct { - engine *gin.Engine - userHandler *handler.UserHandler - postHandler *handler.PostHandler - commentHandler *handler.CommentHandler - messageHandler *handler.MessageHandler - notificationHandler *handler.NotificationHandler - uploadHandler *handler.UploadHandler - pushHandler *handler.PushHandler - systemMessageHandler *handler.SystemMessageHandler - groupHandler *handler.GroupHandler - stickerHandler *handler.StickerHandler - voteHandler *handler.VoteHandler - channelHandler *handler.ChannelHandler - scheduleHandler *handler.ScheduleHandler - roleHandler *handler.RoleHandler - adminUserHandler *handler.AdminUserHandler - adminPostHandler *handler.AdminPostHandler - adminCommentHandler *handler.AdminCommentHandler - adminGroupHandler *handler.AdminGroupHandler - adminDashboardHandler *handler.AdminDashboardHandler - adminLogHandler *handler.AdminLogHandler - qrcodeHandler *handler.QRCodeHandler - materialHandler *handler.MaterialHandler - callHandler *handler.CallHandler - reportHandler *handler.ReportHandler - adminReportHandler *handler.AdminReportHandler - wsHandler *handler.WSHandler - logService *service.LogService - jwtService *service.JWTService - casbinService service.CasbinService + engine *gin.Engine + userHandler *handler.UserHandler + postHandler *handler.PostHandler + commentHandler *handler.CommentHandler + messageHandler *handler.MessageHandler + notificationHandler *handler.NotificationHandler + uploadHandler *handler.UploadHandler + pushHandler *handler.PushHandler + systemMessageHandler *handler.SystemMessageHandler + groupHandler *handler.GroupHandler + stickerHandler *handler.StickerHandler + voteHandler *handler.VoteHandler + channelHandler *handler.ChannelHandler + scheduleHandler *handler.ScheduleHandler + roleHandler *handler.RoleHandler + adminUserHandler *handler.AdminUserHandler + adminPostHandler *handler.AdminPostHandler + adminCommentHandler *handler.AdminCommentHandler + adminGroupHandler *handler.AdminGroupHandler + adminDashboardHandler *handler.AdminDashboardHandler + adminLogHandler *handler.AdminLogHandler + qrcodeHandler *handler.QRCodeHandler + materialHandler *handler.MaterialHandler + callHandler *handler.CallHandler + reportHandler *handler.ReportHandler + adminReportHandler *handler.AdminReportHandler + verificationHandler *handler.VerificationHandler + adminVerificationHandler *handler.AdminVerificationHandler + wsHandler *handler.WSHandler + logService *service.LogService + jwtService *service.JWTService + casbinService service.CasbinService } // New 创建路由 @@ -71,6 +73,8 @@ func New( callHandler *handler.CallHandler, reportHandler *handler.ReportHandler, adminReportHandler *handler.AdminReportHandler, + verificationHandler *handler.VerificationHandler, + adminVerificationHandler *handler.AdminVerificationHandler, logService *service.LogService, activityService service.UserActivityService, casbinService service.CasbinService, @@ -82,36 +86,38 @@ func New( userHandler.SetActivityService(activityService) r := &Router{ - engine: gin.Default(), - userHandler: userHandler, - postHandler: postHandler, - commentHandler: commentHandler, - messageHandler: messageHandler, - notificationHandler: notificationHandler, - uploadHandler: uploadHandler, - pushHandler: pushHandler, - systemMessageHandler: systemMessageHandler, - groupHandler: groupHandler, - stickerHandler: stickerHandler, - voteHandler: voteHandler, - channelHandler: channelHandler, - scheduleHandler: scheduleHandler, - roleHandler: roleHandler, - adminUserHandler: adminUserHandler, - adminPostHandler: adminPostHandler, - adminCommentHandler: adminCommentHandler, - adminGroupHandler: adminGroupHandler, - adminDashboardHandler: adminDashboardHandler, - adminLogHandler: adminLogHandler, - qrcodeHandler: qrcodeHandler, - materialHandler: materialHandler, - callHandler: callHandler, - reportHandler: reportHandler, - adminReportHandler: adminReportHandler, - logService: logService, - jwtService: jwtService, - casbinService: casbinService, - wsHandler: wsHandler, + engine: gin.Default(), + userHandler: userHandler, + postHandler: postHandler, + commentHandler: commentHandler, + messageHandler: messageHandler, + notificationHandler: notificationHandler, + uploadHandler: uploadHandler, + pushHandler: pushHandler, + systemMessageHandler: systemMessageHandler, + groupHandler: groupHandler, + stickerHandler: stickerHandler, + voteHandler: voteHandler, + channelHandler: channelHandler, + scheduleHandler: scheduleHandler, + roleHandler: roleHandler, + adminUserHandler: adminUserHandler, + adminPostHandler: adminPostHandler, + adminCommentHandler: adminCommentHandler, + adminGroupHandler: adminGroupHandler, + adminDashboardHandler: adminDashboardHandler, + adminLogHandler: adminLogHandler, + qrcodeHandler: qrcodeHandler, + materialHandler: materialHandler, + callHandler: callHandler, + reportHandler: reportHandler, + adminReportHandler: adminReportHandler, + verificationHandler: verificationHandler, + adminVerificationHandler: adminVerificationHandler, + logService: logService, + jwtService: jwtService, + casbinService: casbinService, + wsHandler: wsHandler, } r.setupRoutes() @@ -201,6 +207,17 @@ func (r *Router) setupRoutes() { users.GET("/:id/favorites", middleware.OptionalAuth(r.jwtService), r.postHandler.GetFavorites) } + // 身份认证路由 + if r.verificationHandler != nil { + verification := v1.Group("/verification") + verification.Use(authMiddleware) + { + verification.GET("/status", r.verificationHandler.GetVerificationStatus) + verification.POST("/submit", r.verificationHandler.SubmitVerification) + verification.GET("/records", r.verificationHandler.GetVerificationRecords) + } + } + // 二维码登录(公开) if r.qrcodeHandler != nil { qrcode := v1.Group("/auth/qrcode") @@ -580,6 +597,13 @@ func (r *Router) setupRoutes() { admin.PUT("/reports/:id/handle", r.adminReportHandler.Handle) admin.POST("/reports/batch-handle", r.adminReportHandler.BatchHandle) } + + // 身份认证管理 + if r.adminVerificationHandler != nil { + admin.GET("/verifications", r.adminVerificationHandler.ListVerifications) + admin.GET("/verifications/:id", r.adminVerificationHandler.GetVerificationDetail) + admin.PUT("/verifications/:id/review", r.adminVerificationHandler.ReviewVerification) + } } } } diff --git a/internal/service/verification_service.go b/internal/service/verification_service.go new file mode 100644 index 0000000..b5dddb4 --- /dev/null +++ b/internal/service/verification_service.go @@ -0,0 +1,176 @@ +package service + +import ( + "context" + "fmt" + "time" + + apperrors "carrot_bbs/internal/errors" + "carrot_bbs/internal/model" + "carrot_bbs/internal/repository" +) + +type VerificationService interface { + SubmitVerification(ctx context.Context, userID string, identity model.UserIdentity, realName, studentID, employeeID, department, proofMaterials string) (*model.VerificationRecord, error) + GetVerificationStatus(ctx context.Context, userID string) (*VerificationStatusResponse, error) + GetVerificationRecords(ctx context.Context, userID string, page, pageSize int) ([]*model.VerificationRecord, int64, error) +} + +type VerificationStatusResponse struct { + UserID string `json:"user_id"` + Identity string `json:"identity"` + VerificationStatus string `json:"verification_status"` + HasPendingRequest bool `json:"has_pending_request"` + LatestRecord *model.VerificationRecord `json:"latest_record,omitempty"` +} + +type verificationServiceImpl struct { + verificationRepo repository.VerificationRepository + userRepo repository.UserRepository +} + +func NewVerificationService( + verificationRepo repository.VerificationRepository, + userRepo repository.UserRepository, +) VerificationService { + return &verificationServiceImpl{ + verificationRepo: verificationRepo, + userRepo: userRepo, + } +} + +func (s *verificationServiceImpl) SubmitVerification(ctx context.Context, userID string, identity model.UserIdentity, realName, studentID, employeeID, department, proofMaterials string) (*model.VerificationRecord, error) { + user, err := s.userRepo.GetByID(userID) + if err != nil { + return nil, apperrors.ErrUserNotFound + } + + if user.VerificationStatus == model.VerificationStatusApproved { + return nil, apperrors.ErrAlreadyVerified + } + + existing, err := s.verificationRepo.GetPendingByUserID(userID) + if err == nil && existing != nil { + return nil, apperrors.ErrVerificationPending + } + + record := &model.VerificationRecord{ + UserID: userID, + Identity: identity, + Status: model.VerificationStatusPending, + RealName: realName, + StudentID: studentID, + EmployeeID: employeeID, + Department: department, + ProofMaterials: proofMaterials, + } + + if err := s.verificationRepo.Create(record); err != nil { + return nil, fmt.Errorf("创建认证记录失败: %w", err) + } + + user.VerificationStatus = model.VerificationStatusPending + user.Identity = identity + if err := s.userRepo.Update(user); err != nil { + return nil, fmt.Errorf("更新用户状态失败: %w", err) + } + + return record, nil +} + +func (s *verificationServiceImpl) GetVerificationStatus(ctx context.Context, userID string) (*VerificationStatusResponse, error) { + user, err := s.userRepo.GetByID(userID) + if err != nil { + return nil, apperrors.ErrUserNotFound + } + + response := &VerificationStatusResponse{ + UserID: userID, + Identity: string(user.Identity), + VerificationStatus: string(user.VerificationStatus), + HasPendingRequest: false, + } + + latestRecord, err := s.verificationRepo.GetLatestByUserID(userID) + if err == nil && latestRecord != nil { + response.LatestRecord = latestRecord + response.HasPendingRequest = latestRecord.Status == model.VerificationStatusPending + } + + return response, nil +} + +func (s *verificationServiceImpl) GetVerificationRecords(ctx context.Context, userID string, page, pageSize int) ([]*model.VerificationRecord, int64, error) { + return s.verificationRepo.GetRecordsByUserID(userID, page, pageSize) +} + +type AdminVerificationService interface { + ListVerifications(ctx context.Context, page, pageSize int, status, keyword string) ([]*model.VerificationRecord, int64, error) + GetVerificationDetail(ctx context.Context, id string) (*model.VerificationRecord, error) + ReviewVerification(ctx context.Context, reviewerID string, recordID string, approve bool, rejectReason string) (*model.VerificationRecord, error) +} + +type adminVerificationServiceImpl struct { + verificationRepo repository.VerificationRepository + userRepo repository.UserRepository +} + +func NewAdminVerificationService( + verificationRepo repository.VerificationRepository, + userRepo repository.UserRepository, +) AdminVerificationService { + return &adminVerificationServiceImpl{ + verificationRepo: verificationRepo, + userRepo: userRepo, + } +} + +func (s *adminVerificationServiceImpl) ListVerifications(ctx context.Context, page, pageSize int, status, keyword string) ([]*model.VerificationRecord, int64, error) { + return s.verificationRepo.List(page, pageSize, status, keyword) +} + +func (s *adminVerificationServiceImpl) GetVerificationDetail(ctx context.Context, id string) (*model.VerificationRecord, error) { + return s.verificationRepo.GetByID(id) +} + +func (s *adminVerificationServiceImpl) ReviewVerification(ctx context.Context, reviewerID string, recordID string, approve bool, rejectReason string) (*model.VerificationRecord, error) { + record, err := s.verificationRepo.GetByID(recordID) + if err != nil { + return nil, apperrors.ErrVerificationNotFound + } + + if record.Status != model.VerificationStatusPending { + return nil, apperrors.ErrVerificationAlreadyHandled + } + + if approve { + record.Status = model.VerificationStatusApproved + } else { + record.Status = model.VerificationStatusRejected + record.RejectReason = rejectReason + } + + now := time.Now() + record.ReviewedBy = &reviewerID + record.ReviewedAt = &now + + if err := s.verificationRepo.Update(record); err != nil { + return nil, fmt.Errorf("更新认证记录失败: %w", err) + } + + user, err := s.userRepo.GetByID(record.UserID) + if err != nil { + return record, nil + } + + if approve { + user.VerificationStatus = model.VerificationStatusApproved + user.Identity = record.Identity + } else { + user.VerificationStatus = model.VerificationStatusRejected + } + + _ = s.userRepo.Update(user) + + return record, nil +} diff --git a/internal/wire/handler.go b/internal/wire/handler.go index b3a2ce9..5cbaca2 100644 --- a/internal/wire/handler.go +++ b/internal/wire/handler.go @@ -3,6 +3,7 @@ package wire import ( "carrot_bbs/internal/handler" "carrot_bbs/internal/pkg/ws" + "carrot_bbs/internal/repository" "carrot_bbs/internal/service" "github.com/google/wire" @@ -30,6 +31,7 @@ var HandlerSet = wire.NewSet( handler.NewCallHandler, handler.NewReportHandler, handler.NewAdminReportHandler, + handler.NewVerificationHandler, // 需要特殊处理的 Handler handler.NewUserHandler, @@ -39,6 +41,7 @@ var HandlerSet = wire.NewSet( ProvideGroupHandler, ProvideScheduleHandler, ProvideWSHandler, + ProvideAdminVerificationHandler, ) // ProvideMessageHandler 提供消息处理器 @@ -85,3 +88,11 @@ func ProvideWSHandler( ) *handler.WSHandler { return handler.NewWSHandler(wsHub, chatService, groupService, jwtService, callService) } + +// ProvideAdminVerificationHandler 提供管理端身份认证处理器 +func ProvideAdminVerificationHandler( + adminVerificationService service.AdminVerificationService, + userRepo repository.UserRepository, +) *handler.AdminVerificationHandler { + return handler.NewAdminVerificationHandler(adminVerificationService, userRepo) +} diff --git a/internal/wire/repository.go b/internal/wire/repository.go index e5b7abd..18b4cb5 100644 --- a/internal/wire/repository.go +++ b/internal/wire/repository.go @@ -34,6 +34,9 @@ var RepositorySet = wire.NewSet( repository.NewOperationLogRepository, repository.NewLoginLogRepository, repository.NewDataChangeLogRepository, + + // 身份认证相关仓储 + repository.NewVerificationRepository, ) // ProvideUserActivityRepository 提供用户活跃数据仓储 diff --git a/internal/wire/service.go b/internal/wire/service.go index c4dda22..438ec72 100644 --- a/internal/wire/service.go +++ b/internal/wire/service.go @@ -58,6 +58,8 @@ var ServiceSet = wire.NewSet( ProvideCallService, ProvideReportService, ProvideAdminReportService, + ProvideVerificationService, + ProvideAdminVerificationService, // 日志服务 ProvideAsyncLogManager, @@ -427,3 +429,19 @@ func ProvideAdminReportService( ) service.AdminReportService { return service.NewAdminReportService(reportRepo, postRepo, commentRepo, messageRepo, userRepo, notifyRepo, pushService, txManager, logService) } + +// ProvideVerificationService 提供身份认证服务 +func ProvideVerificationService( + verificationRepo repository.VerificationRepository, + userRepo repository.UserRepository, +) service.VerificationService { + return service.NewVerificationService(verificationRepo, userRepo) +} + +// ProvideAdminVerificationService 提供管理端身份认证服务 +func ProvideAdminVerificationService( + verificationRepo repository.VerificationRepository, + userRepo repository.UserRepository, +) service.AdminVerificationService { + return service.NewAdminVerificationService(verificationRepo, userRepo) +}