package controllers import ( "encoding/json" "fmt" "io" "regexp" "strconv" "strings" "time" "unicode/utf8" "server/models" "server/pkg/holiday" "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() } 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, }) } // Holidays GET /backend/oa/holiday // 返回指定日期区间(或指定年份)内的中国法定节假日/调休日,供日历控件标注 // 「休 / 班」角标与节日名称。 // // 参数(二选一): // - start_date + end_date:"YYYY-MM-DD",日历按 42 格区间查询; // - year:整年查询。 // // 数据获取顺序:内存缓存 → 内置数据(2024-2026,零网络)→ 外网接口(仅未收录年份)。 // 已收录年份不会发起任何网络请求,因此日历翻月是纯内存操作、无卡顿。 // 响应里的 builtin_years 字段告知前端内置数据覆盖到哪一年。 func (c *BackendOaScheduleController) Holidays() { if _, err := c.oaScheduleClaims(); err != nil { c.oasJsonErr(401, 401, "未登录或无权限") return } startDate := strings.TrimSpace(c.GetString("start_date")) endDate := strings.TrimSpace(c.GetString("end_date")) yearStr := strings.TrimSpace(c.GetString("year")) // 参数归一化:区间优先,其次按年 var start, end string if isValidOaDate(startDate) && isValidOaDate(endDate) && startDate <= endDate { start, end = startDate, endDate } else if y, err := strconv.Atoi(yearStr); err == nil && y >= 2000 && y <= 2100 { start = fmt.Sprintf("%04d-01-01", y) end = fmt.Sprintf("%04d-12-31", y) } else { // 未传参时默认当前年份,保证前端任何情况下都能拿到可用数据 now := time.Now() start = now.Format("2006-01-01") end = now.Format("2006") + "-12-31" } list := ensureOaHolidays(start, end) c.oasOk(map[string]interface{}{ "list": list, "start_date": start, "end_date": end, "builtin_years": holiday.Years(), }) } // ensureOaHolidays 取区间内的节假日数据。 // // 顺序上的取舍(性能关键):先调 holiday.FetchYear,而不是先查数据库。 // 原因是 FetchYear 对已收录年份(2024-2026)直接返回内置数据、不发网络请求, // 也无需查库;日历每次翻月都会走到这里,先查库会白白多一次 SQL 往返。 // 只有 FetchYear 真的联网拉到了数据(即年份未收录),才回写数据库缓存, // 供后续同一年份的请求直接命中。 func ensureOaHolidays(start, end string) []holiday.Day { list := make([]holiday.Day, 0, 64) startYear, endYear := holidayYear(start), holidayYear(end) for y := startYear; y <= endYear; y++ { if y == 0 { continue } items, src, ferr := holiday.FetchYear(y) if ferr != nil || len(items) == 0 { continue } // 仅当数据来自外网时才落库:内置数据本身就在代码里,落库没有收益, // 反而会让每次翻月都产生一批无谓的写库操作。 if src == holiday.SourceHolidayCN { saveOaHolidays(items) } for _, d := range items { if d.Date >= start && d.Date <= end { list = append(list, d) } } } return mergeOaHolidays(list) } // holidayYear 从 "YYYY-MM-DD" 取年份,失败返回 0。 func holidayYear(date string) int { if len(date) < 4 { return 0 } y, err := strconv.Atoi(date[:4]) if err != nil { return 0 } return y } // saveOaHolidays 把外网拉取到的数据写入缓存表。 // 使用 insert-or-update:已存在的日期更新其名称/类型/来源,保证外网数据 // 修正后能覆盖库里的旧值。写入失败静默忽略(缓存非强依赖)。 func saveOaHolidays(list []holiday.Day) { now := time.Now() for _, d := range list { if !isValidOaDate(d.Date) { continue } var exist models.OaHoliday ferr := models.Orm.QueryTable(new(models.OaHoliday)). Filter("date", d.Date). One(&exist) if ferr == nil { // 已存在:仅当来源或名称有变化时才更新,减少无谓写库 if exist.Name == d.Name && exist.Type == d.Type && exist.Source == d.Source { continue } exist.Name = d.Name exist.Type = d.Type exist.Year = d.Year exist.Wage = d.Wage exist.Source = d.Source exist.UpdateTime = &now _, _ = models.Orm.Update(&exist, "Name", "Type", "Year", "Wage", "Source", "UpdateTime") continue } item := models.OaHoliday{ Date: d.Date, Name: d.Name, Type: d.Type, Year: d.Year, Wage: d.Wage, Source: d.Source, CreateTime: now, UpdateTime: &now, } _, _ = models.Orm.Insert(&item) } } // mergeOaHolidays 按日期去重并升序排列;同日重复时保留先出现的记录 // (数据库缓存排在前面,因此缓存优先于外网新拉取的数据)。 func mergeOaHolidays(list []holiday.Day) []holiday.Day { seen := make(map[string]bool, len(list)) out := make([]holiday.Day, 0, len(list)) for _, d := range list { if d.Date == "" || seen[d.Date] { continue } seen[d.Date] = true out = append(out, d) } for i := 1; i < len(out); i++ { cur := out[i] j := i - 1 for j >= 0 && out[j].Date > cur.Date { out[j+1] = out[j] j-- } out[j+1] = cur } return out } // 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() weekDone, _ := base().Filter("schedule_date__gte", weekStart).Filter("schedule_date__lte", weekEnd).Filter("status", 1).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, "week_done": weekDone, "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, }) }