package controllers import ( "encoding/json" "errors" "sort" "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" ) // ============================================================= // 移动端「工作台」聚合接口 // // uniapp 首页改造成 ERP/OA/CRM/CMS 聚合平台后,首屏需要一次性拿到三组数据: // 1. 应用模块卡片(yz_system_modules + 租户套餐开通状态) // 2. 当前登录用户可见的菜单(yz_system_menu,按角色过滤 + 套餐锁定标记) // 3. 首页 KPI(待办日程 / 逾期 / 笔记 / 站内信未读 / 报销在途) // // Web 端分别由 /backend/modules/getTenantList、/backend/menu/:id、各 dashboard // 提供,但都要求 user_type=backend,移动端令牌调不到。这里复用同一套 service // 逻辑,仅把 user_type 放宽到 app,并聚合成一次请求以减少移动端首屏往返。 // // ID 口径(与既有 /app/* 接口保持一致,勿混用): // - 业务表的 user_id = 令牌里的 identity_id(claims.UserID) // - 老表 yz_system_tenant_user.uid = services.ResolveTenantUID(tid, claims.UserID) // ============================================================= var ( errNotLogin = errors.New("未登录") errAuthFormat = errors.New("认证信息格式错误") errTokenInvalid = errors.New("无效的token") errNoPermission = errors.New("无权访问") ) // AppAggregateController 移动端工作台聚合 type AppAggregateController struct { beego.Controller } // appMenuItem 菜单扁平项。 // 移动端要按 path 前缀把菜单归到各应用模块下(与套餐鉴权 ownerModuleCode 同源逻辑), // 树形在前端分组不便,扁平 + parent_id 更适合「应用详情」页渲染二级列表。 type appMenuItem struct { ID uint64 `json:"id"` Pid int64 `json:"pid"` Title string `json:"title"` Path string `json:"path,omitempty"` Icon string `json:"icon,omitempty"` Type int8 `json:"type"` Sort int64 `json:"sort"` Module string `json:"module"` // 归属模块 code(crm/erp/oa/cms/ai),公共菜单为空 Locked bool `json:"locked"` // true = 不在租户套餐内,前端置灰并引导升级 Leaf bool `json:"leaf"` // true = 叶子菜单(无下级),可直接跳转 Docs int `json:"docs,omitempty"` // 该节点下可见的子菜单数量 } func (c *AppAggregateController) serveJSON(data map[string]interface{}) { c.Data["json"] = data _ = c.ServeJSON() } func (c *AppAggregateController) fail(msg string) { c.Ctx.Output.SetStatus(401) c.serveJSON(map[string]interface{}{"code": 401, "msg": msg}) } // appClaims 校验 Bearer 令牌,允许 backend / app 两种身份 func (c *AppAggregateController) appClaims() (*jwtutil.Claims, error) { return identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndApp, identity.EndBackend) } // appModuleItem 模块卡片:在模块基础信息上标注当前租户是否已开通 type appModuleItem struct { models.SystemModules Enabled bool `json:"enabled"` Locked bool `json:"locked"` Menus int `json:"menus"` // 该模块下当前用户可见的菜单数 } // Home GET /app/home // // 一次返回工作台首屏所需的全部数据: // // { user:{...}, modules:[...], menus:[...], stats:{todo,overdue,note,message,reimburse} } func (c *AppAggregateController) Home() { claims, err := c.appClaims() if err != nil { c.fail(err.Error()) return } tid := uint64(claims.TenantId) uid := services.ResolveTenantUID(tid, uint64(claims.UserID)) modules := c.appModules(tid) menus := c.appMenus(uid, tid) // 把每个模块下可见的菜单数回填到卡片上,端上决定是否显示「N 个功能」 menuCount := map[string]int{} for _, m := range menus { if m.Module != "" { menuCount[m.Module]++ } } for i := range modules { modules[i].Menus = menuCount[services.NormalizeModuleCode(modules[i].Code)] } c.serveJSON(map[string]interface{}{ "code": 200, "msg": "success", "data": map[string]interface{}{ "user": c.appUser(claims, uid, tid), "modules": modules, "menus": menus, "stats": c.appStats(claims, tid), "quick_menu_ids": c.appQuickMenuIds(tid, uint64(claims.UserID)), }, }) } // appQuickMenuIds 读取当前用户自定义的常用功能菜单 ID 列表(有序)。 // 未设置或解析失败一律返回空数组,前端据此回落到默认取前 N 个叶子菜单。 func (c *AppAggregateController) appQuickMenuIds(tid, uid uint64) []uint64 { out := []uint64{} var row models.UniappUserQuickMenu if err := models.Orm.QueryTable(new(models.UniappUserQuickMenu)). Filter("tid", tid).Filter("uid", uid).One(&row); err != nil { return out } raw := strings.TrimSpace(row.MenuIds) if raw == "" { return out } var ids []uint64 if err := json.Unmarshal([]byte(raw), &ids); err != nil { return out } if ids == nil { return out } return ids } // SaveQuickMenus POST /app/quick-menus // // 全量覆盖保存当前用户的常用功能菜单 ID 列表。请求体:{ "menu_ids": [81,102,...] }。 // 顺序即展示顺序;传空数组表示清空自定义(前端回落到默认)。 func (c *AppAggregateController) SaveQuickMenus() { claims, err := c.appClaims() if err != nil { c.fail(err.Error()) return } tid := uint64(claims.TenantId) uid := uint64(claims.UserID) var body struct { MenuIds []uint64 `json:"menu_ids"` } if err := json.Unmarshal(c.Ctx.Input.RequestBody, &body); err != nil { c.Ctx.Output.SetStatus(400) c.serveJSON(map[string]interface{}{"code": 400, "msg": "请求体格式错误"}) return } if body.MenuIds == nil { body.MenuIds = []uint64{} } raw, _ := json.Marshal(body.MenuIds) now := time.Now() var row models.UniappUserQuickMenu if err := models.Orm.QueryTable(new(models.UniappUserQuickMenu)). Filter("tid", tid).Filter("uid", uid).One(&row); err == nil { _, _ = models.Orm.QueryTable(new(models.UniappUserQuickMenu)). Filter("id", row.ID).Update(orm.Params{ "menu_ids": string(raw), "update_time": now, }) } else { _, _ = models.Orm.Insert(&models.UniappUserQuickMenu{ Tid: tid, Uid: uid, MenuIds: string(raw), CreateTime: now, UpdateTime: &now, }) } c.serveJSON(map[string]interface{}{ "code": 200, "msg": "success", "data": map[string]interface{}{"menu_ids": body.MenuIds}, }) } // appUser 当前登录用户的展示信息(含企业名,供首页问候语使用) func (c *AppAggregateController) appUser(claims *jwtutil.Claims, uid, tid uint64) map[string]interface{} { out := map[string]interface{}{ "uid": uid, "tid": tid, "name": "用户", "account": "", "role_name": "", "tenant": "", "tenant_id": tid, "avatar": "", } if claims.Username != "" { out["name"] = claims.Username } var tu models.SystemTenantUser if models.Orm.QueryTable(new(models.SystemTenantUser)). Filter("uid", uid).Filter("tid", tid).One(&tu) == nil { if tu.Name != nil && strings.TrimSpace(*tu.Name) != "" { out["name"] = strings.TrimSpace(*tu.Name) } if tu.Account != nil && strings.TrimSpace(*tu.Account) != "" { out["account"] = strings.TrimSpace(*tu.Account) } if tu.GroupID > 0 { var role models.AdminRole if models.Orm.QueryTable(new(models.AdminRole)). Filter("id", tu.GroupID).Filter("cid", 2).One(&role) == nil { if strings.TrimSpace(role.Name) != "" { out["role_name"] = strings.TrimSpace(role.Name) } } } } var tenant models.SystemTenant if models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant) == nil { if strings.TrimSpace(tenant.TenantName) != "" { out["tenant"] = strings.TrimSpace(tenant.TenantName) } } return out } // appModules 模块卡片列表(status=1 且 is_show=1),并标注套餐开通状态与可见菜单数 func (c *AppAggregateController) appModules(tid uint64) []appModuleItem { var rows []models.SystemModules if _, err := models.Orm.QueryTable(new(models.SystemModules)). Filter("delete_time__isnull", true). Filter("status", 1). Filter("is_show", 1). OrderBy("sort", "id"). All(&rows); err != nil { return []appModuleItem{} } // 移动端只展示 app 可见的模块(未配置 platforms 视为全端) rows = services.FilterModulesByPlatform(rows, models.PlatformApp) allowed := services.GetTenantModuleCodes(tid) out := make([]appModuleItem, 0, len(rows)) for _, m := range rows { enabled := true if len(allowed) > 0 { enabled = allowed[services.NormalizeModuleCode(m.Code)] } out = append(out, appModuleItem{ SystemModules: m, Enabled: enabled, Locked: !enabled, }) } return out } // appMenus 当前用户可见的租户端菜单(扁平 + 模块归属 + 套餐锁定标记) func (c *AppAggregateController) appMenus(uid, tid uint64) []appMenuItem { var rows []models.SystemMenu if _, err := models.Orm.QueryTable(new(models.SystemMenu)). Filter("status", 1).All(&rows); err != nil { return []appMenuItem{} } menus := filterMenusByView(rows, 2) // 角色权限过滤:rights 为空视为全权限(与 Web 端口径一致) if uid > 0 { var tu models.SystemTenantUser if models.Orm.QueryTable(new(models.SystemTenantUser)). Filter("uid", uid).Filter("tid", tid).One(&tu) == nil && tu.GroupID > 0 { var role models.AdminRole if models.Orm.QueryTable(new(models.AdminRole)). Filter("id", tu.GroupID).Filter("cid", 2).One(&role) == nil { menus = filterMenusByRights(menus, role.Rights) } } } // 套餐未开通的菜单保留但标记 locked,前端置灰并引导升级 menus, lockedIDs := filterMenusByTenantPackageLocked("", menus, tid, true) // 归属计算必须用「全部模块」而非仅启用的模块: // 系统设置类模块(basicSettings/systemManager/moduleshop,type=2)默认不启用, // 若只用启用模块,它们的菜单会全部落到"无归属"被误吸进协作工具区; // 同理已停用模块的残留菜单也应当归属回自己的模块,由前端按 enabled 决定是否展示。 allModules := c.allModules() // 第一遍:按展示端筛出本端可见菜单(is_visible=0 的也不下发)。 // 必须先筛再统计子节点,否则会出现「父节点非叶子但零个可见子项」的死路。 // 按展示端筛选:菜单自身未配置时继承所属模块 sortedModules := services.SortedModulesForScope(allModules) visible := make([]models.SystemMenu, 0, len(menus)) for _, m := range menus { if m.IsVisible != nil && *m.IsVisible == 0 { continue } if !services.MenuVisibleOn(m, sortedModules, allModules, models.PlatformApp) { continue } visible = append(visible, m) } // 第二遍:统计本端可见子节点数量,用于区分「目录」与「可跳转页面」 childCount := map[int64]int{} for _, m := range visible { if m.Pid != 0 { childCount[m.Pid]++ } } out := make([]appMenuItem, 0, len(visible)) for _, m := range visible { item := appMenuItem{ ID: m.ID, Pid: m.Pid, Type: m.Type, Sort: m.Sort, Title: strings.TrimSpace(m.Title), } if m.Path != nil { item.Path = strings.TrimSpace(*m.Path) } if m.Icon != nil { item.Icon = strings.TrimSpace(*m.Icon) } if lockedIDs != nil { item.Locked = lockedIDs[m.ID] } item.Module = appModuleCodeOfPath(item.Path, allModules) item.Leaf = childCount[int64(m.ID)] == 0 item.Docs = childCount[int64(m.ID)] out = append(out, item) } sort.SliceStable(out, func(i, j int) bool { return out[i].Sort < out[j].Sort }) return out } // pathOf 取菜单路径(空指针安全) func pathOf(m models.SystemMenu) string { if m.Path == nil { return "" } return strings.TrimSpace(*m.Path) } // appModuleCodeOfPath 判断菜单归属哪个功能模块。 // 与 services.ownerModuleCode 同规则(长路径优先,避免 /apps 吞掉 /apps/erp), // 区别是自行排序以避免依赖 services 包的内部实现。 func appModuleCodeOfPath(path string, allModules []models.SystemModules) string { p := strings.TrimSuffix(strings.TrimSpace(path), "/") if p == "" { return "" } sorted := make([]models.SystemModules, len(allModules)) copy(sorted, allModules) sort.SliceStable(sorted, func(i, j int) bool { return len(sorted[i].Path) > len(sorted[j].Path) }) for _, m := range sorted { mp := strings.TrimSuffix(strings.TrimSpace(m.Path), "/") if mp == "" { continue } if p == mp || strings.HasPrefix(p, mp+"/") { return services.NormalizeModuleCode(m.Code) } } return "" } // allModules 读取全部未删除模块(含停用 / type=2 系统设置),仅用于菜单归属计算。 func (c *AppAggregateController) allModules() []models.SystemModules { var rows []models.SystemModules if _, err := models.Orm.QueryTable(new(models.SystemModules)). Filter("delete_time__isnull", true). OrderBy("sort", "id"). All(&rows); err != nil { return nil } return rows } // appStats 首页 KPI。任一子查询失败都降级为 0,不影响其它指标。 func (c *AppAggregateController) appStats(claims *jwtutil.Claims, tid uint64) map[string]interface{} { uid := claims.UserID now := time.Now() today := now.Format("2006-01-02") // 待办日程 / 逾期:日程是否完成由提醒任务的 remind_status 决定(与 /app/schedule/list 同口径) todo, overdue := 0, 0 { var rows []models.BackendSchedule if _, err := models.Orm.QueryTable(new(models.BackendSchedule)). Filter("user_id", uid).Filter("tid", claims.TenantId). OrderBy("-id").Limit(500).All(&rows); err == nil { for _, s := range rows { var reminders []models.BackendScheduleReminder if _, e := models.Orm.QueryTable(new(models.BackendScheduleReminder)). Filter("schedule_id", s.ID).Filter("is_deleted", 0). All(&reminders); e != nil { continue } if len(reminders) == 0 { todo++ if s.ScheduleTime.Format("2006-01-02") < today { overdue++ } continue } done := true for _, r := range reminders { if r.RemindStatus != 2 { done = false break } } if !done { todo++ if s.ScheduleTime.Format("2006-01-02") < today { overdue++ } } } } } // 笔记 note := 0 { cnt, _ := models.Orm.QueryTable(new(models.BackendNotebook)). Filter("user_id", uid).Filter("is_deleted", 0).Count() note = int(cnt) } // 站内信未读(receiver_type=tenant,与 /backend/sitereminder/myList 同口径) message := 0 { cnt, _ := models.Orm.QueryTable(new(models.SystemReminderList)). Filter("receiver_id", uid).Filter("receiver_type", "tenant"). Filter("is_read", 0).Filter("delete_time__isnull", true).Count() message = int(cnt) } // 报销在途(status=1 审批中) reimburse := 0 { cnt, _ := models.Orm.QueryTable(new(models.BackendReimbursement)). Filter("user_id", uid).Filter("tid", tid). Filter("is_deleted", 0).Filter("status", 1).Count() reimburse = int(cnt) } return map[string]interface{}{ "todo": todo, "overdue": overdue, "note": note, "message": message, "reimburse": reimburse, } }