Files
cpdaily_xiaozhushou/backed/internal/handler/admin_announcement.go
T
becomer 486e7ede04
ci/woodpecker/tag/build Pipeline was successful
工单与公告问题修复:回复强制弹窗、消息左右顺序、指定用户搜索、用户回复邮件通知
1. 工单新回复任意页面强制弹出:通知新增 kind 字段(reply/status),
   MobileLayout 轮询检测本设备本账号新出现的未读 reply 通知并强制弹窗
   (打卡/请假/我的等所有页面均生效);首次加载只记基线不弹历史,
   基线按用户 id 分开存储避免多账号串扰;状态变更通知不弹窗。
2. 修复管理端工单对话左右顺序:此前 user/admin 位置对调(admin 在左、
   user 在右),现 manager 侧 admin 居右、user 居左,与用户端一致。
3. 公告指定用户改为搜索选择:新增 /admin/announcements/targets,
   按用户名/学号/姓名/学校(代理按用户名)模糊查询,前端改为
   搜索候选列表点选,替代手动输入用户ID。
4. 用户回复工单也邮件通知管理员:新增 NotifyTicketUserReply,
   AddTicketMessage 后异步发信。
2026-10-16 23:15:26 +08:00

367 lines
11 KiB
Go

package handler
import (
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/utils"
"errors"
"log/slog"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// AnnouncementTargetItem 公告指定对象候选项(供管理端搜索选择指定用户/代理)
type AnnouncementTargetItem struct {
ID uint `json:"id"`
Username string `json:"username"`
RealName string `json:"realName"`
StudentID string `json:"studentId,omitempty"`
SchoolName string `json:"schoolName,omitempty"`
Role string `json:"role,omitempty"`
}
// SearchAnnouncementTargets 搜索公告指定对象:按 targetType 在普通用户或代理中
// 按用户名/学号/姓名/学校模糊查询,返回轻量候选列表供前端选择(最多 20 条)。
func SearchAnnouncementTargets(c *fiber.Ctx) error {
targetType := c.Query("targetType", "user")
keyword := strings.TrimSpace(c.Query("keyword", ""))
const limit = 20
items := make([]AnnouncementTargetItem, 0, limit)
if targetType == "agent" {
q := database.DB.Model(&model.AgentUser{})
if keyword != "" {
like := "%" + keyword + "%"
q = q.Where("username LIKE ?", like)
}
var agents []model.AgentUser
q.Order("created_at DESC").Limit(limit).Find(&agents)
for _, a := range agents {
items = append(items, AnnouncementTargetItem{
ID: a.ID,
Username: a.Username,
Role: a.Role,
})
}
return utils.Success(c, items)
}
q := database.DB.Model(&model.SysUser{})
if keyword != "" {
like := "%" + keyword + "%"
q = q.Where("username LIKE ? OR real_name LIKE ? OR student_id LIKE ? OR school_name LIKE ?",
like, like, like, like)
}
var users []model.SysUser
q.Order("created_at DESC").Limit(limit).Find(&users)
for _, u := range users {
items = append(items, AnnouncementTargetItem{
ID: u.ID,
Username: u.Username,
RealName: u.RealName,
StudentID: u.StudentID,
SchoolName: u.SchoolName,
})
}
return utils.Success(c, items)
}
// CreateAnnouncementRequest 创建公告请求
type CreateAnnouncementRequest struct {
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"`
IsPinned bool `json:"isPinned"` // 置顶公告:打卡页顶部横向滚动
}
// CreateAnnouncement 创建公告
func CreateAnnouncement(c *fiber.Ctx) error {
adminID := c.Locals("adminId").(uint)
var req CreateAnnouncementRequest
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
if req.Title == "" {
return utils.FailParam(c, "公告标题不能为空")
}
if req.Content == "" {
return utils.FailParam(c, "公告内容不能为空")
}
if req.TargetType != "agent" && req.TargetType != "user" {
return utils.Fail(c, 400, "目标类型必须为 agent 或 user")
}
// 置顶公告仅在打卡页(普通用户端)展示,仅普通用户公告可置顶
if req.IsPinned && req.TargetType != "user" {
return utils.Fail(c, 400, "仅普通用户公告可设为置顶")
}
// 如果指定了用户,验证用户存在
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,
Source: "manual",
TargetType: req.TargetType,
TargetUserID: req.TargetUserID,
ForcePopup: req.ForcePopup,
LoopPopup: req.LoopPopup,
IsPinned: req.IsPinned,
CreatedBy: adminID,
}
if err := database.DB.Create(&announcement).Error; err != nil {
slog.Error("创建公告失败", "error", err)
return utils.FailServer(c, "创建公告失败")
}
slog.Info("创建公告", "id", announcement.ID, "title", req.Title, "targetType", req.TargetType, "targetUserId", req.TargetUserID)
return utils.Success(c, announcement)
}
// UpdateAnnouncementRequest 更新公告请求
type UpdateAnnouncementRequest struct {
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"`
IsPinned bool `json:"isPinned"` // 置顶公告:打卡页顶部横向滚动
}
// UpdateAnnouncement 更新公告(仅手动创建的公告可编辑)
func UpdateAnnouncement(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, "系统自动生成的公告不能编辑")
}
var req UpdateAnnouncementRequest
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
if req.Title == "" {
return utils.FailParam(c, "公告标题不能为空")
}
if req.Content == "" {
return utils.FailParam(c, "公告内容不能为空")
}
if req.TargetType != "agent" && req.TargetType != "user" {
return utils.Fail(c, 400, "目标类型必须为 agent 或 user")
}
// 置顶公告仅在打卡页(普通用户端)展示,仅普通用户公告可置顶
if req.IsPinned && req.TargetType != "user" {
return utils.Fail(c, 400, "仅普通用户公告可设为置顶")
}
// 如果指定了用户,验证用户存在
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, "指定的代理不存在")
}
}
}
updates := map[string]interface{}{
"title": req.Title,
"content": req.Content,
"target_type": req.TargetType,
"target_user_id": req.TargetUserID,
"force_popup": req.ForcePopup,
"loop_popup": req.LoopPopup,
"is_pinned": req.IsPinned,
}
if err := database.DB.Model(&announcement).Updates(updates).Error; err != nil {
slog.Error("更新公告失败", "error", err)
return utils.FailServer(c, "更新公告失败")
}
slog.Info("更新公告", "id", announcement.ID, "title", req.Title, "targetType", req.TargetType, "targetUserId", req.TargetUserID)
return utils.Success(c, announcement)
}
// AnnouncementListItem 公告列表项(含统计和来源信息)
type AnnouncementListItem struct {
model.Announcement
ReadCount int `json:"readCount"`
UnreadCount int `json:"unreadCount"`
TotalTargets int64 `json:"totalTargets"`
TargetUserName string `json:"targetUserName,omitempty"` // 指定用户时的用户名
}
// ListAnnouncements 公告列表(含已读/未读统计;支持 ?source=manual|system 筛选;系统公告排在最后)
func ListAnnouncements(c *fiber.Ctx) error {
var announcements []model.Announcement
query := database.DB
source := c.Query("source")
if source == "manual" || source == "system" {
query = query.Where("source = ?", source)
}
// 系统公告排在最后,同来源内按创建时间倒序
query.Order("CASE WHEN source = 'system' THEN 1 ELSE 0 END ASC, created_at DESC").Find(&announcements)
// 获取目标总数
var totalAgents int64
var totalUsers int64
database.DB.Model(&model.AgentUser{}).Count(&totalAgents)
database.DB.Model(&model.SysUser{}).Count(&totalUsers)
items := make([]AnnouncementListItem, 0, len(announcements))
for _, a := range announcements {
var readCount int64
database.DB.Model(&model.AnnouncementRead{}).
Where("announcement_id = ?", a.ID).
Count(&readCount)
var totalTargets int64
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,
TargetUserName: targetUserName,
})
}
return utils.Success(c, items)
}
// GetAnnouncement 公告详情(含已读用户列表)
func GetAnnouncement(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, "查询失败")
}
// 查询已读记录(带用户信息)
type ReadUser struct {
UserID uint `json:"userId"`
Username string `json:"username"`
ReadAt time.Time `json:"readAt"`
}
var reads []model.AnnouncementRead
database.DB.Where("announcement_id = ?", announcement.ID).Find(&reads)
readUsers := make([]ReadUser, 0, len(reads))
for _, r := range reads {
username := ""
if r.UserType == "agent" {
var agent model.AgentUser
if err := database.DB.First(&agent, r.UserID).Error; err == nil {
username = agent.Username
}
} else {
var user model.SysUser
if err := database.DB.First(&user, r.UserID).Error; err == nil {
username = user.Username
}
}
readUsers = append(readUsers, ReadUser{
UserID: r.UserID,
Username: username,
ReadAt: r.ReadAt,
})
}
return utils.Success(c, fiber.Map{
"announcement": announcement,
"readUsers": readUsers,
})
}
// DeleteAnnouncement 删除公告
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, "删除失败")
}
// 同时删除已读记录
database.DB.Where("announcement_id = ?", id).Delete(&model.AnnouncementRead{})
slog.Info("删除公告", "id", id)
return utils.Success(c, nil)
}