批量优化

This commit is contained in:
hero920103 committed 2026-10-03 23:46:55 +08:00
1 parent e3db229f41
commit aa92ca40a2
79 files changed
+7495 -1104

No files matched your search

+416
View File
@@ -0,0 +1,416 @@
package controllers
import (
"errors"
"sort"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"server/services"
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) {
auth := c.Ctx.Request.Header.Get("Authorization")
if auth == "" {
return nil, errNotLogin
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return nil, errAuthFormat
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
return nil, errTokenInvalid
}
if claims.UserType != "backend" && claims.UserType != "app" {
return nil, errNoPermission
}
return claims, nil
}
// 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),
},
})
}
// 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,
}
}