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() } 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 } // List GET /backend/oa/schedule/list // 分页查询当前用户的日程,支持 keyword/status/priority/start_date/end_date 筛选。 // 日历视图传 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")) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 500 { pageSize = 20 } qs := models.Orm.QueryTable(new(models.OaSchedule)). Filter("is_deleted", 0). Filter("tid", claims.TenantId). Filter("user_id", claims.UserID) if keyword != "" { qs = qs.Filter("title__icontains", keyword) } if status == "0" || status == "1" { statusVal, _ := strconv.Atoi(status) qs = qs.Filter("status", int8(statusVal)) } if priority == "0" || priority == "1" || priority == "2" { priorityVal, _ := strconv.Atoi(priority) qs = qs.Filter("priority", int8(priorityVal)) } if isValidOaDate(startDate) { qs = qs.Filter("schedule_date__gte", startDate) } if isValidOaDate(endDate) { qs = qs.Filter("schedule_date__lte", endDate) } 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}) } // 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}) }