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