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 后异步发信。
367 lines
11 KiB
Go
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)
|
|
}
|