Files
yunzerwebsiteallinone/go/controllers/backend_oa_schedule.go
T
2026-09-08 22:18:36 +08:00

730 lines
21 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"
"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"`
}
// 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"
}
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
}
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 _, err := models.Orm.Update(&item,
"Title", "Content", "ScheduleDate", "StartTime", "EndTime",
"AllDay", "Color", "Priority", "UpdateTime"); err != nil {
c.oasJsonErr(500, 500, "保存失败")
return
}
c.oasOk(item)
}
// 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()
n, err := models.Orm.QueryTable(new(models.OaSchedule)).
Filter("id", id).
Filter("is_deleted", 0).
Filter("tid", claims.TenantId).
Filter("user_id", claims.UserID).
Update(map[string]interface{}{
"IsDeleted": int8(1),
"DeleteTime": now,
"UpdateTime": now,
})
if err != nil || n == 0 {
c.oasJsonErr(404, 404, "日程不存在或已删除")
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,
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,
})
}