Files
yunzerwebsiteallinone/go/controllers/backend_oa_schedule.go
T
2026-09-09 17:02:29 +08:00

1049 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controllers
import (
"encoding/json"
"fmt"
"io"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"server/models"
"server/pkg/jwtutil"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// BackendOaScheduleController OA日程管理(日历待办)。
// 日程数据按 JWT 中的租户与用户双重隔离,仅创建者本人可见与操作。
type BackendOaScheduleController struct {
beego.Controller
}
func (c *BackendOaScheduleController) oaScheduleClaims() (*jwtutil.Claims, error) {
auth := c.Ctx.Request.Header.Get("Authorization")
if auth == "" {
return nil, orm.ErrNoRows
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return nil, orm.ErrNoRows
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
return nil, err
}
if claims.UserType != "backend" {
return nil, orm.ErrNoRows
}
return claims, nil
}
func (c *BackendOaScheduleController) oasJsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *BackendOaScheduleController) oasOk(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
// Prepare 所有日程接口执行前补齐顺延所需字段,避免历史库缺列导致查询报错。
func (c *BackendOaScheduleController) Prepare() {
_ = models.EnsureOaScheduleCarryColumns()
}
var (
oasDatePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
oasTimePattern = regexp.MustCompile(`^\d{2}:\d{2}$`)
)
// isValidOaDate 校验 "YYYY-MM-DD" 日期字符串(含真实日历合法性,如 2 月 30 日)。
func isValidOaDate(s string) bool {
if !oasDatePattern.MatchString(s) {
return false
}
_, err := time.ParseInLocation("2006-01-02", s, time.Local)
return err == nil
}
// isValidOaTime 校验 "HH:mm" 时间字符串;HH:mm 定长数字串的字典序即时间序。
func isValidOaTime(s string) bool {
if !oasTimePattern.MatchString(s) {
return false
}
_, err := time.ParseInLocation("15:04", s, time.Local)
return err == nil
}
// normalizeOaDate 将 ORM 读出的日期值统一为 "YYYY-MM-DD"。
// DSN 开启 parseTime=True 后 DATE 列会被驱动解析为 time.Time,
// 赋给 string 字段后是 Go 默认格式("2026-08-29 00:00:00 +0800 CST"),取前 10 位。
func normalizeOaDate(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 10 {
return s[:10]
}
return s
}
// oaScheduleFilter 日程列表筛选条件,用于构造 ORM 查询条件。
// 指针式的 Has*/On* 标记用于区分"未传参"与"传了默认值 0/空串"。
type oaScheduleFilter struct {
Keyword string
Priority int8
HasPriority bool
StartDate string
EndDate string
OnDate string
Status int8
HasStatus bool
Ongoing bool
}
// oaScheduleCond 构造日程查询条件,始终按租户 + 用户双重隔离。
// Ongoing 表示"进行中":跨天顺延中的任务,即被顺延出去的源日程(carry_over=1)
// 或由前序日程顺延而来的续做日程(source_id>0)。
func oaScheduleCond(tid int, userID int, f oaScheduleFilter) *orm.Condition {
cond := orm.NewCondition()
cond = cond.And("is_deleted", 0).And("tid", tid).And("user_id", userID)
if f.Keyword != "" {
cond = cond.And("title__icontains", f.Keyword)
}
if f.HasPriority {
cond = cond.And("priority", f.Priority)
}
if f.StartDate != "" {
cond = cond.And("schedule_date__gte", f.StartDate)
}
if f.EndDate != "" {
cond = cond.And("schedule_date__lte", f.EndDate)
}
if f.OnDate != "" {
cond = cond.And("schedule_date", f.OnDate)
}
if f.HasStatus {
cond = cond.And("status", f.Status)
}
if f.Ongoing {
sub := orm.NewCondition()
sub = sub.Or("source_id__gt", 0).Or("carry_over", 1)
cond = cond.AndCond(sub)
}
return cond
}
func oaScheduleQS(tid int, userID int, f oaScheduleFilter) orm.QuerySeter {
return models.Orm.QueryTable(new(models.OaSchedule)).
SetCond(oaScheduleCond(tid, userID, f))
}
// List GET /backend/oa/schedule/list
// 分页查询当前用户的日程,支持 keyword/status/priority/start_date/end_date 筛选,
// 以及 category 待办分类:all-全部 today-今日 pending-未完成 ongoing-进行中 done-已完成。
// 返回值额外带 counts(各分类数量),供页面 tabs 展示角标。
// 日历视图传 start_date/end_date 且 pageSize 足够大时一次拉取区间全量数据。
func (c *BackendOaScheduleController) List() {
claims, err := c.oaScheduleClaims()
if err != nil {
c.oasJsonErr(401, 401, "未登录或无权限")
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
keyword := strings.TrimSpace(c.GetString("keyword"))
status := strings.TrimSpace(c.GetString("status"))
priority := strings.TrimSpace(c.GetString("priority"))
startDate := strings.TrimSpace(c.GetString("start_date"))
endDate := strings.TrimSpace(c.GetString("end_date"))
category := strings.TrimSpace(c.GetString("category"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 500 {
pageSize = 20
}
base := oaScheduleFilter{Keyword: keyword}
if status == "0" || status == "1" {
statusVal, _ := strconv.Atoi(status)
base.HasStatus = true
base.Status = int8(statusVal)
}
if priority == "0" || priority == "1" || priority == "2" {
priorityVal, _ := strconv.Atoi(priority)
base.HasPriority = true
base.Priority = int8(priorityVal)
}
// 分类角标按全量统计(只受关键词/优先级影响),不随日历翻月或区间筛选跳动
countOf := func(f oaScheduleFilter) int64 {
n, _ := oaScheduleQS(claims.TenantId, claims.UserID, f).Count()
return n
}
today := time.Now().Format("2006-01-02")
counts := map[string]interface{}{
"all": countOf(base),
"today": countOf(oaScheduleFilter{Keyword: base.Keyword, Priority: base.Priority, HasPriority: base.HasPriority, OnDate: today}),
"pending": countOf(oaScheduleFilter{Keyword: base.Keyword, Priority: base.Priority, HasPriority: base.HasPriority, HasStatus: true, Status: 0}),
"ongoing": countOf(oaScheduleFilter{Keyword: base.Keyword, Priority: base.Priority, HasPriority: base.HasPriority, Ongoing: true}),
"done": countOf(oaScheduleFilter{Keyword: base.Keyword, Priority: base.Priority, HasPriority: base.HasPriority, HasStatus: true, Status: 1}),
}
// 列表数据 = 基础筛选 + 日期区间 + 当前分类
listFilter := base
if isValidOaDate(startDate) {
listFilter.StartDate = startDate
}
if isValidOaDate(endDate) {
listFilter.EndDate = endDate
}
switch category {
case "today":
listFilter.OnDate = today
case "pending":
listFilter.HasStatus = true
listFilter.Status = 0
case "ongoing":
listFilter.Ongoing = true
case "done":
listFilter.HasStatus = true
listFilter.Status = 1
}
qs := oaScheduleQS(claims.TenantId, claims.UserID, listFilter)
total, _ := qs.Count()
var list []models.OaSchedule
_, err = qs.OrderBy("schedule_date", "start_time", "-id").
Limit(pageSize).Offset((page - 1) * pageSize).
All(&list)
if err != nil && err != orm.ErrNoRows {
c.oasJsonErr(500, 500, "查询失败")
return
}
if list == nil {
list = []models.OaSchedule{}
}
// 统一日期格式,前端日历分组与日期标签均依赖 "YYYY-MM-DD"。
for i := range list {
list[i].ScheduleDate = normalizeOaDate(list[i].ScheduleDate)
}
c.oasOk(map[string]interface{}{
"list": list,
"total": total,
"counts": counts,
})
}
// Stats GET /backend/oa/schedule/stats
// 返回今日/本周/本月的日程统计与逾期数,供页面顶部统计卡片展示。
func (c *BackendOaScheduleController) Stats() {
claims, err := c.oaScheduleClaims()
if err != nil {
c.oasJsonErr(401, 401, "未登录或无权限")
return
}
now := time.Now()
today := now.Format("2006-01-02")
// 本周从周一开始
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7
}
weekStart := now.AddDate(0, 0, -(weekday - 1)).Format("2006-01-02")
weekEnd := now.AddDate(0, 0, 7-weekday).Format("2006-01-02")
monthStart := now.Format("2006-01") + "-01"
base := func() orm.QuerySeter {
return models.Orm.QueryTable(new(models.OaSchedule)).
Filter("is_deleted", 0).
Filter("tid", claims.TenantId).
Filter("user_id", claims.UserID)
}
todayTotal, _ := base().Filter("schedule_date", today).Count()
todayDone, _ := base().Filter("schedule_date", today).Filter("status", 1).Count()
weekTotal, _ := base().Filter("schedule_date__gte", weekStart).Filter("schedule_date__lte", weekEnd).Count()
monthTotal, _ := base().Filter("schedule_date__gte", monthStart).Count()
monthDone, _ := base().Filter("schedule_date__gte", monthStart).Filter("status", 1).Count()
overdue, _ := base().Filter("schedule_date__lt", today).Filter("status", 0).Count()
c.oasOk(map[string]interface{}{
"today_total": todayTotal,
"today_done": todayDone,
"week_total": weekTotal,
"month_total": monthTotal,
"month_done": monthDone,
"overdue": overdue,
})
}
type oaSchedulePayload struct {
Title string `json:"title"`
Content string `json:"content"`
ScheduleDate string `json:"schedule_date"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
AllDay int8 `json:"all_day"`
Color string `json:"color"`
Priority int8 `json:"priority"`
// 提醒绑定:开启后会在提醒工具生成一条提醒任务,并把其 ID 回写到 ReminderID。
ReminderEnabled bool `json:"reminder_enabled"`
ReminderTime string `json:"reminder_time"`
RemindChannels []string `json:"remind_channels"`
AdvanceMinutes int `json:"advance_minutes"`
}
// oaReminderChannels 允许的提醒渠道集合。
var oaReminderChannels = map[string]bool{
"SMS": true, "EMAIL": true, "BARK": true, "SITE_MSG": true,
}
// createOaScheduleReminder 为 OA 日程创建一条绑定的提醒任务。
// 复用提醒工具的数据模型(yz_backend_schedule + yz_backend_schedule_reminder):
// 生成一条 BackendSchedule,再按渠道逐条生成提醒记录,返回 BackendSchedule.ID
// 作为 OA 日程的 reminder_id。EMAIL/BARK 渠道会附带确认回执令牌与重复发送配置。
func createOaScheduleReminder(tid int, userID uint64, title, content, reminderTime string, channels []string, advanceMinutes int) (uint64, error) {
if strings.TrimSpace(reminderTime) == "" {
return 0, fmt.Errorf("提醒时间不能为空")
}
schedTime, err := time.ParseInLocation("2006-01-02 15:04:05", reminderTime, time.Local)
if err != nil {
return 0, fmt.Errorf("提醒时间格式不合法")
}
if advanceMinutes < 0 {
advanceMinutes = 0
}
title = strings.TrimSpace(title)
if title == "" {
title = "日程提醒"
}
bs := models.BackendSchedule{
Tid: tid,
Title: title,
Content: content,
ScheduleTime: schedTime,
UserID: userID,
}
schedID, err := models.Orm.Insert(&bs)
if err != nil {
return 0, fmt.Errorf("创建提醒任务失败: %v", err)
}
now := time.Now()
for _, ch := range channels {
ch = strings.ToUpper(strings.TrimSpace(ch))
if !oaReminderChannels[ch] {
continue
}
firstSend := schedTime.Add(-time.Duration(advanceMinutes) * time.Minute)
reminder := models.BackendScheduleReminder{
Tid: tid,
ScheduleID: uint64(schedID),
RemindChannel: ch,
AdvanceMinutes: advanceMinutes,
NextRemindTime: firstSend,
ReceiverUserID: userID,
RemindStatus: 0,
CreateTime: now,
UpdateTime: now,
}
if ch == "EMAIL" || ch == "BARK" {
token := backendGenerateToken()
reminder.AckToken = &token
reminder.RepeatIntervalMinutes = 10
reminder.MaxSendCount = 5
} else {
reminder.RepeatIntervalMinutes = 0
reminder.MaxSendCount = 1
}
if _, err := models.Orm.Insert(&reminder); err != nil {
return 0, fmt.Errorf("创建提醒失败: %v", err)
}
}
return uint64(schedID), nil
}
// deleteBoundReminder 软删除 OA 日程绑定的提醒任务及其提醒记录。
// 删除日程或关闭提醒时使用,避免提醒工具里残留孤立任务。
func deleteBoundReminder(scheduleID uint64) {
if scheduleID == 0 {
return
}
_, _ = models.Orm.QueryTable(new(models.BackendSchedule)).
Filter("id", scheduleID).Delete()
_, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)).
Filter("schedule_id", scheduleID).
Update(map[string]interface{}{"IsDeleted": 1, "UpdateTime": time.Now()})
}
// oaScheduleImage 日程相关图片(截图/照片),只存文件引用,不存二进制。
type oaScheduleImage struct {
ID uint64 `json:"id"`
URL string `json:"url"`
Name string `json:"name"`
}
const (
oaScheduleMaxImages = 20
oaScheduleMaxImageURL = 500
oaScheduleMaxImageName = 120
)
// normalizeOaScheduleImages 校验并序列化日程图片列表:
// 丢弃无 url 或超长的条目,条目数超限时返回 false。
func normalizeOaScheduleImages(list []oaScheduleImage) (string, bool) {
if len(list) > oaScheduleMaxImages {
return "", false
}
out := make([]oaScheduleImage, 0, len(list))
for _, img := range list {
img.URL = strings.TrimSpace(img.URL)
if img.URL == "" || len(img.URL) > oaScheduleMaxImageURL {
continue
}
img.Name = strings.TrimSpace(img.Name)
if utf8.RuneCountInString(img.Name) > oaScheduleMaxImageName {
img.Name = string([]rune(img.Name)[:oaScheduleMaxImageName])
}
out = append(out, img)
}
raw, err := json.Marshal(out)
if err != nil {
return "", false
}
return string(raw), true
}
// parseOaScheduleImages 解析 images 列;脏数据返回空数组,不阻断详情展示。
func parseOaScheduleImages(raw string) []oaScheduleImage {
list := make([]oaScheduleImage, 0)
_ = json.Unmarshal([]byte(raw), &list)
return list
}
// parseOaSchedulePayload 读取并校验新建/编辑日程的请求体;校验失败时直接输出错误响应。
func (c *BackendOaScheduleController) parseOaSchedulePayload() (oaSchedulePayload, bool) {
var payload oaSchedulePayload
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil || json.Unmarshal(raw, &payload) != nil {
c.oasJsonErr(400, 400, "参数错误")
return payload, false
}
payload.Title = strings.TrimSpace(payload.Title)
if payload.Title == "" {
c.oasJsonErr(400, 400, "请输入日程标题")
return payload, false
}
if utf8.RuneCountInString(payload.Title) > 80 {
c.oasJsonErr(400, 400, "日程标题不能超过80字")
return payload, false
}
if !isValidOaDate(payload.ScheduleDate) {
c.oasJsonErr(400, 400, "日程日期无效")
return payload, false
}
if payload.AllDay != 0 {
payload.AllDay = 1
payload.StartTime = ""
payload.EndTime = ""
} else {
if !isValidOaTime(payload.StartTime) {
c.oasJsonErr(400, 400, "开始时间无效")
return payload, false
}
if payload.EndTime != "" {
if !isValidOaTime(payload.EndTime) {
c.oasJsonErr(400, 400, "结束时间无效")
return payload, false
}
if payload.EndTime < payload.StartTime {
c.oasJsonErr(400, 400, "结束时间不能早于开始时间")
return payload, false
}
}
}
if payload.Priority < 0 || payload.Priority > 2 {
payload.Priority = 0
}
payload.Color = strings.TrimSpace(payload.Color)
if payload.Color == "" {
payload.Color = "#409EFF"
}
// 提醒绑定校验:开启提醒时必须选择渠道且提醒时间合法。
if payload.ReminderEnabled {
validChannels := make([]string, 0, len(payload.RemindChannels))
for _, ch := range payload.RemindChannels {
ch = strings.ToUpper(strings.TrimSpace(ch))
if oaReminderChannels[ch] {
validChannels = append(validChannels, ch)
}
}
payload.RemindChannels = validChannels
if len(validChannels) == 0 {
c.oasJsonErr(400, 400, "请至少选择一个提醒渠道")
return payload, false
}
if _, err := time.ParseInLocation("2006-01-02 15:04:05", payload.ReminderTime, time.Local); err != nil {
c.oasJsonErr(400, 400, "提醒时间格式不合法,支持 YYYY-MM-DD HH:mm:ss")
return payload, false
}
if payload.AdvanceMinutes < 0 {
payload.AdvanceMinutes = 0
}
}
return payload, true
}
// Create POST /backend/oa/schedule/create
func (c *BackendOaScheduleController) Create() {
claims, err := c.oaScheduleClaims()
if err != nil {
c.oasJsonErr(401, 401, "未登录或无权限")
return
}
payload, ok := c.parseOaSchedulePayload()
if !ok {
return
}
now := time.Now()
item := &models.OaSchedule{
Tid: claims.TenantId,
UserID: uint64(claims.UserID),
UserName: claims.Username,
Title: payload.Title,
Content: payload.Content,
ScheduleDate: payload.ScheduleDate,
StartTime: payload.StartTime,
EndTime: payload.EndTime,
AllDay: payload.AllDay,
Color: payload.Color,
Priority: payload.Priority,
Status: 0,
IsDeleted: 0,
CreateTime: now,
UpdateTime: &now,
}
if _, err := models.Orm.Insert(item); err != nil {
c.oasJsonErr(500, 500, "保存失败")
return
}
// 开启提醒:生成绑定的提醒任务,并把其 ID 回写到日程。
if payload.ReminderEnabled {
rid, rerr := createOaScheduleReminder(
claims.TenantId,
uint64(claims.UserID),
payload.Title,
payload.Content,
payload.ReminderTime,
payload.RemindChannels,
payload.AdvanceMinutes,
)
if rerr != nil {
// 回滚已创建的日程,避免产生孤立日程
_, _ = models.Orm.QueryTable(new(models.OaSchedule)).Filter("id", item.ID).Delete()
c.oasJsonErr(500, 500, rerr.Error())
return
}
item.ReminderID = rid
if _, err := models.Orm.Update(item, "ReminderID"); err != nil {
c.oasJsonErr(500, 500, "保存失败")
return
}
}
c.oasOk(item)
}
// Update POST /backend/oa/schedule/update/:id
func (c *BackendOaScheduleController) Update() {
claims, err := c.oaScheduleClaims()
if err != nil {
c.oasJsonErr(401, 401, "未登录或无权限")
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.oasJsonErr(400, 400, "无效ID")
return
}
payload, ok := c.parseOaSchedulePayload()
if !ok {
return
}
var item models.OaSchedule
err = models.Orm.QueryTable(new(models.OaSchedule)).
Filter("id", id).
Filter("is_deleted", 0).
Filter("tid", claims.TenantId).
Filter("user_id", claims.UserID).
One(&item)
if err != nil {
c.oasJsonErr(404, 404, "日程不存在")
return
}
now := time.Now()
item.Title = payload.Title
item.Content = payload.Content
item.ScheduleDate = payload.ScheduleDate
item.StartTime = payload.StartTime
item.EndTime = payload.EndTime
item.AllDay = payload.AllDay
item.Color = payload.Color
item.Priority = payload.Priority
item.UpdateTime = &now
// 重新绑定提醒:先创建新提醒,成功后再删除旧提醒(关闭提醒则清掉绑定)。
// 顺序反过来可避免"新提醒创建失败却删掉了原提醒"导致提醒丢失。
if payload.ReminderEnabled {
rid, rerr := createOaScheduleReminder(
claims.TenantId,
uint64(claims.UserID),
payload.Title,
payload.Content,
payload.ReminderTime,
payload.RemindChannels,
payload.AdvanceMinutes,
)
if rerr != nil {
c.oasJsonErr(500, 500, rerr.Error())
return
}
if item.ReminderID > 0 {
deleteBoundReminder(item.ReminderID)
}
item.ReminderID = rid
} else if item.ReminderID > 0 {
deleteBoundReminder(item.ReminderID)
item.ReminderID = 0
}
if _, err := models.Orm.Update(&item,
"Title", "Content", "ScheduleDate", "StartTime", "EndTime",
"AllDay", "Color", "Priority", "ReminderID", "UpdateTime"); err != nil {
c.oasJsonErr(500, 500, "保存失败")
return
}
c.oasOk(item)
}
// SetImages POST /backend/oa/schedule/images/:id
// 单独维护日程的相关图片(文件已先上传到文件管理,这里只存引用)。
// 与 Update 分开,避免编辑标题/时间等基础信息时把图片清空。
func (c *BackendOaScheduleController) SetImages() {
claims, err := c.oaScheduleClaims()
if err != nil {
c.oasJsonErr(401, 401, "未登录或无权限")
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.oasJsonErr(400, 400, "无效ID")
return
}
var payload struct {
Images []oaScheduleImage `json:"images"`
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if len(raw) > 0 && json.Unmarshal(raw, &payload) != nil {
c.oasJsonErr(400, 400, "参数错误")
return
}
imagesJSON, ok := normalizeOaScheduleImages(payload.Images)
if !ok {
c.oasJsonErr(400, 400, "图片数量过多")
return
}
var item models.OaSchedule
err = models.Orm.QueryTable(new(models.OaSchedule)).
Filter("id", id).
Filter("is_deleted", 0).
Filter("tid", claims.TenantId).
Filter("user_id", claims.UserID).
One(&item)
if err != nil {
c.oasJsonErr(404, 404, "日程不存在")
return
}
now := time.Now()
item.Images = imagesJSON
item.UpdateTime = &now
if _, err := models.Orm.Update(&item, "Images", "UpdateTime"); err != nil {
c.oasJsonErr(500, 500, "保存失败")
return
}
c.oasOk(map[string]interface{}{"images": parseOaScheduleImages(imagesJSON)})
}
// UpdateContent POST /backend/oa/schedule/content/:id 仅更新备注内容。
// 与 SetImages 类似,只改写 Content 字段,不影响标题、日期、提醒等其他字段,
// 因此即便日程已完成、已绑定提醒,补充备注也不会破坏既有状态。
func (c *BackendOaScheduleController) UpdateContent() {
claims, err := c.oaScheduleClaims()
if err != nil {
c.oasJsonErr(401, 401, "未登录或无权限")
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.oasJsonErr(400, 400, "无效ID")
return
}
var payload struct {
Content string `json:"content"`
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if len(raw) > 0 && json.Unmarshal(raw, &payload) != nil {
c.oasJsonErr(400, 400, "参数错误")
return
}
var item models.OaSchedule
err = models.Orm.QueryTable(new(models.OaSchedule)).
Filter("id", id).
Filter("is_deleted", 0).
Filter("tid", claims.TenantId).
Filter("user_id", claims.UserID).
One(&item)
if err != nil {
c.oasJsonErr(404, 404, "日程不存在")
return
}
now := time.Now()
item.Content = payload.Content
item.UpdateTime = &now
if _, err := models.Orm.Update(&item, "Content", "UpdateTime"); err != nil {
c.oasJsonErr(500, 500, "保存失败")
return
}
c.oasOk(map[string]interface{}{"content": item.Content})
}
// Delete DELETE /backend/oa/schedule/delete/:id 软删除日程。
func (c *BackendOaScheduleController) Delete() {
claims, err := c.oaScheduleClaims()
if err != nil {
c.oasJsonErr(401, 401, "未登录或无权限")
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.oasJsonErr(400, 400, "无效ID")
return
}
now := time.Now()
var item models.OaSchedule
err = models.Orm.QueryTable(new(models.OaSchedule)).
Filter("id", id).
Filter("is_deleted", 0).
Filter("tid", claims.TenantId).
Filter("user_id", claims.UserID).
One(&item)
if err != nil {
c.oasJsonErr(404, 404, "日程不存在或已删除")
return
}
// 先删除绑定的提醒任务,避免提醒工具里残留孤立任务
if item.ReminderID > 0 {
deleteBoundReminder(item.ReminderID)
}
item.IsDeleted = 1
item.DeleteTime = &now
item.UpdateTime = &now
if _, err := models.Orm.Update(&item, "IsDeleted", "DeleteTime", "UpdateTime"); err != nil {
c.oasJsonErr(500, 500, "删除失败")
return
}
c.oasOk(nil)
}
// FinishSchedule POST /backend/oa/schedule/finish/:id
// 切换完成状态:待办 -> 完成(记录完成时间);完成 -> 待办(清除完成时间)。
// 注意:方法名不能叫 Finish——Finish 是 beego Controller 的生命周期钩子,
// 同名会导致路由调用后再被框架自动调用一次,toggle 逻辑被执行两遍(状态来回切)。
func (c *BackendOaScheduleController) FinishSchedule() {
claims, err := c.oaScheduleClaims()
if err != nil {
c.oasJsonErr(401, 401, "未登录或无权限")
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.oasJsonErr(400, 400, "无效ID")
return
}
var item models.OaSchedule
err = models.Orm.QueryTable(new(models.OaSchedule)).
Filter("id", id).
Filter("is_deleted", 0).
Filter("tid", claims.TenantId).
Filter("user_id", claims.UserID).
One(&item)
if err != nil {
c.oasJsonErr(404, 404, "日程不存在")
return
}
now := time.Now()
newStatus := int8(1)
var finishTime *time.Time
if item.Status == 1 {
newStatus = 0
} else {
finishTime = &now
}
item.Status = newStatus
item.FinishTime = finishTime
item.UpdateTime = &now
if _, err := models.Orm.Update(&item, "Status", "FinishTime", "UpdateTime"); err != nil {
c.oasJsonErr(500, 500, "操作失败")
return
}
c.oasOk(map[string]interface{}{"status": newStatus})
}
// nextOaDate 返回 dateStr 之后的第 days 天,格式仍为 "YYYY-MM-DD"。
func nextOaDate(dateStr string, days int) string {
t, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)
if err != nil {
return ""
}
return t.AddDate(0, 0, days).Format("2006-01-02")
}
// oaScheduleRootID 取顺延链的源头日程ID:本身就是顺延出来的则沿用其来源,否则为自身。
func oaScheduleRootID(item *models.OaSchedule) uint64 {
if item.SourceID > 0 {
return item.SourceID
}
return item.ID
}
// carryOaSchedule 把一条待办日程顺延到目标日期:
// 1) 原日程标记完成(status=1, carry_over=1, carry_to_date=目标日期);
// 2) 在目标日期生成一条同内容的待办,source_id 指向顺延链源头。
// 返回新生成的日程与失败原因(空串表示成功)。
func carryOaSchedule(item *models.OaSchedule, targetDate string, claims *jwtutil.Claims, now time.Time) (*models.OaSchedule, string) {
if item.Status == 1 {
return nil, "该日程已完成,无需顺延"
}
if !isValidOaDate(targetDate) {
return nil, "顺延日期无效"
}
if targetDate <= normalizeOaDate(item.ScheduleDate) {
return nil, "顺延日期需晚于当前日程日期"
}
rootID := oaScheduleRootID(item)
// 同一条顺延链在目标日期只允许存在一条,避免重复顺延刷出多份待办。
exist := models.Orm.QueryTable(new(models.OaSchedule)).
Filter("is_deleted", 0).
Filter("tid", claims.TenantId).
Filter("user_id", claims.UserID).
Filter("source_id", rootID).
Filter("schedule_date", targetDate).
Exist()
if exist {
return nil, "该日期已存在顺延的日程"
}
next := &models.OaSchedule{
Tid: item.Tid,
UserID: item.UserID,
UserName: item.UserName,
Title: item.Title,
Content: item.Content,
Images: item.Images,
ScheduleDate: targetDate,
StartTime: item.StartTime,
EndTime: item.EndTime,
AllDay: item.AllDay,
Color: item.Color,
Priority: item.Priority,
Status: 0,
SourceID: rootID,
CarryCount: item.CarryCount + 1,
IsDeleted: 0,
CreateTime: now,
UpdateTime: &now,
}
if _, err := models.Orm.Insert(next); err != nil {
return nil, "生成顺延日程失败"
}
item.Status = 1
item.FinishTime = &now
item.CarryOver = 1
item.CarryToDate = targetDate
item.UpdateTime = &now
if _, err := models.Orm.Update(item,
"Status", "FinishTime", "CarryOver", "CarryToDate", "UpdateTime"); err != nil {
return nil, "标记原日程完成失败"
}
return next, ""
}
// CarrySchedule POST /backend/oa/schedule/carry/:id
// 将单条待办顺延到指定日期(默认下一天):原日程标记完成,目标日期自动生成新待办。
func (c *BackendOaScheduleController) CarrySchedule() {
claims, err := c.oaScheduleClaims()
if err != nil {
c.oasJsonErr(401, 401, "未登录或无权限")
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.oasJsonErr(400, 400, "无效ID")
return
}
// 目标日期可选,不传则默认顺延到下一天
var payload struct {
TargetDate string `json:"target_date"`
}
if raw, e := io.ReadAll(c.Ctx.Request.Body); e == nil && len(raw) > 0 {
_ = json.Unmarshal(raw, &payload)
}
payload.TargetDate = strings.TrimSpace(payload.TargetDate)
var item models.OaSchedule
err = models.Orm.QueryTable(new(models.OaSchedule)).
Filter("id", id).
Filter("is_deleted", 0).
Filter("tid", claims.TenantId).
Filter("user_id", claims.UserID).
One(&item)
if err != nil {
c.oasJsonErr(404, 404, "日程不存在")
return
}
targetDate := payload.TargetDate
if targetDate == "" {
targetDate = nextOaDate(normalizeOaDate(item.ScheduleDate), 1)
}
now := time.Now()
next, msg := carryOaSchedule(&item, targetDate, claims, now)
if msg != "" {
c.oasJsonErr(400, 400, msg)
return
}
c.oasOk(map[string]interface{}{
"new_id": next.ID,
"schedule_date": next.ScheduleDate,
"carry_count": next.CarryCount,
})
}
// CarryPending POST /backend/oa/schedule/carry-pending
// 批量顺延:把指定日期(默认今天)全部未完成的日程一次性顺延到目标日期(默认下一天)。
// 已在目标日期存在顺延记录的会被跳过,不会重复生成。
func (c *BackendOaScheduleController) CarryPending() {
claims, err := c.oaScheduleClaims()
if err != nil {
c.oasJsonErr(401, 401, "未登录或无权限")
return
}
var payload struct {
Date string `json:"date"`
TargetDate string `json:"target_date"`
}
if raw, e := io.ReadAll(c.Ctx.Request.Body); e == nil && len(raw) > 0 {
_ = json.Unmarshal(raw, &payload)
}
payload.Date = strings.TrimSpace(payload.Date)
payload.TargetDate = strings.TrimSpace(payload.TargetDate)
now := time.Now()
date := payload.Date
if !isValidOaDate(date) {
date = now.Format("2006-01-02")
}
targetDate := payload.TargetDate
if !isValidOaDate(targetDate) {
targetDate = nextOaDate(date, 1)
}
if !isValidOaDate(targetDate) {
c.oasJsonErr(400, 400, "顺延日期无效")
return
}
var list []models.OaSchedule
_, err = models.Orm.QueryTable(new(models.OaSchedule)).
Filter("is_deleted", 0).
Filter("tid", claims.TenantId).
Filter("user_id", claims.UserID).
Filter("schedule_date", date).
Filter("status", 0).
OrderBy("start_time", "id").
All(&list)
if err != nil && err != orm.ErrNoRows {
c.oasJsonErr(500, 500, "查询失败")
return
}
success, skipped := 0, 0
lastDate := targetDate
for i := range list {
if _, msg := carryOaSchedule(&list[i], targetDate, claims, now); msg != "" {
skipped++
continue
}
success++
lastDate = targetDate
}
c.oasOk(map[string]interface{}{
"success": success,
"skipped": skipped,
"schedule_date": lastDate,
})
}