package controllers import ( "encoding/json" "io" "path/filepath" "strconv" "strings" "server/models" "server/pkg/identity" "server/services" ) // 移动端 OA 工作日程(日历待办)接口,挂在 AppOaController 上。 // // 与 Web 管理端 /backend/oa/schedule/* 共用同一套数据(yz_backend_oa_schedule, // 按 JWT 中 tid + user_id 双重隔离)与核心逻辑(oaSchedule*Core / oaScheduleListData), // 仅鉴权放宽为 app 令牌(oaClaims)。小程序「工作日程」页面走这里, // 不再复用「日程提醒」(/api/schedule/*,yz_backend_schedule)的接口。 // oaScheduleIDParam 解析 :id 路由参数,非法返回 0。 func (c *AppOaController) oaScheduleIDParam() uint64 { id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if err != nil { return 0 } return id } // oaScheduleReadPayload 读取并校验新建/编辑请求体;失败时直接输出错误响应。 func (c *AppOaController) oaScheduleReadPayload() (oaSchedulePayload, bool) { raw, err := io.ReadAll(c.Ctx.Request.Body) if err != nil { c.oaErr(400, 400, "参数错误") return oaSchedulePayload{}, false } payload, msg := parseOaSchedulePayloadBody(raw) if msg != "" { c.oaErr(400, 400, msg) return payload, false } return payload, true } // ScheduleList GET /api/oa/schedule/list // 移动端工作日程列表:参数与返回结构与 /backend/oa/schedule/list 一致 // (page/pageSize/keyword/status/priority/start_date/end_date/category, // 返回 list/total/counts)。 func (c *AppOaController) ScheduleList() { claims, err := c.oaClaims() if err != nil { code := identity.Status(err) c.oaErr(code, code, err.Error()) return } page, _ := c.GetInt("page", 1) pageSize, _ := c.GetInt("pageSize", 20) data, code, msg := oaScheduleListData(claims.TenantId, claims.UserID, page, pageSize, strings.TrimSpace(c.GetString("keyword")), strings.TrimSpace(c.GetString("status")), strings.TrimSpace(c.GetString("priority")), strings.TrimSpace(c.GetString("start_date")), strings.TrimSpace(c.GetString("end_date")), strings.TrimSpace(c.GetString("category"))) if msg != "" { c.oaErr(code, code, msg) return } c.oaOk(data) } // ScheduleDetail GET /api/oa/schedule/detail/:id // 日程详情(移动端详情页使用;Web 端直接由列表渲染,无此接口)。 func (c *AppOaController) ScheduleDetail() { claims, err := c.oaClaims() if err != nil { code := identity.Status(err) c.oaErr(code, code, err.Error()) return } id := c.oaScheduleIDParam() if id == 0 { c.oaErr(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.oaErr(404, 404, "日程不存在") return } item.ScheduleDate = normalizeOaDate(item.ScheduleDate) c.oaOk(map[string]interface{}{ "item": item, "images": parseOaScheduleImages(item.Images), }) } // ScheduleCreate POST /api/oa/schedule/create 新建日程(可选绑定提醒)。 func (c *AppOaController) ScheduleCreate() { claims, err := c.oaClaims() if err != nil { code := identity.Status(err) c.oaErr(code, code, err.Error()) return } payload, ok := c.oaScheduleReadPayload() if !ok { return } item, code, msg := oaScheduleCreateCore(claims, payload) if msg != "" { c.oaErr(code, code, msg) return } c.oaOk(item) } // ScheduleUpdate POST /api/oa/schedule/update/:id 编辑日程(含提醒重绑)。 func (c *AppOaController) ScheduleUpdate() { claims, err := c.oaClaims() if err != nil { code := identity.Status(err) c.oaErr(code, code, err.Error()) return } id := c.oaScheduleIDParam() if id == 0 { c.oaErr(400, 400, "无效ID") return } payload, ok := c.oaScheduleReadPayload() if !ok { return } item, code, msg := oaScheduleUpdateCore(claims, id, payload) if msg != "" { c.oaErr(code, code, msg) return } c.oaOk(item) } // ScheduleDelete DELETE /api/oa/schedule/delete/:id 软删除日程。 func (c *AppOaController) ScheduleDelete() { claims, err := c.oaClaims() if err != nil { code := identity.Status(err) c.oaErr(code, code, err.Error()) return } id := c.oaScheduleIDParam() if id == 0 { c.oaErr(400, 400, "无效ID") return } code, msg := oaScheduleDeleteCore(claims, id) if msg != "" { c.oaErr(code, code, msg) return } c.oaOk(nil) } // ScheduleFinish POST /api/oa/schedule/finish/:id 切换完成状态。 // 方法名不能叫 Finish——那是 beego Controller 的生命周期钩子。 func (c *AppOaController) ScheduleFinish() { claims, err := c.oaClaims() if err != nil { code := identity.Status(err) c.oaErr(code, code, err.Error()) return } id := c.oaScheduleIDParam() if id == 0 { c.oaErr(400, 400, "无效ID") return } newStatus, code, msg := oaScheduleToggleCore(claims, id) if msg != "" { c.oaErr(code, code, msg) return } c.oaOk(map[string]interface{}{"status": newStatus}) } // oaUploadMaxBytes OA 模块附件上传单个文件大小上限(10MB)。 const oaUploadMaxBytes = 10 << 20 // uploadTenantImage 移动端 OA 附件上传共享实现(日程资料 / 报销发票等复用): // multipart 字段名 file,支持图片与 PDF,单个文件 ≤10MB;文件落入租户文件管理 // (backend/{tid}/{uid}/...,用户个人范围),返回 {id,url,name} 供业务写引用。 func (c *AppOaController) uploadTenantImage() { claims, err := c.oaClaims() if err != nil { code := identity.Status(err) c.oaErr(code, code, err.Error()) return } fh, header, err := c.GetFile("file") if err != nil || fh == nil { c.oaErr(400, 400, "请选择要上传的文件") return } defer fh.Close() switch strings.ToLower(filepath.Ext(header.Filename)) { case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".pdf": default: c.oaErr(400, 400, "仅支持 JPG/PNG/GIF/WEBP/BMP 图片或 PDF 文件") return } if header.Size > oaUploadMaxBytes { c.oaErr(400, 400, "文件大小不能超过10MB") return } storage, err := services.GetStorageService() if err != nil { c.oaErr(500, 500, "存储服务不可用,请稍后重试") return } staged, err := storage.Stage(fh, header) if err != nil { c.oaErr(500, 500, "读取上传文件失败") return } committed := false defer func() { // 查重命中或出错时清理临时文件 if !committed && staged != nil { _ = storage.Discard(staged) } }() tid := uint64(claims.TenantId) tuid := uint64(claims.UserID) ctx := services.UploadContext{Source: services.SourceBackend, Tid: tid, Tuid: tuid} scope := ctx.Scope() // 同用户上传过同一张图(MD5 相同)直接复用文件记录,不重复落盘 exist, err := services.FindDuplicate(services.SourceBackend, scope, tid, tuid, staged.MD5) if err != nil { c.oaErr(500, 500, "查重失败") return } if exist != nil { committed = true c.oaOk(map[string]interface{}{"id": exist.ID, "url": exist.Src, "name": exist.Name}) return } result, err := storage.Commit(staged, ctx) if err != nil { c.oaErr(500, 500, "上传失败,请重试") return } committed = true fileID, _ := services.CreateFileRecord(services.FileUploadParams{ Source: services.SourceBackend, Scope: scope, Tid: tid, Tuid: tuid, Uid: tuid, Name: header.Filename, Ext: staged.Ext, Size: uint64(staged.Size), Src: result.URL, ObjectKey: result.Key, Storage: storage.Type(), MD5: staged.MD5, }) c.oaOk(map[string]interface{}{"id": fileID, "url": result.URL, "name": header.Filename}) } // ScheduleUploadImage POST /api/oa/schedule/upload(multipart,字段名 file) // 上传日程资料图片,共享 uploadTenantImage;随后由 ScheduleImages 把引用写进日程。 func (c *AppOaController) ScheduleUploadImage() { c.uploadTenantImage() } // ScheduleImages POST /api/oa/schedule/images/:id 维护日程资料图片(只存文件引用)。 // 与 Web 后台 SetImages 共用核心逻辑;已完成日程也允许补充资料。 func (c *AppOaController) ScheduleImages() { claims, err := c.oaClaims() if err != nil { code := identity.Status(err) c.oaErr(code, code, err.Error()) return } id := c.oaScheduleIDParam() if id == 0 { c.oaErr(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.oaErr(400, 400, "参数错误") return } images, code, msg := oaScheduleSetImagesCore(claims, id, payload.Images) if msg != "" { c.oaErr(code, code, msg) return } c.oaOk(map[string]interface{}{"images": images}) }