package controllers import ( "sort" "strconv" "strings" "time" "server/models" "server/pkg/identity" "server/pkg/jwtutil" "server/services" "github.com/beego/beego/v2/client/orm" beego "github.com/beego/beego/v2/server/web" ) // AppOaController 移动端 OA 协同办公接口(通知公告 / 文档库 / 薪酬工资单 / 员工档案)。 // // 与 Web 管理端(/backend/oa/*)的区别: // - 鉴权放宽到 app 令牌(backend/app/platform),供 uniapp 调用; // - 只提供读能力(浏览 / 下载 / 收藏);写入操作仍留在 Web 端; // - 薪酬工资单与员工档案仅返回「本人」数据,通过登录账号匹配员工。 // // 数据隔离:一律以 JWT 中的 TenantId 过滤,前端无法指定租户。 type AppOaController struct { beego.Controller } // 只放行移动端与租户端令牌:/api/** 仅被 uniapp 调用,平台令牌不该能打移动端接口。 func (c *AppOaController) oaClaims() (*jwtutil.Claims, error) { return identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndApp, identity.EndBackend) } func (c *AppOaController) oaErr(httpStatus, bizCode int, msg string) { c.Ctx.Output.SetStatus(httpStatus) c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} _ = c.ServeJSON() } func (c *AppOaController) oaOk(data interface{}) { c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} _ = c.ServeJSON() } // oaMyEmployee 解析当前登录用户对应的员工记录(tid + 账号匹配,口径与 backend_auth 一致)。 func (c *AppOaController) oaMyEmployee(claims *jwtutil.Claims) (*models.BackendEmployee, bool) { uid := services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID)) account := strings.TrimSpace(claims.Username) var tu models.SystemTenantUser if models.Orm.QueryTable(new(models.SystemTenantUser)). Filter("uid", uid).Filter("tid", claims.TenantId). Filter("delete_time__isnull", true).One(&tu) == nil { if tu.Account != nil && strings.TrimSpace(*tu.Account) != "" { account = strings.TrimSpace(*tu.Account) } } if account == "" { return nil, false } var emp models.BackendEmployee if models.Orm.QueryTable(new(models.BackendEmployee)). Filter("tid", claims.TenantId).Filter("account", account). Filter("delete_time__isnull", true).One(&emp) != nil { return nil, false } return &emp, true } // ---------------- 通知公告 ---------------- // noticeItem 公告列表项(正文只回传摘要,避免列表过大)。 func noticeItem(n models.OaNotice) map[string]interface{} { publishTime := "" if n.PublishTime != nil { publishTime = n.PublishTime.Format("2006-01-02 15:04:05") } return map[string]interface{}{ "id": n.ID, "title": n.Title, "notice_type": n.NoticeType, "level": n.Level, "is_top": n.IsTop, "publisher_name": n.PublisherName, "publish_time": publishTime, "read_count": n.ReadCount, "summary": oaTextSummary(n.Content, 60), } } // oaTextSummary 取正文纯文本摘要,按「字符数」截断(中文安全)。 func oaTextSummary(content string, limit int) string { text := strings.Join(strings.Fields(content), " ") runes := []rune(text) if limit > 0 && len(runes) > limit { return string(runes[:limit]) + "…" } return text } // NoticeList GET /app/oa/notice/list 仅返回已发布(status=1)的公告。 func (c *AppOaController) NoticeList() { claims, err := c.oaClaims() if err != nil { c.oaErr(identity.Status(err), identity.Status(err), err.Error()) return } page, _ := c.GetInt("page", 1) pageSize, _ := c.GetInt("pageSize", 20) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } keyword := strings.TrimSpace(c.GetString("keyword")) qs := models.Orm.QueryTable(new(models.OaNotice)). Filter("is_deleted", 0). Filter("tid", claims.TenantId). Filter("status", 1) if keyword != "" { qs = qs.Filter("title__icontains", keyword) } total, _ := qs.Count() var rows []models.OaNotice _, err = qs.OrderBy("-is_top", "-publish_time", "-id"). Limit(pageSize).Offset((page - 1) * pageSize).All(&rows) if err != nil && err != orm.ErrNoRows { c.oaErr(500, 500, "查询失败") return } list := make([]map[string]interface{}, 0, len(rows)) for _, n := range rows { list = append(list, noticeItem(n)) } c.oaOk(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize}) } // NoticeDetail GET /app/oa/notice/:id 公告详情,同时累加阅读数。 func (c *AppOaController) NoticeDetail() { claims, err := c.oaClaims() if err != nil { c.oaErr(identity.Status(err), identity.Status(err), err.Error()) return } id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if id == 0 { c.oaErr(400, 400, "无效的公告ID") return } var n models.OaNotice if err := models.Orm.QueryTable(new(models.OaNotice)). Filter("id", id).Filter("tid", claims.TenantId). Filter("is_deleted", 0).Filter("status", 1).One(&n); err != nil { c.oaErr(404, 404, "公告不存在") return } _, _ = models.Orm.Raw( "UPDATE yz_backend_oa_notice SET read_count = read_count + 1 WHERE id = ?", id).Exec() n.ReadCount++ item := noticeItem(n) item["content"] = n.Content c.oaOk(item) } // ---------------- 文档库 ---------------- // oaDocActor 由 JWT 构造文档访问者上下文(租户 + 用户可见性)。 func (c *AppOaController) oaDocActor(claims *jwtutil.Claims) services.OaDocActor { return services.NewOaDocActor(claims.TenantId, uint64(claims.UserID)) } // docItem 文档列表/详情项(不回传体积较大的在线编辑源)。 func docItem(d models.OaDoc) map[string]interface{} { updateTime := "" if d.UpdateTime != nil { updateTime = d.UpdateTime.Format("2006-01-02 15:04:05") } return map[string]interface{}{ "id": d.ID, "title": d.Title, "category_id": d.CategoryID, "file_url": d.FileURL, "file_name": d.FileName, "ext": d.Ext, "size": d.Size, "doc_type": d.DocType, "tags": d.Tags, "summary": d.Summary, "is_online": d.IsOnline, "status": d.Status, "version": d.Version, "is_star": d.IsStar, "owner_name": d.OwnerName, "creator_name": d.CreatorName, "view_count": d.ViewCount, "download_count": d.DownCount, "visibility": d.Visibility, "create_time": d.CreateTime.Format("2006-01-02 15:04:05"), "update_time": updateTime, } } // DocCategories GET /app/oa/document/categories?scope=shared|personal|all 分类树(含数量)。 func (c *AppOaController) DocCategories() { claims, err := c.oaClaims() if err != nil { c.oaErr(identity.Status(err), identity.Status(err), err.Error()) return } tree, err := services.OaDocCategoryTree(c.oaDocActor(claims), strings.TrimSpace(c.GetString("scope"))) if err != nil { c.oaErr(400, 400, err.Error()) return } c.oaOk(tree) } // DocList GET /app/oa/document/list 文档分页(按可见性 + 空间过滤)。 func (c *AppOaController) DocList() { claims, err := c.oaClaims() if err != nil { c.oaErr(identity.Status(err), identity.Status(err), err.Error()) return } page, _ := c.GetInt("page", 1) pageSize, _ := c.GetInt("pageSize", 20) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } status, _ := c.GetInt("status", -1) docType, _ := c.GetInt("doc_type", -1) star, _ := c.GetInt("star", -1) categoryID, _ := c.GetUint64("category_id") actor := c.oaDocActor(claims) list, total, err := services.OaDocList(actor, services.OaDocListParams{ Actor: actor, Scope: strings.TrimSpace(c.GetString("scope")), Keyword: strings.TrimSpace(c.GetString("keyword")), CategoryID: categoryID, Uncategorized: strings.TrimSpace(c.GetString("uncategorized")) == "1", Visibility: -1, Mine: strings.TrimSpace(c.GetString("mine")) == "1", Status: status, DocType: docType, Star: star, Page: page, PageSize: pageSize, }) if err != nil { c.oaErr(400, 400, err.Error()) return } items := make([]map[string]interface{}, 0, len(list)) for _, d := range list { items = append(items, docItem(d)) } c.oaOk(map[string]interface{}{"list": items, "total": total, "page": page, "pageSize": pageSize}) } // DocDetail GET /app/oa/document/:id 文档详情(累加查看数)。 func (c *AppOaController) DocDetail() { claims, err := c.oaClaims() if err != nil { c.oaErr(identity.Status(err), identity.Status(err), err.Error()) return } id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if id == 0 { c.oaErr(400, 400, "无效的文档ID") return } doc, err := services.OaDocView(c.oaDocActor(claims), id) if err != nil { c.oaErr(404, 404, err.Error()) return } if strings.TrimSpace(doc.Content) != "" { doc.IsOnline = 1 } c.oaOk(docItem(*doc)) } // DocDownload GET /app/oa/document/download/:id 记录下载并返回下载地址。 func (c *AppOaController) DocDownload() { claims, err := c.oaClaims() if err != nil { c.oaErr(identity.Status(err), identity.Status(err), err.Error()) return } id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if id == 0 { c.oaErr(400, 400, "无效的文档ID") return } doc, err := services.OaDocDownload(c.oaDocActor(claims), id) if err != nil { c.oaErr(404, 404, err.Error()) return } c.oaOk(map[string]interface{}{ "id": doc.ID, "title": doc.Title, "file_url": doc.FileURL, "file_name": doc.FileName, "download_count": doc.DownCount, }) } // DocStar POST /app/oa/document/:id/star 收藏 / 取消收藏。 func (c *AppOaController) DocStar() { claims, err := c.oaClaims() if err != nil { c.oaErr(identity.Status(err), identity.Status(err), err.Error()) return } id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if id == 0 { c.oaErr(400, 400, "无效的文档ID") return } isStar, err := services.OaDocToggleStar(c.oaDocActor(claims), id) if err != nil { c.oaErr(400, 400, err.Error()) return } c.oaOk(map[string]interface{}{"is_star": isStar}) } // ---------------- 薪酬工资单(仅本人) ---------------- // PayrollList GET /app/oa/compensation/payrolls 仅返回当前登录用户本人的薪资单。 func (c *AppOaController) PayrollList() { claims, err := c.oaClaims() if err != nil { c.oaErr(identity.Status(err), identity.Status(err), err.Error()) return } page, _ := c.GetInt("page", 1) pageSize, _ := c.GetInt("pageSize", 20) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } emp, ok := c.oaMyEmployee(claims) if !ok { c.oaOk(map[string]interface{}{"list": []map[string]interface{}{}, "total": 0}) return } qs := models.Orm.QueryTable(new(models.BackendOaPayroll)). Filter("tid", claims.TenantId). Filter("employee_id", emp.ID). Filter("is_deleted", 0) if month := strings.TrimSpace(c.GetString("payroll_month")); compensationMonthPattern.MatchString(month) { qs = qs.Filter("payroll_month", month) } total, _ := qs.Count() var rows []models.BackendOaPayroll if _, err := qs.OrderBy("-payroll_month", "-id"). Limit(pageSize).Offset((page - 1) * pageSize).All(&rows); err != nil && err != orm.ErrNoRows { c.oaErr(500, 500, "查询失败") return } list := make([]map[string]interface{}, 0, len(rows)) for _, row := range rows { list = append(list, payrollMap(row)) } c.oaOk(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize}) } // PayrollDetail GET /app/oa/compensation/payrolls/:id 本人薪资单明细(含自定义增减项)。 func (c *AppOaController) PayrollDetail() { claims, err := c.oaClaims() if err != nil { c.oaErr(identity.Status(err), identity.Status(err), err.Error()) return } id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if id == 0 { c.oaErr(400, 400, "无效的薪资单ID") return } emp, ok := c.oaMyEmployee(claims) if !ok { c.oaErr(404, 404, "未找到您的员工信息") return } var row models.BackendOaPayroll if models.Orm.QueryTable(new(models.BackendOaPayroll)). Filter("id", id).Filter("tid", claims.TenantId). Filter("employee_id", emp.ID).Filter("is_deleted", 0).One(&row) != nil { c.oaErr(404, 404, "薪资单不存在") return } var items []models.BackendOaPayrollItem _, _ = models.Orm.QueryTable(new(models.BackendOaPayrollItem)). Filter("tid", claims.TenantId).Filter("payroll_id", id). Filter("is_deleted", 0).OrderBy("sort_order", "id").All(&items) if items == nil { items = []models.BackendOaPayrollItem{} } data := payrollMap(row) data["items"] = items c.oaOk(data) } // ---------------- 员工档案(仅本人) ---------------- // MyEmployeeFile GET /app/oa/employee-file 返回当前登录用户本人的档案与子记录(只读)。 func (c *AppOaController) MyEmployeeFile() { claims, err := c.oaClaims() if err != nil { c.oaErr(identity.Status(err), identity.Status(err), err.Error()) return } emp, ok := c.oaMyEmployee(claims) if !ok { c.oaErr(404, 404, "未找到您的员工信息") return } tid := claims.TenantId var file models.BackendEmployeeFile if models.Orm.QueryTable(new(models.BackendEmployeeFile)). Filter("tid", tid).Filter("is_deleted", 0). Filter("employee_id", emp.ID).One(&file) != nil { c.oaErr(404, 404, "您暂无员工档案") return } var records []models.BackendEmployeeFileRecord _, _ = models.Orm.QueryTable(new(models.BackendEmployeeFileRecord)). Filter("tid", tid).Filter("is_deleted", 0). Filter("employee_id", emp.ID).OrderBy("sort", "id").All(&records) if records == nil { records = []models.BackendEmployeeFileRecord{} } dtos := (&BackendEmployeeFileController{}).efAssembleDTOList(tid, []models.BackendEmployeeFile{file}) c.oaOk(map[string]interface{}{"file": dtos[0], "records": records}) } // ---------------- 工作台仪表盘(移动端首屏聚合) ---------------- // Dashboard GET /app/oa/dashboard // // 一次返回移动端 OA 首页顶部仪表盘所需的全部数据,口径对齐 Web 端 // /backend/oa/schedule/stats 与 /backend/reimbursements/dashboard: // // { // schedule_stats:{today_total,today_done,week_total,week_done,month_total,month_done,overdue}, // reimburse:{week,month,year,trend,status_dist,expense_types}, // notices:[...], // 最近已发布公告(置顶优先,最多 5 条) // today_schedules:[...] // 今日日程(含已完成,灰显沉底) // } func (c *AppOaController) Dashboard() { claims, err := c.oaClaims() if err != nil { c.oaErr(identity.Status(err), identity.Status(err), err.Error()) return } uid := uint64(claims.UserID) c.oaOk(map[string]interface{}{ "schedule_stats": c.oaDashboardScheduleStats(claims.TenantId, uid), "reimburse": c.oaDashboardReimburse(uint64(claims.TenantId), uid), "notices": c.oaDashboardNotices(claims.TenantId), "today_schedules": c.oaDashboardTodaySchedules(claims.TenantId, uid), }) } // oaDashboardScheduleStats 今日/本周/本月日程统计与逾期数(与 Web 端 Stats 同口径)。 func (c *AppOaController) oaDashboardScheduleStats(tid int, uid uint64) map[string]interface{} { 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", tid). Filter("user_id", uid) } 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() return map[string]interface{}{ "today_total": todayTotal, "today_done": todayDone, "week_total": weekTotal, "week_done": weekDone, "month_total": monthTotal, "month_done": monthDone, "overdue": overdue, } } // oaDashboardTodaySchedules 今日日程列表(未完成在前,已完成灰显沉底)。 func (c *AppOaController) oaDashboardTodaySchedules(tid int, uid uint64) []map[string]interface{} { today := time.Now().Format("2006-01-02") var rows []models.OaSchedule _, _ = models.Orm.QueryTable(new(models.OaSchedule)). Filter("is_deleted", 0).Filter("tid", tid).Filter("user_id", uid). Filter("schedule_date", today). OrderBy("status", "start_time", "id").Limit(50).All(&rows) out := make([]map[string]interface{}, 0, len(rows)) for _, s := range rows { out = append(out, map[string]interface{}{ "id": s.ID, "title": s.Title, "schedule_date": s.ScheduleDate, "start_time": s.StartTime, "end_time": s.EndTime, "all_day": s.AllDay, "color": s.Color, "priority": s.Priority, "status": s.Status, }) } return out } // oaDashboardNotices 最近已发布公告(置顶优先,最多 5 条)。 func (c *AppOaController) oaDashboardNotices(tid int) []map[string]interface{} { var rows []models.OaNotice _, _ = models.Orm.QueryTable(new(models.OaNotice)). Filter("is_deleted", 0).Filter("tid", tid).Filter("status", 1). OrderBy("-is_top", "-publish_time", "-id").Limit(5).All(&rows) out := make([]map[string]interface{}, 0, len(rows)) for _, n := range rows { out = append(out, noticeItem(n)) } return out } // oaDashboardReimburse 报销统计(周/月/年 + 近6月趋势 + 状态分布 + 类型分析), // 个人报销单量小,一次全量查询内存聚合(与 Web 端 Dashboard 同口径)。 func (c *AppOaController) oaDashboardReimburse(tid, uid uint64) map[string]interface{} { now := time.Now() y, m, _ := now.Date() today := time.Date(y, m, now.Day(), 0, 0, 0, 0, now.Location()) tomorrow := today.AddDate(0, 0, 1) weekday := int(today.Weekday()) if weekday == 0 { weekday = 7 } weekStart := today.AddDate(0, 0, -(weekday - 1)) monthStart := time.Date(y, m, 1, 0, 0, 0, 0, now.Location()) yearStart := time.Date(y, 1, 1, 0, 0, 0, 0, now.Location()) newStat := func() map[string]interface{} { return map[string]interface{}{"total": 0.0, "paid": 0.0, "unpaid": 0.0, "count": 0} } addTo := func(stat map[string]interface{}, row *models.BackendReimbursement) { stat["total"] = stat["total"].(float64) + row.TotalAmount stat["count"] = stat["count"].(int) + 1 if row.Status == 5 { stat["paid"] = stat["paid"].(float64) + row.TotalAmount } else { stat["unpaid"] = stat["unpaid"].(float64) + row.TotalAmount } } inRange := func(t, start, end time.Time) bool { return !t.Before(start) && t.Before(end) } weekStat, monthStat, yearStat := newStat(), newStat(), newStat() monthKeys := make([]string, 0, 6) monthStats := map[string]map[string]interface{}{} for i := 5; i >= 0; i-- { key := time.Date(y, m-time.Month(i), 1, 0, 0, 0, 0, now.Location()).Format("2006-01") monthKeys = append(monthKeys, key) monthStats[key] = newStat() } statusNames := map[int8]string{0: "草稿", 1: "审批中", 2: "已通过", 3: "已驳回", 4: "已撤回", 5: "已打款"} statusDist := map[string]interface{}{} var rows []models.BackendReimbursement if _, err := models.Orm.QueryTable(new(models.BackendReimbursement)). Filter("tid", tid).Filter("user_id", uid).Filter("is_deleted", 0). All(&rows); err != nil { return map[string]interface{}{ "week": weekStat, "month": monthStat, "year": yearStat, "trend": []map[string]interface{}{}, "status_dist": statusDist, "expense_types": []map[string]interface{}{}, } } for i := range rows { row := &rows[i] created := row.CreateTime if inRange(created, weekStart, tomorrow) { addTo(weekStat, row) } if inRange(created, monthStart, tomorrow) { addTo(monthStat, row) } if inRange(created, yearStart, tomorrow) { addTo(yearStat, row) } if st, ok := monthStats[created.Format("2006-01")]; ok { addTo(st, row) } sk := strconv.Itoa(int(row.Status)) dist, ok := statusDist[sk].(map[string]interface{}) if !ok { dist = map[string]interface{}{"name": statusNames[row.Status], "count": 0, "amount": 0.0} statusDist[sk] = dist } dist["count"] = dist["count"].(int) + 1 dist["amount"] = dist["amount"].(float64) + row.TotalAmount } trend := make([]map[string]interface{}, 0, len(monthKeys)) for _, key := range monthKeys { st := monthStats[key] trend = append(trend, map[string]interface{}{ "month": key, "total": st["total"], "paid": st["paid"], "unpaid": st["unpaid"], "count": st["count"], }) } // 报销类型分析:仅统计已打款(status=5)的报销单费用明细 paidIDs := map[uint64]bool{} for i := range rows { if rows[i].Status == 5 { paidIDs[rows[i].ID] = true } } expenseTypes := []map[string]interface{}{} paidIDList := make([]uint64, 0, len(paidIDs)) for id := range paidIDs { paidIDList = append(paidIDList, id) } var items []models.BackendReimbursementItem if len(paidIDList) > 0 { // 按已打款主单 ID 查明细,不依赖明细行的 tid/uid(历史数据可能不一致) if _, err := models.Orm.QueryTable(new(models.BackendReimbursementItem)). Filter("reimbursement_id__in", paidIDList).Filter("is_deleted", 0). All(&items); err == nil { typeAgg := map[string]*struct { count int amount float64 }{} for i := range items { if !paidIDs[items[i].ReimbursementID] { continue } name := strings.TrimSpace(items[i].ExpenseType) if name == "" { name = "未分类" } agg, ok := typeAgg[name] if !ok { agg = &struct { count int amount float64 }{} typeAgg[name] = agg } agg.count++ agg.amount += items[i].Amount } for name, agg := range typeAgg { expenseTypes = append(expenseTypes, map[string]interface{}{ "name": name, "count": agg.count, "amount": agg.amount, }) } sort.Slice(expenseTypes, func(i, j int) bool { return expenseTypes[i]["amount"].(float64) > expenseTypes[j]["amount"].(float64) }) } } return map[string]interface{}{ "week": weekStat, "month": monthStat, "year": yearStat, "trend": trend, "status_dist": statusDist, "expense_types": expenseTypes, } }