Files
yunzerwebsiteallinone/go/controllers/app_aggregate.go
T
2026-11-11 18:42:09 +08:00

685 lines
22 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"
"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)
// 注册即登录:新用户尚未加入企业时令牌 tid=0。
// 此时按「原免登录功能」返回公开模块与公开菜单,其余模块由前端引导创建/挂靠企业。
if tid == 0 {
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "success",
"data": c.noTenantHome(claims),
})
return
}
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)),
},
})
}
// noTenantHome tid=0(已注册、尚未加入企业)时的首屏数据。
// 模块沿用公开宫格(保留 require_login 作为「需企业」标记),菜单只下发免登录菜单,
// 因此新注册用户能直接使用原「免登录」功能,进入需企业模块时由端上引导申请。
func (c *AppAggregateController) noTenantHome(claims *jwtutil.Claims) map[string]interface{} {
var modRows []models.SystemModules
_, _ = models.Orm.QueryTable(new(models.SystemModules)).
Filter("delete_time__isnull", true).
Filter("status", 1).
Filter("is_show", 1).
OrderBy("sort", "id").
All(&modRows)
modRows = services.FilterModulesByPlatform(modRows, models.PlatformApp)
modules := make([]publicModuleItem, 0, len(modRows))
for _, m := range modRows {
modules = append(modules, publicModuleItem{
ID: m.ID, Code: m.Code, Name: m.Name, Icon: m.Icon,
Path: m.Path, Description: m.Description, RequireLogin: m.RequireLogin,
})
}
var menuRows []models.SystemMenu
_, _ = models.Orm.QueryTable(new(models.SystemMenu)).
Filter("status", 1).
Filter("require_login", 0).
All(&menuRows)
allModules := c.allModules()
menus := make([]appMenuItem, 0, len(menuRows))
for _, m := range menuRows {
if m.Type == 3 {
continue
}
item := appMenuItem{
ID: m.ID, Pid: m.Pid, Type: m.Type, Sort: m.Sort,
Title: strings.TrimSpace(m.Title), Leaf: true,
}
if m.Path != nil {
item.Path = strings.TrimSpace(*m.Path)
}
if m.Icon != nil {
item.Icon = strings.TrimSpace(*m.Icon)
}
item.Module = appModuleCodeOfPath(item.Path, allModules)
menus = append(menus, item)
}
state := "none"
var apply models.AuthTenantApply
if err := models.Orm.QueryTable(new(models.AuthTenantApply)).
Filter("identity_id", uint64(claims.UserID)).
Filter("delete_time__isnull", true).
OrderBy("-id").One(&apply); err == nil {
switch apply.Status {
case models.TenantApplyStatusPending:
state = "pending"
case models.TenantApplyStatusReject:
state = "rejected"
}
}
// 展示姓名取统一身份昵称(个人中心可改),不再把账号/手机号当名字下发
displayName := ""
var ai models.AuthIdentity
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
Filter("id", uint64(claims.UserID)).
Filter("delete_time__isnull", true).One(&ai); err == nil && ai.Nickname != nil {
displayName = strings.TrimSpace(*ai.Nickname)
}
return map[string]interface{}{
"user": map[string]interface{}{
"name": displayName, "account": claims.Username, "tenant": "", "tid": 0, "need_tenant": true,
},
"modules": modules,
"menus": menus,
"stats": map[string]interface{}{},
"quick_menu_ids": []uint64{},
"tenant_state": state,
}
}
// publicModuleItem 移动端免登录可见的模块卡片:只暴露展示与安全开关,不含菜单/KPI。
// 供未登录用户渲染 nav 宫格,require_login 决定点击是「进登录引导」还是「直接进入」。
type publicModuleItem struct {
ID uint64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Icon string `json:"icon"`
Path string `json:"path"`
Description string `json:"description"`
RequireLogin int8 `json:"require_login"`
}
// PublicModules GET /api/modules/public
//
// 公开接口(无需登录,路径不在 jwt 中间件 protectedPrefixes 内):
// 返回移动端可见的模块宫格 + 每个模块的 require_login 标记,
// 让未登录用户也能进入 nav 默认页并分辨「哪些能免登录用、哪些必须先登录」。
func (c *AppAggregateController) PublicModules() {
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 {
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success", "data": []publicModuleItem{}})
return
}
// 移动端只展示 app 可见模块(未配置 platforms 视为全端)
rows = services.FilterModulesByPlatform(rows, models.PlatformApp)
out := make([]publicModuleItem, 0, len(rows))
for _, m := range rows {
out = append(out, publicModuleItem{
ID: m.ID,
Code: m.Code,
Name: m.Name,
Icon: m.Icon,
Path: m.Path,
Description: m.Description,
RequireLogin: m.RequireLogin,
})
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success", "data": out})
}
// PublicMenus GET /api/menus/public
//
// 公开接口(无需登录):返回移动端免登录菜单(require_login=0)。
// 免登录控制按菜单粒度配置(Platform 菜单管理),游客持游客令牌直接使用;
// nav 宫格据此判定模块卡片是否可免登录进入(模块级 require_login 不再作为判定依据)。
// 不做角色/套餐过滤:免登录功能对所有人开放,未配 platforms 的菜单继承所属模块。
func (c *AppAggregateController) PublicMenus() {
var rows []models.SystemMenu
if _, err := models.Orm.QueryTable(new(models.SystemMenu)).
Filter("status", 1).
Filter("require_login", 0).
All(&rows); err != nil {
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success", "data": []appMenuItem{}})
return
}
allModules := c.allModules()
sortedModules := services.SortedModulesForScope(allModules)
visible := make([]models.SystemMenu, 0, len(rows))
for _, m := range rows {
if m.Type == 3 { // 接口类菜单是权限标识载体,不下发
continue
}
if m.IsVisible != nil && *m.IsVisible == 0 {
continue
}
if !services.MenuVisibleOn(m, sortedModules, allModules, models.PlatformApp) {
continue
}
visible = append(visible, m)
}
// 公开集合内统计子节点数:目录若只包含未放开(或不存在)的子菜单,leaf 为 true 但不跳任何页,
// 前端按 NATIVE_RULES 解析不到路由时会落「开发中」占位,不会误入受保护页。
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)
}
item.Module = appModuleCodeOfPath(item.Path, allModules)
item.Leaf = childCount[int64(m.ID)] == 0
out = append(out, item)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Sort < out[j].Sort })
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success", "data": out})
}
// 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": "",
}
// 姓名只取真实资料(统一身份昵称,个人中心可改),不再用账号/手机号冒充
var ai models.AuthIdentity
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
Filter("id", uint64(claims.UserID)).
Filter("delete_time__isnull", true).One(&ai); err == nil && ai.Nickname != nil {
out["name"] = strings.TrimSpace(*ai.Nickname)
}
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,
}
}