Files
yunzerwebsiteallinone/go/controllers/app_aggregate.go
T
2026-10-19 01:13:17 +08:00

480 lines
15 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)
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,
}
}