From 63b002d074ff6f6c66b8ed7cde84518618639327 Mon Sep 17 00:00:00 2001 From: becomer Date: Sun, 5 Jul 2026 15:23:42 +0800 Subject: [PATCH] feat: announcement system upgrade + user management - Add Source/TargetUserID fields to announcements, support individual push - Add status (active/disabled) and DisabledReason to SysUser - Add admin user management API (list, detail, enable/disable) - Add login disabled check with reason prompt - Add system-generated invitation notifications - Allow invite reward amount configurable via system settings - Frontend: AdminUsers page, update AdminAnnouncements with target user support --- .gitignore | 7 +- backed/cmd/server/main.go | 5 + backed/internal/handler/admin_announcement.go | 96 +++- backed/internal/handler/admin_user.go | 215 ++++++++ backed/internal/handler/auth.go | 171 +++++- backed/internal/handler/settings.go | 10 +- backed/internal/handler/user_announcement.go | 34 +- backed/internal/model/announcement.go | 10 +- backed/internal/model/sys_user.go | 5 + backed/internal/service/cpdaily/crypto.go | 72 +-- backed/internal/service/cpdaily/login_cas.go | 1 + backed/internal/service/cpdaily/login_iap.go | 46 +- backed/internal/service/cpdaily/service.go | 7 +- frondend/src/api/admin.ts | 68 +++ frondend/src/router/index.ts | 6 + .../src/views/admin/AdminAnnouncements.vue | 121 ++++- frondend/src/views/admin/AdminLayout.vue | 2 + frondend/src/views/admin/AdminSettings.vue | 77 ++- frondend/src/views/admin/AdminUsers.vue | 503 ++++++++++++++++++ frondend/src/views/profile/ProfileView.vue | 2 +- 20 files changed, 1311 insertions(+), 147 deletions(-) create mode 100644 backed/internal/handler/admin_user.go create mode 100644 frondend/src/views/admin/AdminUsers.vue diff --git a/.gitignore b/.gitignore index d351e0c..90562db 100644 --- a/.gitignore +++ b/.gitignore @@ -2,12 +2,17 @@ backed/server backed/cpdaily-new backed/cpdaily-server +backed/cmd/server/cpdaily-new backed/*.log backed/*.har +# Frontend build (rebuilt by Docker multi-stage) +backed/dist/ +frondend/dist/ + # Node frondend/node_modules/ -frondend/dist/ +frondend/*.tsbuildinfo # IDE .idea/ diff --git a/backed/cmd/server/main.go b/backed/cmd/server/main.go index f5d3fa3..8706b89 100644 --- a/backed/cmd/server/main.go +++ b/backed/cmd/server/main.go @@ -96,6 +96,11 @@ func main() { adminAuth.Get("/all-agents", handler.ListAllAgents) adminAuth.Get("/agent/list", handler.ListAllAgents) + // 管理后台 - 用户管理(仅admin) + adminAuth.Get("/users", middleware.AdminOnly(), handler.ListUsers) + adminAuth.Get("/users/:id", middleware.AdminOnly(), handler.GetUserDetail) + adminAuth.Put("/users/:id/status", middleware.AdminOnly(), handler.UpdateUserStatus) + // 管理后台 - 公告 adminAuth.Post("/announcements", handler.CreateAnnouncement) adminAuth.Get("/announcements", handler.ListAnnouncements) diff --git a/backed/internal/handler/admin_announcement.go b/backed/internal/handler/admin_announcement.go index 7138076..7357539 100644 --- a/backed/internal/handler/admin_announcement.go +++ b/backed/internal/handler/admin_announcement.go @@ -14,11 +14,12 @@ import ( // CreateAnnouncementRequest 创建公告请求 type CreateAnnouncementRequest struct { - Title string `json:"title"` - Content string `json:"content"` - TargetType string `json:"targetType"` // agent / user - ForcePopup bool `json:"forcePopup"` - LoopPopup bool `json:"loopPopup"` + Title string `json:"title"` + Content string `json:"content"` + TargetType string `json:"targetType"` // agent / user + TargetUserID *uint `json:"targetUserId,omitempty"` // 指定单个用户(null=推送全部) + ForcePopup bool `json:"forcePopup"` + LoopPopup bool `json:"loopPopup"` } // CreateAnnouncement 创建公告 @@ -39,13 +40,30 @@ func CreateAnnouncement(c *fiber.Ctx) error { return utils.Fail(c, 400, "目标类型必须为 agent 或 user") } + // 如果指定了用户,验证用户存在 + if req.TargetUserID != nil { + if req.TargetType == "user" { + var user model.SysUser + if err := database.DB.First(&user, *req.TargetUserID).Error; err != nil { + return utils.Fail(c, 404, "指定的用户不存在") + } + } else { + var agent model.AgentUser + if err := database.DB.First(&agent, *req.TargetUserID).Error; err != nil { + return utils.Fail(c, 404, "指定的代理不存在") + } + } + } + announcement := model.Announcement{ - Title: req.Title, - Content: req.Content, - TargetType: req.TargetType, - ForcePopup: req.ForcePopup, - LoopPopup: req.LoopPopup, - CreatedBy: adminID, + Title: req.Title, + Content: req.Content, + Source: "manual", + TargetType: req.TargetType, + TargetUserID: req.TargetUserID, + ForcePopup: req.ForcePopup, + LoopPopup: req.LoopPopup, + CreatedBy: adminID, } if err := database.DB.Create(&announcement).Error; err != nil { @@ -53,16 +71,17 @@ func CreateAnnouncement(c *fiber.Ctx) error { return utils.FailServer(c, "创建公告失败") } - slog.Info("创建公告", "id", announcement.ID, "title", req.Title, "targetType", req.TargetType) + slog.Info("创建公告", "id", announcement.ID, "title", req.Title, "targetType", req.TargetType, "targetUserId", req.TargetUserID) return utils.Success(c, announcement) } -// AnnouncementListItem 公告列表项(含统计) +// AnnouncementListItem 公告列表项(含统计和来源信息) type AnnouncementListItem struct { model.Announcement - ReadCount int `json:"readCount"` - UnreadCount int `json:"unreadCount"` - TotalTargets int64 `json:"totalTargets"` + ReadCount int `json:"readCount"` + UnreadCount int `json:"unreadCount"` + TotalTargets int64 `json:"totalTargets"` + TargetUserName string `json:"targetUserName,omitempty"` // 指定用户时的用户名 } // ListAnnouncements 公告列表(含已读/未读统计) @@ -84,17 +103,37 @@ func ListAnnouncements(c *fiber.Ctx) error { Count(&readCount) var totalTargets int64 - if a.TargetType == "agent" { + if a.TargetUserID != nil { + // 指定单个用户 + totalTargets = 1 + } else if a.TargetType == "agent" { totalTargets = totalAgents } else { totalTargets = totalUsers } + // 查询指定用户的用户名 + targetUserName := "" + if a.TargetUserID != nil { + if a.TargetType == "user" { + var u model.SysUser + if err := database.DB.Select("username").First(&u, *a.TargetUserID).Error; err == nil { + targetUserName = u.Username + } + } else { + var ag model.AgentUser + if err := database.DB.Select("username").First(&ag, *a.TargetUserID).Error; err == nil { + targetUserName = ag.Username + } + } + } + items = append(items, AnnouncementListItem{ - Announcement: a, - ReadCount: int(readCount), - UnreadCount: int(max(0, totalTargets-readCount)), - TotalTargets: totalTargets, + Announcement: a, + ReadCount: int(readCount), + UnreadCount: int(max(0, totalTargets-readCount)), + TotalTargets: totalTargets, + TargetUserName: targetUserName, }) } @@ -154,13 +193,22 @@ func GetAnnouncement(c *fiber.Ctx) error { func DeleteAnnouncement(c *fiber.Ctx) error { id := c.Params("id") + // 只能删除手动创建的公告 + var announcement model.Announcement + if err := database.DB.First(&announcement, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return utils.Fail(c, 404, "公告不存在") + } + return utils.FailServer(c, "查询失败") + } + if announcement.Source == "system" { + return utils.Fail(c, 400, "系统自动生成的公告不能手动删除") + } + result := database.DB.Delete(&model.Announcement{}, id) if result.Error != nil { return utils.FailServer(c, "删除失败") } - if result.RowsAffected == 0 { - return utils.Fail(c, 404, "公告不存在") - } // 同时删除已读记录 database.DB.Where("announcement_id = ?", id).Delete(&model.AnnouncementRead{}) diff --git a/backed/internal/handler/admin_user.go b/backed/internal/handler/admin_user.go new file mode 100644 index 0000000..73ddc5e --- /dev/null +++ b/backed/internal/handler/admin_user.go @@ -0,0 +1,215 @@ +package handler + +import ( + "cpdaily-new/internal/database" + "cpdaily-new/internal/model" + "cpdaily-new/internal/utils" + "log/slog" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" +) + +// SysUserListItem 用户列表项 +type SysUserListItem struct { + ID uint `json:"id"` + Username string `json:"username"` + SchoolID string `json:"schoolId"` + SchoolName string `json:"schoolName"` + StudentID string `json:"studentId"` + RealName string `json:"realName"` + SignCount int `json:"signCount"` + InviteReward float64 `json:"inviteReward"` + InvitedCount int `json:"invitedCount"` + TaskCount int64 `json:"taskCount"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + LastLoginAt string `json:"lastLoginAt"` +} + +// ListUsers 获取用户列表(仅admin) +func ListUsers(c *fiber.Ctx) error { + // 分页 + page, _ := strconv.Atoi(c.Query("page", "1")) + pageSize, _ := strconv.Atoi(c.Query("pageSize", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + offset := (page - 1) * pageSize + + // 搜索 + keyword := strings.TrimSpace(c.Query("keyword", "")) + + var total int64 + query := database.DB.Model(&model.SysUser{}) + if keyword != "" { + like := "%" + keyword + "%" + query = query.Where("username LIKE ? OR school_name LIKE ? OR student_id LIKE ? OR real_name LIKE ?", + like, like, like, like) + } + query.Count(&total) + + // 查询用户列表 + var users []model.SysUser + dataQuery := database.DB + if keyword != "" { + like := "%" + keyword + "%" + dataQuery = dataQuery.Where("username LIKE ? OR school_name LIKE ? OR student_id LIKE ? OR real_name LIKE ?", + like, like, like, like) + } + dataQuery.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&users) + + // 统计每个用户的任务数 + type TaskCount struct { + UserID uint + Count int64 + } + var taskCounts []TaskCount + if len(users) > 0 { + userIDs := make([]uint, len(users)) + for i, u := range users { + userIDs[i] = u.ID + } + database.DB.Model(&model.CpTask{}). + Select("user_id, COUNT(*) as count"). + Where("user_id IN ?", userIDs). + Group("user_id"). + Scan(&taskCounts) + } + taskCountMap := make(map[uint]int64) + for _, tc := range taskCounts { + taskCountMap[tc.UserID] = tc.Count + } + + items := make([]SysUserListItem, 0, len(users)) + for _, u := range users { + lastLogin := "" + if u.LastLoginAt != nil { + lastLogin = u.LastLoginAt.Format("2006-01-02 15:04:05") + } + items = append(items, SysUserListItem{ + ID: u.ID, + Username: u.Username, + SchoolID: u.SchoolID, + SchoolName: u.SchoolName, + StudentID: u.StudentID, + RealName: u.RealName, + SignCount: u.SignCount, + InviteReward: u.InviteReward, + InvitedCount: u.InvitedCount, + TaskCount: taskCountMap[u.ID], + Status: u.Status, + CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"), + LastLoginAt: lastLogin, + }) + } + + return utils.Success(c, fiber.Map{ + "total": total, + "items": items, + "page": page, + "pageSize": pageSize, + }) +} + +// GetUserDetail 获取用户详情(仅admin) +func GetUserDetail(c *fiber.Ctx) error { + userID := c.Params("id") + + var user model.SysUser + if err := database.DB.First(&user, userID).Error; err != nil { + return utils.Fail(c, 404, "用户不存在") + } + + // 统计任务数 + var taskCount int64 + database.DB.Model(&model.CpTask{}).Where("user_id = ?", user.ID).Count(&taskCount) + + // 统计被邀请人(通过邀请码查找) + var invitees []model.SysUser + database.DB.Where("invite_code = ?", user.InviteCode).Find(&invitees) // 查找使用该用户邀请码注册的人,不对,应该查找谁是被这个用户邀请的 + // 实际上我们无法直接知道谁是被这个用户邀请的,因为没有记录"被谁邀请"的字段 + // 可以通过统计该用户的 invited_count 来知道邀请人数 + + lastLogin := "" + if user.LastLoginAt != nil { + lastLogin = user.LastLoginAt.Format("2006-01-02 15:04:05") + } + + // 解密密码 + password := "[已加密]" + if user.RawPassword != "" { + decrypted, err := decryptRawPassword(user.RawPassword) + if err == nil && decrypted != "" { + password = decrypted + } + } + + return utils.Success(c, fiber.Map{ + "id": user.ID, + "username": user.Username, + "schoolId": user.SchoolID, + "schoolName": user.SchoolName, + "studentId": user.StudentID, + "realName": user.RealName, + "password": password, + "signCount": user.SignCount, + "inviteReward": user.InviteReward, + "invitedCount": user.InvitedCount, + "status": user.Status, + "disabledReason": user.DisabledReason, + "taskCount": taskCount, + "mobileCode": user.MobileCode, + "mobileType": user.MobileType, + "createdAt": user.CreatedAt.Format("2006-01-02 15:04:05"), + "lastLoginAt": lastLogin, + }) +} + +// UpdateUserStatusRequest 更新用户状态请求 +type UpdateUserStatusRequest struct { + Status string `json:"status"` // active / disabled + DisabledReason string `json:"disabledReason"` // 禁用原因 +} + +// UpdateUserStatus 启用/禁用用户(仅admin) +func UpdateUserStatus(c *fiber.Ctx) error { + userID := c.Params("id") + + var req UpdateUserStatusRequest + if err := c.BodyParser(&req); err != nil { + return utils.FailParam(c, "请求参数错误") + } + if req.Status != "active" && req.Status != "disabled" { + return utils.Fail(c, 400, "状态值无效,必须为 active 或 disabled") + } + + var user model.SysUser + if err := database.DB.First(&user, userID).Error; err != nil { + return utils.Fail(c, 404, "用户不存在") + } + + updates := map[string]interface{}{ + "status": req.Status, + } + if req.Status == "disabled" { + updates["disabled_reason"] = req.DisabledReason + } else { + updates["disabled_reason"] = "" + } + + if err := database.DB.Model(&user).Updates(updates).Error; err != nil { + slog.Error("更新用户状态失败", "error", err) + return utils.FailServer(c, "操作失败") + } + + slog.Info("更新用户状态成功", "userId", user.ID, "status", req.Status) + return utils.Success(c, fiber.Map{ + "id": user.ID, + "status": req.Status, + }) +} diff --git a/backed/internal/handler/auth.go b/backed/internal/handler/auth.go index c75987b..65a71cd 100644 --- a/backed/internal/handler/auth.go +++ b/backed/internal/handler/auth.go @@ -8,6 +8,7 @@ import ( "cpdaily-new/internal/utils" "errors" "log/slog" + "strconv" "strings" "time" @@ -122,6 +123,19 @@ func Login(c *fiber.Ctx) error { return utils.FailServer(c, "用户处理失败") } + // 检查用户是否被管理员禁用 + if user.Status == "disabled" { + reason := user.DisabledReason + if reason == "" { + reason = "您的账号已被管理员禁用,如有疑问请联系客服" + } + slog.Warn("被禁用的用户尝试登录", "userId", user.ID, "username", user.Username) + return c.Status(403).JSON(utils.Response{ + Code: 403, + Message: reason, + }) + } + // 更新 cookie 和最后登录时间 now := time.Now() database.DB.Model(&user).Updates(map[string]interface{}{ @@ -242,8 +256,67 @@ func buildUsername(school *cpd.SchoolDetailInfo, studentID string) string { return abbr + studentID } +// getInviterRewardAmount 获取邀请人每次邀请获得的奖励积分(从系统设置读取,默认 0.5) +func getInviterRewardAmount() float64 { + var cfg model.SystemConfig + result := database.DB.Where("`key` = ?", "inviter_reward_per_invite").First(&cfg) + if result.Error == nil { + if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v >= 0 { + return v + } + } + return 0.5 +} + +// getInviteeRewardAmount 获取被邀请人每次被邀请获得的奖励积分(从系统设置读取,默认 0.5) +func getInviteeRewardAmount() float64 { + var cfg model.SystemConfig + result := database.DB.Where("`key` = ?", "invitee_reward_per_invite").First(&cfg) + if result.Error == nil { + if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v >= 0 { + return v + } + } + return 0.5 +} + +// createSystemAnnouncement 创建系统自动生成的公告(强制弹窗) +func createSystemAnnouncement(userID uint, userType string, title, content string) { + announcement := model.Announcement{ + Title: title, + Content: content, + Source: "system", + TargetType: userType, + TargetUserID: &userID, + ForcePopup: true, + LoopPopup: false, + CreatedBy: 0, // 系统创建 + } + if err := database.DB.Create(&announcement).Error; err != nil { + slog.Error("创建系统公告失败", "error", err, "userId", userID) + } +} + +// createInviteNotifications 创建邀请相关的系统通知(双方各一条强制弹窗公告) +func createInviteNotifications(inviterID, inviteeID uint, inviterReward, inviteeReward float64) { + // 给邀请人发送通知 + if inviterReward > 0 { + createSystemAnnouncement(inviterID, "user", + "邀请奖励到账", + "恭喜!您邀请的好友已成功注册,获得 "+strconv.FormatFloat(inviterReward, 'f', 1, 64)+" 奖励积分。\n\n积分满 1 即可在个人中心兑换 1 次签到次数。", + ) + } + // 给被邀请人发送通知 + if inviteeReward > 0 { + createSystemAnnouncement(inviteeID, "user", + "新人注册奖励", + "欢迎注册!您通过邀请链接注册,获得 "+strconv.FormatFloat(inviteeReward, 'f', 1, 64)+" 奖励积分。\n\n积分满 1 即可在个人中心兑换 1 次签到次数。", + ) + } +} + // 查找或创建用户 -func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, inviteCode string) (*model.SysUser, error) { +func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, referralCode string) (*model.SysUser, error) { var user model.SysUser err := database.DB.Where("username = ?", username).First(&user).Error if err == nil { @@ -254,39 +327,91 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki return nil, err } - // 自动注册 + // 自动注册:生成密码哈希和邀请码 hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, err } - user = model.SysUser{ - Username: username, - Password: string(hashedPwd), - SchoolID: schoolID, - SchoolName: schoolName, - StudentID: studentID, - Cookie: cookie, - } + // 为新用户生成一个唯一的邀请码(避免空字符串与 uniqueIndex 冲突) + myInviteCode := generateRandomCode(6) - if err := database.DB.Create(&user).Error; err != nil { + // 读取可配置的奖励积分 + inviterReward := getInviterRewardAmount() + inviteeReward := getInviteeRewardAmount() + + // 记录邀请人ID,在事务外创建通知 + var inviterID uint + hasValidReferral := false + + // 使用事务包裹:用户创建 + 奖励发放,保证原子性 + err = database.DB.Transaction(func(tx *gorm.DB) error { + user = model.SysUser{ + Username: username, + Password: string(hashedPwd), + SchoolID: schoolID, + SchoolName: schoolName, + StudentID: studentID, + Cookie: cookie, + InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突 + } + if err := tx.Create(&user).Error; err != nil { + return err + } + + // 处理邀请码奖励 + if referralCode != "" { + var inviter model.SysUser + if err := tx.Where("invite_code = ?", referralCode).First(&inviter).Error; err != nil { + // 邀请码无效,仅记录日志,不中断注册流程 + slog.Warn("邀请码无效,跳过奖励", "referralCode", referralCode, "newUserId", user.ID) + return nil + } + + // 自邀请防护 + if inviter.ID == user.ID { + slog.Warn("检测到自邀请,跳过奖励", "userId", user.ID) + return nil + } + + // 给邀请人发放奖励积分 + 增加邀请计数 + if inviterReward > 0 { + tx.Model(&inviter).Updates(map[string]interface{}{ + "invite_reward": gorm.Expr("invite_reward + ?", inviterReward), + "invited_count": gorm.Expr("invited_count + 1"), + }) + } + + // 给被邀请人(新用户)发放奖励积分 + if inviteeReward > 0 { + tx.Model(&user).Update("invite_reward", gorm.Expr("invite_reward + ?", inviteeReward)) + } + + slog.Info("邀请奖励已发放", + "inviterId", inviter.ID, + "newUserId", user.ID, + "inviterReward", inviterReward, + "inviteeReward", inviteeReward, + ) + + inviterID = inviter.ID + hasValidReferral = true + } + + return nil + }) + + if err != nil { + slog.Error("用户创建失败(可能用户名冲突)", "username", username, "error", err) return nil, err } - // 处理邀请码奖励 - if inviteCode != "" { - var inviter model.SysUser - if err := database.DB.Where("invite_code = ?", inviteCode).First(&inviter).Error; err == nil { - // 给邀请人 +0.5 奖励 + 增加邀请计数 - database.DB.Model(&inviter).Updates(map[string]interface{}{ - "invite_reward": gorm.Expr("invite_reward + 0.5"), - "invited_count": gorm.Expr("invited_count + 1"), - }) - slog.Info("邀请奖励已发放", "inviterId", inviter.ID, "newUserId", user.ID) - } + // 邀请成功后,在事务外创建系统通知(双方各一条强制弹窗公告) + if hasValidReferral { + createInviteNotifications(inviterID, user.ID, inviterReward, inviteeReward) } - slog.Info("新用户自动注册", "username", username, "school", schoolName, "inviteCode", inviteCode) + slog.Info("新用户自动注册", "username", username, "school", schoolName, "referralCode", referralCode, "myInviteCode", myInviteCode) return &user, nil } diff --git a/backed/internal/handler/settings.go b/backed/internal/handler/settings.go index 2c21293..43f928a 100644 --- a/backed/internal/handler/settings.go +++ b/backed/internal/handler/settings.go @@ -24,6 +24,12 @@ func GetSettings(c *fiber.Ctx) error { if _, ok := settings["base_deduction_per_run"]; !ok { settings["base_deduction_per_run"] = "1.00" } + if _, ok := settings["inviter_reward_per_invite"]; !ok { + settings["inviter_reward_per_invite"] = "0.50" + } + if _, ok := settings["invitee_reward_per_invite"]; !ok { + settings["invitee_reward_per_invite"] = "0.50" + } return utils.Success(c, settings) } @@ -46,7 +52,9 @@ func UpdateSetting(c *fiber.Ctx) error { // 支持的 key 白名单 allowedKeys := map[string]bool{ - "base_deduction_per_run": true, + "base_deduction_per_run": true, + "inviter_reward_per_invite": true, + "invitee_reward_per_invite": true, } if !allowedKeys[req.Key] { return utils.Fail(c, 400, "不支持的设置项: "+req.Key) diff --git a/backed/internal/handler/user_announcement.go b/backed/internal/handler/user_announcement.go index 06e2f91..27d8aa0 100644 --- a/backed/internal/handler/user_announcement.go +++ b/backed/internal/handler/user_announcement.go @@ -29,6 +29,7 @@ type UserAnnouncementItem struct { } // ListUserAnnouncements 获取当前用户的公告列表(含已读状态) +// 包含:target_type 匹配且全员推送的 + 指定给当前用户的 func ListUserAnnouncements(c *fiber.Ctx) error { userID, userType, err := getCurrentUser(c) if err != nil { @@ -36,9 +37,10 @@ func ListUserAnnouncements(c *fiber.Ctx) error { } var announcements []model.Announcement - database.DB.Where("target_type = ?", userType). - Order("created_at DESC"). - Find(&announcements) + database.DB.Where( + "(target_type = ? AND target_user_id IS NULL) OR target_user_id = ?", + userType, userID, + ).Order("created_at DESC").Find(&announcements) // 查询该用户的已读记录 readIDs := make(map[uint]time.Time) @@ -73,7 +75,7 @@ func GetUnreadCount(c *fiber.Ctx) error { var totalCount int64 database.DB.Model(&model.Announcement{}). - Where("target_type = ?", userType). + Where("(target_type = ? AND target_user_id IS NULL) OR target_user_id = ?", userType, userID). Count(&totalCount) var readCount int64 @@ -105,8 +107,8 @@ type PendingPopupItem struct { // MyAnnouncementInfo 我的公告信息聚合 type MyAnnouncementInfo struct { - UnreadCount int `json:"unreadCount"` - PopupQueue []PendingPopupItem `json:"popupQueue"` // 需要弹窗的公告(依次弹出) + UnreadCount int `json:"unreadCount"` + PopupQueue []PendingPopupItem `json:"popupQueue"` } // GetMyAnnouncementInfo 获取当前用户的公告聚合信息(未读数 + 待弹窗队列) @@ -116,10 +118,10 @@ func GetMyAnnouncementInfo(c *fiber.Ctx) error { return utils.FailAuth(c, "未认证") } - // 1. 总公告数 + // 1. 总公告数(包含指定给当前用户的) var totalCount int64 database.DB.Model(&model.Announcement{}). - Where("target_type = ?", userType). + Where("(target_type = ? AND target_user_id IS NULL) OR target_user_id = ?", userType, userID). Count(&totalCount) // 2. 已读数 @@ -135,9 +137,10 @@ func GetMyAnnouncementInfo(c *fiber.Ctx) error { // 3. 待弹窗队列:forcePopup=true 且 (loopPopup=true 或 未读) var forceAnnouncements []model.Announcement - database.DB.Where("target_type = ? AND force_popup = 1", userType). - Order("created_at ASC"). - Find(&forceAnnouncements) + database.DB.Where( + "force_popup = 1 AND ((target_type = ? AND target_user_id IS NULL) OR target_user_id = ?)", + userType, userID, + ).Order("created_at ASC").Find(&forceAnnouncements) // 查已读记录 readIDs := make(map[uint]bool) @@ -176,14 +179,17 @@ func MarkAnnouncementRead(c *fiber.Ctx) error { announcementID := c.Params("id") - // 验证公告存在 + // 验证公告存在且用户有权访问 var announcement model.Announcement if err := database.DB.First(&announcement, announcementID).Error; err != nil { return utils.Fail(c, 404, "公告不存在") } - // 验证公告目标类型匹配 - if announcement.TargetType != userType { + // 验证公告目标类型匹配或指定给当前用户 + if announcement.TargetType != userType && (announcement.TargetUserID == nil || *announcement.TargetUserID != userID) { + return utils.Fail(c, 403, "无权操作此公告") + } + if announcement.TargetUserID != nil && *announcement.TargetUserID != userID { return utils.Fail(c, 403, "无权操作此公告") } diff --git a/backed/internal/model/announcement.go b/backed/internal/model/announcement.go index c3128ad..d3eb06e 100644 --- a/backed/internal/model/announcement.go +++ b/backed/internal/model/announcement.go @@ -7,10 +7,16 @@ type Announcement struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` Title string `gorm:"type:varchar(255);not null" json:"title"` Content string `gorm:"type:text;not null" json:"content"` - TargetType string `gorm:"type:varchar(16);not null;index" json:"targetType"` // agent / user + Source string `gorm:"type:varchar(16);not null;default:manual;index" json:"source"` // manual(管理员创建) / system(系统自动) + TargetType string `gorm:"type:varchar(16);not null;index" json:"targetType"` // agent / user + TargetUserID *uint `gorm:"index" json:"targetUserId"` // 指定单个用户(null=推送全部) ForcePopup bool `gorm:"default:false" json:"forcePopup"` LoopPopup bool `gorm:"default:false" json:"loopPopup"` - CreatedBy uint `gorm:"not null" json:"createdBy"` // admin_id + CreatedBy uint `gorm:"not null" json:"createdBy"` // admin_id (system公告为0) CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } + +func (Announcement) TableName() string { + return "announcements" +} diff --git a/backed/internal/model/sys_user.go b/backed/internal/model/sys_user.go index 050fba0..1e9f3db 100644 --- a/backed/internal/model/sys_user.go +++ b/backed/internal/model/sys_user.go @@ -44,6 +44,11 @@ type SysUser struct { // 累计邀请人数 InvitedCount int `gorm:"type:int;default:0" json:"invitedCount"` + // 用户状态 active / disabled + Status string `gorm:"type:varchar(16);not null;default:active;index" json:"status"` + // 禁用原因(Status=disabled 时显示给用户) + DisabledReason string `gorm:"type:varchar(500);default:''" json:"disabledReason"` + // 最后登录时间 LastLoginAt *time.Time `gorm:"type:datetime" json:"lastLoginAt"` diff --git a/backed/internal/service/cpdaily/crypto.go b/backed/internal/service/cpdaily/crypto.go index c3fc633..dbf7aff 100644 --- a/backed/internal/service/cpdaily/crypto.go +++ b/backed/internal/service/cpdaily/crypto.go @@ -6,11 +6,12 @@ import ( "crypto/cipher" "crypto/des" "crypto/rand" + "crypto/rsa" + "crypto/x509" "encoding/base64" "encoding/hex" "fmt" "math/big" - "strings" ) // ========== AES-128-CBC-PKCS7 ========== @@ -104,61 +105,26 @@ func NewRSAKeyPairFromPublicKey(base64Key string) (*RSAKeyPair, error) { if err != nil { return nil, fmt.Errorf("base64 decode error: %w", err) } - // 简单的 DER 解析 (PKCS#1 Public Key) - // 跳过 ASN.1 头部,提取 n 和 e - return parsePKCS1PublicKey(der) -} -func parsePKCS1PublicKey(der []byte) (*RSAKeyPair, error) { - // 简化的 PKCS#1 解析 - // 实际上应该用 crypto/x509,但为了轻量级,手动解析 - // 格式: SEQUENCE { SEQUENCE { OID, NULL }, BIT STRING { SEQUENCE { INTEGER n, INTEGER e } } } - // 或者直接 PKCS#1: SEQUENCE { INTEGER n, INTEGER e } - - // 找最后的两个 INTEGER - data := string(der) - nParts := strings.Split(data, "\x02") - if len(nParts) < 3 { - return nil, fmt.Errorf("invalid RSA key format") - } - - // 提取 n (倒数第二个 INTEGER) - nBytes := extractIntegerBytes(nParts[len(nParts)-2]) - // 提取 e (最后一个 INTEGER) - eBytes := extractIntegerBytes(nParts[len(nParts)-1]) - - r := &RSAKeyPair{ - n: new(big.Int).SetBytes(nBytes), - e: new(big.Int).SetBytes(eBytes), - } - return r, nil -} - -func extractIntegerBytes(s string) []byte { - if len(s) == 0 { - return nil - } - // 第一个字节是长度 - data := []byte(s) - if len(data) < 2 { - return data - } - length := int(data[0]) - if length > 128 { - // 长格式 - numLenBytes := length & 0x7F - length = 0 - for i := 1; i <= numLenBytes; i++ { - length = length*256 + int(data[i]) + // 尝试 PKCS#1 格式 (SEQUENCE { INTEGER n, INTEGER e }) + pub, err := x509.ParsePKCS1PublicKey(der) + if err != nil { + // 尝试 PKIX/SubjectPublicKeyInfo 格式 + keyAny, err2 := x509.ParsePKIXPublicKey(der) + if err2 != nil { + return nil, fmt.Errorf("parse DER failed (pkcs1: %w, pkix: %w)", err, err2) } - data = data[1+numLenBytes:] - } else { - data = data[1:] + rsaPub, ok := keyAny.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("not an RSA public key") + } + pub = rsaPub } - if length > len(data) { - length = len(data) - } - return data[:length] + + return &RSAKeyPair{ + n: pub.N, + e: big.NewInt(int64(pub.E)), + }, nil } func (r *RSAKeyPair) Encrypt(plaintext string) (string, error) { diff --git a/backed/internal/service/cpdaily/login_cas.go b/backed/internal/service/cpdaily/login_cas.go index 8692f8d..bdc03c3 100644 --- a/backed/internal/service/cpdaily/login_cas.go +++ b/backed/internal/service/cpdaily/login_cas.go @@ -26,6 +26,7 @@ type sessionClient struct { func newSessionClient() *sessionClient { jar := newCookieJar() client := resty.New(). + SetDoNotParseResponse(true). SetTimeout(30*time.Second). SetTLSClientConfig(&tls.Config{ InsecureSkipVerify: true, diff --git a/backed/internal/service/cpdaily/login_iap.go b/backed/internal/service/cpdaily/login_iap.go index 3ee4771..42df23b 100644 --- a/backed/internal/service/cpdaily/login_iap.go +++ b/backed/internal/service/cpdaily/login_iap.go @@ -22,6 +22,7 @@ import ( func iapLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) { sc := newSessionClient() loginUrl := getIapValidLoginUrl(school) + slog.Info("IAP登录", "school", school.Name, "loginUrl", loginUrl, "ampUrl", school.AmpUrl, "wecloudUrl", school.WecloudUrl, "joinType", school.JoinType) // 1. 获取 LT (Login Ticket) lt, err := getIapLT(ctx, loginUrl, sc) @@ -34,6 +35,7 @@ func iapLogin(ctx context.Context, school *SchoolDetailInfo, username, password if err != nil { return nil, fmt.Errorf("获取真实LT失败: %w", err) } + slog.Info("IAP登录凭证", "lt", lt, "realLT", realLT) // 3. RSA 加密密码 rsa, err := NewRSAKeyPairFromPublicKey(IapRsaPubKey) @@ -62,27 +64,53 @@ func iapLogin(ctx context.Context, school *SchoolDetailInfo, username, password "password": "{rsa}" + encPwd, } - submitUrls := []string{ - loginUrl + "/iap/doLogin", - loginUrl + "/doLogin", - } - var loginResp *resty.Response var lastErr error - for _, submitUrl := range submitUrls { + + // 使用 FlexibleRedirectPolicy 让 resty 跟随重定向 + // 最终会到达 portal/index.html (200), 但过程中 cookies 会被自动保存 + sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20)) + + submitUrl := loginUrl + "/iap/doLogin" + loginResp, lastErr = sc.R(). + SetFormData(submitData). + SetContext(ctx). + Post(submitUrl) + statusCode := 0 + if loginResp != nil { + statusCode = loginResp.StatusCode() + } + slog.Info("IAP登录第一尝试", "url", submitUrl, "lastErr", lastErr, "status", statusCode) + + if lastErr != nil || statusCode == 404 { + // 如果 /iap/doLogin 失败,尝试 /doLogin + submitUrl = loginUrl + "/doLogin" loginResp, lastErr = sc.R(). SetFormData(submitData). SetContext(ctx). Post(submitUrl) - if lastErr == nil { - break + statusCode = 0 + if loginResp != nil { + statusCode = loginResp.StatusCode() } + slog.Info("IAP登录第二尝试", "url", submitUrl, "lastErr", lastErr, "status", statusCode) } if lastErr != nil { return nil, fmt.Errorf("提交IAP登录失败: %w", lastErr) } defer loginResp.RawBody().Close() + slog.Info("IAP登录响应", "status", loginResp.StatusCode(), "url", loginResp.RawResponse.Request.URL.String()) + + // 如果重定向被跟随到了最终页面(200),但 cookies 已设置,视为成功 + if loginResp.StatusCode() == 200 && strings.Contains(loginResp.RawResponse.Request.URL.String(), "index.html") { + slog.Info("IAP登录成功(跟随重定向到首页)") + activateSession(ctx, school.WecloudUrl, sc.Client) + return sc.jar.AllCookies(), nil + } + + // 6. 处理响应 + // 6. 处理响应 switch loginResp.StatusCode() { case 302: @@ -200,6 +228,7 @@ func handleIapLoginSuccess(ctx context.Context, resp *resty.Response, school *Sc if err != nil { return nil, fmt.Errorf("获取IAP重定向地址失败: %w", err) } + slog.Info("IAP重定向", "jumpUrl", jumpUrl.String()) sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20)) verifyResp, err := sc.R().SetContext(ctx).Get(jumpUrl.String()) @@ -207,6 +236,7 @@ func handleIapLoginSuccess(ctx context.Context, resp *resty.Response, school *Sc return nil, fmt.Errorf("IAP跟随重定向失败: %w", err) } defer verifyResp.RawBody().Close() + slog.Info("IAP重定向验证", "status", verifyResp.StatusCode(), "finalUrl", verifyResp.RawResponse.Request.URL.String()) activateSession(ctx, school.WecloudUrl, sc.Client) return sc.jar.AllCookies(), nil diff --git a/backed/internal/service/cpdaily/service.go b/backed/internal/service/cpdaily/service.go index 6ed1c2b..6617b61 100644 --- a/backed/internal/service/cpdaily/service.go +++ b/backed/internal/service/cpdaily/service.go @@ -143,10 +143,15 @@ func GetSchoolDetail(tenantID string) (*SchoolDetailInfo, error) { return &detail, nil } +// 硬编码走 IAP 登录的学校 ID 列表(尽管 joinType 不是 cloud,但实际支持 IAP) +var iapSchoolIds = map[string]bool{ + "01e270d1-00e2-4ce9-b0af-5d5fbd462b8a": true, // 河北石油职业技术大学 +} + // CpdailyLogin 执行 cpdaily 登录 // 返回登录成功后的所有 cookies func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) { - if school.IsIap() { + if school.IsIap() || iapSchoolIds[school.ID] { return iapLogin(ctx, school, username, password) } return casLogin(ctx, school, username, password) diff --git a/frondend/src/api/admin.ts b/frondend/src/api/admin.ts index c6c9869..2288458 100644 --- a/frondend/src/api/admin.ts +++ b/frondend/src/api/admin.ts @@ -189,7 +189,9 @@ export interface Announcement { id: number title: string content: string + source: string targetType: 'agent' | 'user' + targetUserId: number | null forcePopup: boolean loopPopup: boolean createdBy: number @@ -201,12 +203,14 @@ export interface AnnouncementListItem extends Announcement { readCount: number unreadCount: number totalTargets: number + targetUserName: string } export interface CreateAnnouncementParams { title: string content: string targetType: 'agent' | 'user' + targetUserId?: number forcePopup: boolean loopPopup: boolean } @@ -241,3 +245,67 @@ export function getAnnouncementDetail(id: number) { export function deleteAnnouncement(id: number) { return api.delete(`/admin/announcements/${id}`) } + +// ======== 用户管理(admin only) ======== + +export interface UserListItem { + id: number + username: string + schoolId: string + schoolName: string + studentId: string + realName: string + signCount: number + inviteReward: number + invitedCount: number + taskCount: number + status: string + createdAt: string + lastLoginAt: string +} + +export interface UserListResult { + total: number + items: UserListItem[] + page: number + pageSize: number +} + +export interface UserDetail { + id: number + username: string + schoolId: string + schoolName: string + studentId: string + realName: string + password: string + signCount: number + inviteReward: number + invitedCount: number + status: string + disabledReason: string + taskCount: number + mobileCode: string + mobileType: string + createdAt: string + lastLoginAt: string +} + +/** 获取用户列表 */ +export function getUsers(page = 1, pageSize = 20, keyword = '') { + const params = new URLSearchParams() + params.set('page', String(page)) + params.set('pageSize', String(pageSize)) + if (keyword) params.set('keyword', keyword) + return api.get<{ data: UserListResult }>(`/admin/users?${params.toString()}`) +} + +/** 获取用户详情 */ +export function getUserDetail(id: number) { + return api.get<{ data: UserDetail }>(`/admin/users/${id}`) +} + +/** 更新用户状态 */ +export function updateUserStatus(id: number, status: 'active' | 'disabled', disabledReason = '') { + return api.put(`/admin/users/${id}/status`, { status, disabledReason }) +} diff --git a/frondend/src/router/index.ts b/frondend/src/router/index.ts index c5a9ca8..a9214ad 100644 --- a/frondend/src/router/index.ts +++ b/frondend/src/router/index.ts @@ -20,6 +20,7 @@ import AdminAnnouncements from '@/views/admin/AdminAnnouncements.vue' import AdminTicketList from '@/views/admin/AdminTicketList.vue' import AdminTicketDetail from '@/views/admin/AdminTicketDetail.vue' import AdminPendingSchools from '@/views/admin/AdminPendingSchools.vue' +import AdminUsers from '@/views/admin/AdminUsers.vue' const router = createRouter({ history: createWebHistory(), @@ -61,6 +62,11 @@ const router = createRouter({ name: 'admin-cdks', component: AdminCdks, }, + { + path: 'users', + name: 'admin-users', + component: AdminUsers, + }, { path: 'settings', name: 'admin-settings', diff --git a/frondend/src/views/admin/AdminAnnouncements.vue b/frondend/src/views/admin/AdminAnnouncements.vue index d6e6fec..6d28cc7 100644 --- a/frondend/src/views/admin/AdminAnnouncements.vue +++ b/frondend/src/views/admin/AdminAnnouncements.vue @@ -1,14 +1,22 @@ + + + +
+ + 邀请奖励设置 +
+
+ +
+
diff --git a/frondend/src/views/admin/AdminUsers.vue b/frondend/src/views/admin/AdminUsers.vue new file mode 100644 index 0000000..b13310c --- /dev/null +++ b/frondend/src/views/admin/AdminUsers.vue @@ -0,0 +1,503 @@ + + +