From aa92ca40a27bd87bdbb51acfd3b7b2fad4af7920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com> Date: Sat, 3 Oct 2026 23:46:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=B9=E9=87=8F=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/router/index.js | 15 +- backend/src/views/auth/appLogin.vue | 232 ++++ go/conf/app.conf | 2 +- go/controllers/app_aggregate.go | 416 ++++++ go/controllers/auth/app_ticket.go | 243 ++++ go/controllers/auth/login.go | 12 +- go/controllers/auth/wechat_miniapp.go | 309 +++++ go/controllers/backend_menu.go | 33 +- go/controllers/backend_modules.go | 2 + go/controllers/platform_modules.go | 86 +- go/controllers/platform_wechat_mini.go | 131 ++ go/docs/sql/add_module_platforms.sql | 89 ++ go/docs/sql/create_auth_app_login_ticket.sql | 13 + go/docs/sql/create_auth_tables.sql | 1 + go/docs/sql/create_wechat_mini_config.sql | 42 + go/models/auth_app_ticket.go | 26 + go/models/init.go | 4 + go/models/system_menu.go | 11 + go/models/system_modules.go | 3 + go/models/wechat_mini.go | 24 + go/routers/app/app.go | 3 + go/routers/auth/auth.go | 8 + go/routers/platform/wechat_mp.go | 3 + go/services/auth/app_ticket.go | 97 ++ go/services/auth/token.go | 2 +- go/services/display_scope.go | 154 +++ go/services/display_scope_test.go | 91 ++ go/services/wechatmini/api.go | 234 ++++ go/services/wechatmini/config.go | 126 ++ go/views/auth/login.tpl | 2 + platform/components.d.ts | 2 - uniapp/App.vue | 5 +- uniapp/api/auth.js | 81 +- uniapp/api/config.js | 54 + uniapp/api/request.js | 6 +- uniapp/api/workspace.js | 35 + uniapp/components/AppTabbar.vue | 52 +- uniapp/components/MainTabbar.vue | 93 ++ uniapp/components/sub/SubFunctions.vue | 315 +++++ uniapp/components/sub/SubHome.vue | 427 ++++++ uniapp/components/sub/SubMine.vue | 485 +++++++ uniapp/components/sub/SubTodo.vue | 297 ++++ uniapp/config/subsystems.js | 85 ++ uniapp/manifest.json | 2 + uniapp/pages.json | 157 ++- uniapp/pages/apps/webview.vue | 149 ++ uniapp/pages/cms/functions/functions.vue | 12 + uniapp/pages/cms/home/home.vue | 12 + uniapp/pages/cms/mine/mine.vue | 12 + uniapp/pages/cms/todo/todo.vue | 12 + uniapp/pages/crm/functions/functions.vue | 12 + uniapp/pages/crm/home/home.vue | 12 + uniapp/pages/crm/mine/mine.vue | 12 + uniapp/pages/crm/todo/todo.vue | 12 + uniapp/pages/dashboard/dashboard.vue | 1111 +++++++++++---- uniapp/pages/erp/functions/functions.vue | 12 + uniapp/pages/erp/home/home.vue | 12 + uniapp/pages/erp/mine/mine.vue | 12 + uniapp/pages/erp/todo/todo.vue | 12 + uniapp/pages/features/features.vue | 544 +++++--- uniapp/pages/login/login.vue | 1296 ++++++++++-------- uniapp/pages/nav/nav.vue | 369 +++++ uniapp/pages/oa/functions/functions.vue | 12 + uniapp/pages/oa/home/home.vue | 12 + uniapp/pages/oa/mine/mine.vue | 12 + uniapp/pages/oa/todo/todo.vue | 12 + uniapp/pages/profile/profile.vue | 26 +- uniapp/pages/toolbox/functions/functions.vue | 12 + uniapp/pages/toolbox/home/home.vue | 12 + uniapp/pages/toolbox/mine/mine.vue | 12 + uniapp/pages/toolbox/todo/todo.vue | 12 + uniapp/src/styles/page-common.scss | 31 + uniapp/src/styles/theme.scss | 53 +- uniapp/src/styles/tint.scss | 19 + uniapp/utils/auth.js | 50 +- uniapp/utils/geetest.js | 5 +- uniapp/utils/nav.js | 131 ++ uniapp/utils/subsystem.js | 60 + uniapp/vite.config.js | 5 +- 79 files changed, 7495 insertions(+), 1104 deletions(-) create mode 100644 backend/src/views/auth/appLogin.vue create mode 100644 go/controllers/app_aggregate.go create mode 100644 go/controllers/auth/app_ticket.go create mode 100644 go/controllers/auth/wechat_miniapp.go create mode 100644 go/controllers/platform_wechat_mini.go create mode 100644 go/docs/sql/add_module_platforms.sql create mode 100644 go/docs/sql/create_auth_app_login_ticket.sql create mode 100644 go/docs/sql/create_wechat_mini_config.sql create mode 100644 go/models/auth_app_ticket.go create mode 100644 go/models/wechat_mini.go create mode 100644 go/services/auth/app_ticket.go create mode 100644 go/services/display_scope.go create mode 100644 go/services/display_scope_test.go create mode 100644 go/services/wechatmini/api.go create mode 100644 go/services/wechatmini/config.go create mode 100644 uniapp/api/workspace.js create mode 100644 uniapp/components/MainTabbar.vue create mode 100644 uniapp/components/sub/SubFunctions.vue create mode 100644 uniapp/components/sub/SubHome.vue create mode 100644 uniapp/components/sub/SubMine.vue create mode 100644 uniapp/components/sub/SubTodo.vue create mode 100644 uniapp/config/subsystems.js create mode 100644 uniapp/pages/apps/webview.vue create mode 100644 uniapp/pages/cms/functions/functions.vue create mode 100644 uniapp/pages/cms/home/home.vue create mode 100644 uniapp/pages/cms/mine/mine.vue create mode 100644 uniapp/pages/cms/todo/todo.vue create mode 100644 uniapp/pages/crm/functions/functions.vue create mode 100644 uniapp/pages/crm/home/home.vue create mode 100644 uniapp/pages/crm/mine/mine.vue create mode 100644 uniapp/pages/crm/todo/todo.vue create mode 100644 uniapp/pages/erp/functions/functions.vue create mode 100644 uniapp/pages/erp/home/home.vue create mode 100644 uniapp/pages/erp/mine/mine.vue create mode 100644 uniapp/pages/erp/todo/todo.vue create mode 100644 uniapp/pages/nav/nav.vue create mode 100644 uniapp/pages/oa/functions/functions.vue create mode 100644 uniapp/pages/oa/home/home.vue create mode 100644 uniapp/pages/oa/mine/mine.vue create mode 100644 uniapp/pages/oa/todo/todo.vue create mode 100644 uniapp/pages/toolbox/functions/functions.vue create mode 100644 uniapp/pages/toolbox/home/home.vue create mode 100644 uniapp/pages/toolbox/mine/mine.vue create mode 100644 uniapp/pages/toolbox/todo/todo.vue create mode 100644 uniapp/src/styles/tint.scss create mode 100644 uniapp/utils/nav.js create mode 100644 uniapp/utils/subsystem.js diff --git a/backend/src/router/index.js b/backend/src/router/index.js index 16c50b9..f2b8ec4 100644 --- a/backend/src/router/index.js +++ b/backend/src/router/index.js @@ -148,6 +148,13 @@ const staticRoutes = [ component: () => import("@/views/auth/mpLogin.vue"), meta: { requiresAuth: false } }, + // 移动端免登录页(uniapp APP / 小程序带一次性票据 → 换发 Web 端令牌,避免二次登录) + { + path: "/app-login", + name: "AppLogin", + component: () => import("@/views/auth/appLogin.vue"), + meta: { requiresAuth: false } + }, { path: "/home", name: "Home", @@ -317,8 +324,12 @@ async function gotoAuthorize() { router.beforeEach(async (to, from, next) => { const token = localStorage.getItem("token"); - // 认证中心回跳页 / 微信登录兑换页:始终放行,由页面自身完成令牌兑换 - if (to.path === "/auth/callback" || to.path === "/mp-login") { + // 认证中心回跳页 / 微信登录兑换页 / 移动端免登录页:始终放行,由页面自身完成令牌兑换 + if ( + to.path === "/auth/callback" || + to.path === "/mp-login" || + to.path === "/app-login" + ) { next(); return; } diff --git a/backend/src/views/auth/appLogin.vue b/backend/src/views/auth/appLogin.vue new file mode 100644 index 0000000..a0609e7 --- /dev/null +++ b/backend/src/views/auth/appLogin.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/go/conf/app.conf b/go/conf/app.conf index 494eaf8..bd96477 100644 --- a/go/conf/app.conf +++ b/go/conf/app.conf @@ -77,7 +77,7 @@ jwt_secrets = # 注意:不要在这里启用与上面公钥不配对的私钥,否则本地签发的 RS256 token 会验签失败。 # 仅当需要在本地自建认证中心并签发 RS256 时才配置私钥,且公钥必须与它配对。 jwt_rsa_private_key_file = -jwt_rsa_public_key_file = E:\Demos\DemoOwns\Go\yunzerwebsiteallinone\go\conf\jwt_rsa_public.pem +jwt_rsa_public_key_file = conf/jwt_rsa_public.pem jwt_rsa_kid = rsa-1 # ==================== 微信公众号(服务号) ==================== diff --git a/go/controllers/app_aggregate.go b/go/controllers/app_aggregate.go new file mode 100644 index 0000000..822cbff --- /dev/null +++ b/go/controllers/app_aggregate.go @@ -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, + } +} diff --git a/go/controllers/auth/app_ticket.go b/go/controllers/auth/app_ticket.go new file mode 100644 index 0000000..7e97a2d --- /dev/null +++ b/go/controllers/auth/app_ticket.go @@ -0,0 +1,243 @@ +package auth + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + authsvc "server/services/auth" + + beego "github.com/beego/beego/v2/server/web" +) + +// ============================================================= +// 移动端免登录票据(把移动端登录态桥接到 Web 端) +// +// POST /auth/app-ticket 移动端带 Bearer 令牌 → 签发一次性票据 +// POST /auth/app-ticket/exchange Web 端带票据 → 核销并换发 Web 端令牌 +// +// 仅用于「移动端已登录 → 打开 Web 端功能页」这一条链路, +// 票据一次性 + 60 秒有效,泄露也无法二次使用。 +// ============================================================= + +// AuthAppTicketController 移动端免登录票据 +type AuthAppTicketController struct { + beego.Controller +} + +func (c *AuthAppTicketController) serveJSON(data map[string]interface{}) { + c.Data["json"] = data + _ = c.ServeJSON() +} + +func (c *AuthAppTicketController) fail(msg string) { + c.Ctx.Output.SetStatus(400) + c.serveJSON(map[string]interface{}{"code": 400, "msg": msg}) +} + +// ticketClaims 解析 Bearer 令牌;移动端是 user_type=app,Web 端是 backend +func (c *AuthAppTicketController) ticketClaims() (*jwtutil.Claims, error) { + header := c.Ctx.Request.Header.Get("Authorization") + if header == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(header, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "app" && claims.UserType != "backend" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +// Create POST /auth/app-ticket +// 移动端用当前登录态换取一次性票据,Web 端拿票据换自己的令牌。 +func (c *AuthAppTicketController) Create() { + claims, err := c.ticketClaims() + if err != nil { + c.Ctx.Output.SetStatus(401) + c.serveJSON(map[string]interface{}{"code": 401, "msg": err.Error()}) + return + } + + var req struct { + ClientID string `json:"client_id"` + } + if body := c.Ctx.Input.RequestBody; len(body) > 0 { + _ = json.Unmarshal(body, &req) + } + // 票据是给 Web 端兑换用的,默认按 Web 端应用签发 + clientID := strings.TrimSpace(req.ClientID) + if clientID == "" { + clientID = "yz-backend" + } + + tid := uint64(0) + if claims.TenantId > 0 { + tid = uint64(claims.TenantId) + } + + ticket, err := authsvc.CreateAppTicket(uint64(claims.UserID), tid, clientID) + if err != nil { + c.fail("签发票据失败:" + err.Error()) + return + } + + c.serveJSON(map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{ + "ticket": ticket, + "expire_in": int(authsvc.AppTicketTTL / time.Second), + }, + }) +} + +// Exchange POST /auth/app-ticket/exchange +// Web 端核销票据并换发本端令牌(无鉴权:票据本身即凭证,一次性 + 短时效)。 +func (c *AuthAppTicketController) Exchange() { + body := c.Ctx.Input.RequestBody + if len(body) == 0 { + body, _ = io.ReadAll(c.Ctx.Request.Body) + } + var req struct { + Ticket string `json:"ticket"` + Token string `json:"token"` // 兼容 mp-login 的命名 + ClientID string `json:"client_id"` + } + if err := json.Unmarshal(body, &req); err != nil { + c.fail("参数错误") + return + } + ticket := strings.TrimSpace(req.Ticket) + if ticket == "" { + ticket = strings.TrimSpace(req.Token) + } + + row, err := authsvc.ConsumeAppTicket(ticket) + if err != nil { + c.fail(err.Error()) + return + } + + // 身份校验:禁用/锁定的账号不签发 + var identity models.AuthIdentity + if err := models.Orm.QueryTable(new(models.AuthIdentity)). + Filter("id", row.IdentityID). + Filter("delete_time__isnull", true). + One(&identity); err != nil { + c.fail("账号不存在或已注销") + return + } + if identity.Status != models.AuthIdentityStatusEnabled { + c.fail("账号已被禁用或锁定,无法登录") + return + } + + username := "云泽用户" + if identity.Nickname != nil && strings.TrimSpace(*identity.Nickname) != "" { + username = strings.TrimSpace(*identity.Nickname) + } + + clientID := strings.TrimSpace(req.ClientID) + if clientID == "" { + clientID = row.ClientID + } + if clientID == "" { + clientID = "yz-backend" + } + + // 未确定企业(多企业)时签发待选令牌,由 Web 端展示企业选择 + tid := row.Tid + needChoose := false + if tid == 0 { + tenants, lerr := authsvc.ListTenantOptions(row.IdentityID) + if lerr != nil || len(tenants) == 0 { + c.fail("该账号未加入任何企业,无法登录") + return + } + if len(tenants) == 1 { + tid = tenants[0].Tid + } else { + tid = authsvc.PendingTenantID + needChoose = true + } + } + + tokens, terr := c.issueTicketTokens(row.IdentityID, tid, clientID, username, needChoose) + if terr != nil { + c.fail(terr.Error()) + return + } + + data := map[string]interface{}{ + "type": "backend", + "tokens": tokens, + "tid": tid, + } + if needChoose { + tenants, _ := authsvc.ListTenantOptions(row.IdentityID) + data["need_choose_tenant"] = true + data["tenants"] = tenants + } + c.serveJSON(map[string]interface{}{"code": 200, "msg": "登录成功", "data": data}) +} + +// issueTicketTokens 建会话 + 签发令牌 + 登录日志(失败不影响主流程) +func (c *AuthAppTicketController) issueTicketTokens(identityID, tid uint64, clientID, username string, needChoose bool) (*authsvc.TokenPair, error) { + accessTTL := 0 + if needChoose { + accessTTL = 600 // 待选状态令牌只够用户完成企业选择 + } + sess, serr := authsvc.CreateSession(authsvc.SessionInfo{ + IdentityID: identityID, + Tid: tid, + ClientID: clientID, + IP: c.Ctx.Input.IP(), + UserAgent: c.Ctx.Request.UserAgent(), + LoginType: "app_ticket", + Amr: "app_ticket", + }) + if serr != nil { + return nil, fmt.Errorf("登录失败:%s", serr.Error()) + } + tokens, terr := authsvc.IssueTokens(authsvc.TokenIssue{ + IdentityID: identityID, + Tid: tid, + ClientID: clientID, + Sid: sess.Sid, + Username: username, + UserType: "backend", // Web 端业务接口普遍要求 backend + Amr: "app_ticket", + AccessTTL: accessTTL, + }) + if terr != nil { + return nil, fmt.Errorf("签发令牌失败:%s", terr.Error()) + } + + identityIDCopy := identityID + tidCopy := tid + amr := "app_ticket" + _, _ = models.Orm.Insert(&models.AuthLoginLog{ + Tid: &tidCopy, + IdentityID: &identityIDCopy, + UserName: username, + ClientID: clientID, + LoginType: "app_ticket", + Amr: &amr, + Status: 1, + Message: "移动端票据免登录", + IP: c.Ctx.Input.IP(), + UserAgent: c.Ctx.Request.UserAgent(), + }) + return tokens, nil +} diff --git a/go/controllers/auth/login.go b/go/controllers/auth/login.go index 6ce15d5..f2e0b3c 100644 --- a/go/controllers/auth/login.go +++ b/go/controllers/auth/login.go @@ -132,11 +132,14 @@ func (c *AuthLoginController) LoginSubmit() { setSessionCookieForCtx(c.Ctx, result.Tokens.Sid) if result.NeedChooseTenant { + // 待选令牌必须一并下发:APP / 小程序等无 Cookie 环境的客户端 + // 只能靠 Bearer 令牌调 /auth/switch-tenant,浏览器端则继续用 yz_sid Cookie。 c.serveJSON(map[string]interface{}{ "code": 200, "msg": "请选择要进入的企业", "need_choose_tenant": true, "tenants": result.Tenants, + "tokens": result.Tokens, "sid": result.Tokens.Sid, }) return @@ -170,12 +173,16 @@ func (c *AuthLoginController) Tenants() { } // SwitchTenant 免密切换企业 -// POST /auth/switch-tenant { tid, client_id, redirect } +// POST /auth/switch-tenant { tid, client_id, redirect, sid } +// +// sid 来源优先级:yz_sid Cookie → Authorization Bearer 令牌中的 sid → 请求体 sid。 +// 请求体 sid 供无 Cookie 能力(且尚未拿到 Bearer 令牌)的客户端兜底。 func (c *AuthLoginController) SwitchTenant() { var req struct { Tid uint64 `json:"tid"` ClientID string `json:"client_id"` Redirect string `json:"redirect"` + Sid string `json:"sid"` } body := c.Ctx.Input.RequestBody if len(body) == 0 { @@ -189,6 +196,9 @@ func (c *AuthLoginController) SwitchTenant() { sid = claims.Sid } } + if sid == "" { + sid = strings.TrimSpace(req.Sid) + } if sid == "" || req.Tid == 0 { c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"}) return diff --git a/go/controllers/auth/wechat_miniapp.go b/go/controllers/auth/wechat_miniapp.go new file mode 100644 index 0000000..c000ecc --- /dev/null +++ b/go/controllers/auth/wechat_miniapp.go @@ -0,0 +1,309 @@ +package auth + +import ( + "encoding/json" + "io" + "strings" + + "server/models" + authsvc "server/services/auth" + "server/services/wechatmini" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" + "github.com/beego/beego/v2/core/logs" +) + +// 小程序登录失败原因(前端按此分支给出不同引导) +const ( + MiniReasonNotConfigured = "not_configured" // 服务端未配置小程序 + MiniReasonIdentityNotFound = "identity_not_found" // 微信未匹配到云泽账号 + MiniReasonNoTenant = "no_tenant" // 账号未加入任何企业 + MiniReasonWechatAPI = "wechat_api_error" // 微信接口报错 +) + +// idPWechatMini 第三方身份源标识(写入 yz_auth_identity_third.provider) +const idPWechatMini = "wechat_mini" + +// AuthWechatMiniController 微信小程序一键登录(统一认证中心) +// +// 与公众号确认链接登录(/api/wechat/mp/login)不同,小程序端由前端直接 +// 携带 wx.login 的 code 与 getPhoneNumber 的动态 code 兑换登录态: +// +// POST /auth/wechat/miniapp/login +// { "code": "wx.login的code", "phone_code": "getPhoneNumber的动态code", "client_id": "yz-miniapp" } +// +// 返回结构与 POST /auth/login 完全一致(tokens / need_choose_tenant / tenants 均在顶层), +// 前端可复用同一套「写入令牌 → 选企业 → switch-tenant」流程。 +type AuthWechatMiniController struct { + beego.Controller +} + +func (c *AuthWechatMiniController) serveJSON(data map[string]interface{}) { + c.Data["json"] = data + _ = c.ServeJSON() +} + +func (c *AuthWechatMiniController) fail(reason string, bizCode int, msg string) { + c.Ctx.Output.SetStatus(400) + c.serveJSON(map[string]interface{}{"code": bizCode, "msg": msg, "reason": reason}) +} + +// Config 小程序登录是否可用 +// GET /auth/wechat/miniapp/config +func (c *AuthWechatMiniController) Config() { + cfg, err := wechatmini.LoadEnabledConfig() + if err != nil { + c.serveJSON(map[string]interface{}{ + "code": 200, + "data": map[string]interface{}{"enabled": false, "auto_bind": false}, + }) + return + } + c.serveJSON(map[string]interface{}{ + "code": 200, + "data": map[string]interface{}{"enabled": true, "auto_bind": cfg.AutoBind}, + }) +} + +// Login 小程序一键登录 +func (c *AuthWechatMiniController) Login() { + var req struct { + Code string `json:"code"` + PhoneCode string `json:"phone_code"` + ClientID string `json:"client_id"` + } + body := c.Ctx.Input.RequestBody + if len(body) == 0 { + body, _ = io.ReadAll(c.Ctx.Request.Body) + } + if err := json.Unmarshal(body, &req); err != nil { + c.fail("", 400, "参数错误") + return + } + req.Code = strings.TrimSpace(req.Code) + req.PhoneCode = strings.TrimSpace(req.PhoneCode) + req.ClientID = strings.TrimSpace(req.ClientID) + if req.ClientID == "" { + req.ClientID = "yz-miniapp" + } + if req.Code == "" { + c.fail("", 400, "缺少登录凭证,请重新进入小程序") + return + } + + cfg, err := wechatmini.LoadEnabledConfig() + if err != nil { + c.fail(MiniReasonNotConfigured, 400, "小程序登录未开通,请使用账号密码登录") + return + } + + // code → openid / unionid + session, err := wechatmini.Code2Session(cfg, req.Code) + if err != nil { + msg := err.Error() + if hint, ok := wechatmini.ExplainError(err); ok { + msg = hint + } + logs.Warn("[wechat-mini] code 换取 openid 失败: %v", err) + c.fail(MiniReasonWechatAPI, 400, msg) + return + } + + // 定位云泽身份:手机号优先(已通过微信验证),其次查已绑定的小程序 openid + var mobile string + if req.PhoneCode != "" { + phone, perr := wechatmini.GetPhoneNumber(cfg, req.PhoneCode) + if perr != nil { + // 手机号能力未开通 / code 失效时不阻断:仍尝试按已绑定 openid 登录 + logs.Warn("[wechat-mini] 获取手机号失败: %v", perr) + } else { + mobile = strings.TrimSpace(phone.PurePhoneNumber) + if mobile == "" { + mobile = strings.TrimSpace(phone.PhoneNumber) + } + } + } + + identity, err := c.resolveIdentity(cfg, mobile, session) + if err != nil { + c.fail(MiniReasonIdentityNotFound, 404, "该微信未绑定云泽账号,请使用手机号一键登录或先注册") + return + } + if identity.Status != models.AuthIdentityStatusEnabled { + c.fail("", 403, "账号已被禁用或锁定,无法登录") + return + } + + // 企业选择:单企业直接进入,多企业签发待选令牌由前端展示选择界面 + tenants, lerr := authsvc.ListTenantOptions(identity.ID) + if lerr != nil || len(tenants) == 0 { + c.fail(MiniReasonNoTenant, 403, "该账号未加入任何企业,请联系管理员") + return + } + tid := uint64(0) + needChoose := false + if len(tenants) == 1 { + tid = tenants[0].Tid + } else { + tid = authsvc.PendingTenantID + needChoose = true + } + + username := "微信用户" + if identity.Nickname != nil && strings.TrimSpace(*identity.Nickname) != "" { + username = strings.TrimSpace(*identity.Nickname) + } else if identity.Mobile != nil && *identity.Mobile != "" { + username = *identity.Mobile + } + + tokens, terr := c.issueTokens(identity.ID, tid, req.ClientID, username, needChoose) + if terr != nil { + c.fail("", 400, terr.Error()) + return + } + + resp := map[string]interface{}{ + "code": 200, + "msg": "登录成功", + "tokens": tokens, + "tenants": tenants, + "identity": map[string]interface{}{"id": identity.ID, "nickname": username, "mobile": mobileOr(identity)}, + } + if needChoose { + resp["need_choose_tenant"] = true + resp["sid"] = tokens.Sid + } + c.serveJSON(resp) +} + +// resolveIdentity 定位身份并维护小程序绑定关系。 +// mobile 非空表示本次已通过微信手机号验证,是可信的强证据,优先采用。 +func (c *AuthWechatMiniController) resolveIdentity(cfg *wechatmini.Config, mobile string, session *wechatmini.Session) (*models.AuthIdentity, error) { + // 1) 手机号命中身份(手机号已由微信验证,属于当前用户的强凭证) + if mobile != "" { + if identity, err := authsvc.FindIdentityByAccount(mobile); err == nil { + c.syncBinding(cfg, identity.ID, session, true) + return identity, nil + } + } + + // 2) 已绑定过该小程序 openid / unionid + bind := findMiniBinding(session.OpenID, session.UnionID) + if bind == nil { + return nil, orm.ErrNoRows + } + var identity models.AuthIdentity + if err := models.Orm.QueryTable(new(models.AuthIdentity)). + Filter("id", bind.IdentityID). + Filter("delete_time__isnull", true). + One(&identity); err != nil { + return nil, err + } + return &identity, nil +} + +// syncBinding 把当前小程序 openid 绑定到该身份。 +// verified 表示本次带通过了微信手机号验证:此时手机号即身份凭证, +// 允许覆盖历史绑定(用户换绑手机号 / 换微信号场景); +// 未验证时只允许「新增绑定」,不抢占已绑定到其他身份的 openid。 +func (c *AuthWechatMiniController) syncBinding(cfg *wechatmini.Config, identityID uint64, session *wechatmini.Session, verified bool) { + bind := findMiniBinding(session.OpenID, session.UnionID) + if bind != nil && bind.IdentityID == identityID { + return + } + if bind != nil { + if !verified { + return + } + _, _ = models.Orm.QueryTable(new(models.AuthIdentityThird)).Filter("id", bind.ID).Delete() + } + if !verified && !cfg.AutoBind { + return + } + item := &models.AuthIdentityThird{ + IdentityID: identityID, + Provider: idPWechatMini, + OpenID: session.OpenID, + } + if session.UnionID != "" { + uid := session.UnionID + item.UnionID = &uid + } + _, _ = models.Orm.Insert(item) +} + +// findMiniBinding 按 openid 查绑定,其次按 unionid 查 +func findMiniBinding(openID, unionID string) *models.AuthIdentityThird { + qs := models.Orm.QueryTable(new(models.AuthIdentityThird)).Filter("provider", idPWechatMini) + if openID != "" { + var bind models.AuthIdentityThird + if err := qs.Filter("open_id", openID).One(&bind); err == nil { + return &bind + } + } + if unionID != "" { + var bind models.AuthIdentityThird + if err := qs.Filter("union_id", unionID).One(&bind); err == nil { + return &bind + } + } + return nil +} + +// issueTokens 建会话并签发 UAC 令牌(含登录日志,失败不影响主流程) +func (c *AuthWechatMiniController) issueTokens(identityID, tid uint64, clientID, username string, needChoose bool) (*authsvc.TokenPair, error) { + accessTTL := 0 + if needChoose { + accessTTL = 600 // 待选状态令牌只够用户完成企业选择 + } + sess, serr := authsvc.CreateSession(authsvc.SessionInfo{ + IdentityID: identityID, + Tid: tid, + ClientID: clientID, + IP: c.Ctx.Input.IP(), + UserAgent: c.Ctx.Request.UserAgent(), + LoginType: "wechat_mini", + Amr: "wechat_mini", + }) + if serr != nil { + return nil, serr + } + tokens, terr := authsvc.IssueTokens(authsvc.TokenIssue{ + IdentityID: identityID, + Tid: tid, + ClientID: clientID, + Sid: sess.Sid, + Username: username, + UserType: "app", + Amr: "wechat_mini", + AccessTTL: accessTTL, + }) + if terr != nil { + return nil, terr + } + + identityIDCopy := identityID + tidCopy := tid + amr := "wechat_mini" + _, _ = models.Orm.Insert(&models.AuthLoginLog{ + Tid: &tidCopy, + IdentityID: &identityIDCopy, + UserName: username, + ClientID: clientID, + LoginType: "wechat_mini", + Amr: &amr, + Status: 1, + Message: "微信小程序一键登录成功", + IP: c.Ctx.Input.IP(), + UserAgent: c.Ctx.Request.UserAgent(), + }) + return tokens, nil +} + +func mobileOr(identity *models.AuthIdentity) string { + if identity != nil && identity.Mobile != nil { + return *identity.Mobile + } + return "" +} diff --git a/go/controllers/backend_menu.go b/go/controllers/backend_menu.go index fbe912a..6659321 100644 --- a/go/controllers/backend_menu.go +++ b/go/controllers/backend_menu.go @@ -18,17 +18,18 @@ type BackendMenuController struct { type AdminMenuController = BackendMenuController type menuPayload struct { - Pid *int64 `json:"pid"` - Title *string `json:"title"` - Path *string `json:"path"` - ComponentPath *string `json:"component_path"` - Icon *string `json:"icon"` - Sort *int64 `json:"sort"` - Status *int8 `json:"status"` - IsVisible *int8 `json:"is_visible"` - Views []int `json:"views"` - Type *int8 `json:"type"` - Permission *string `json:"permission"` + Pid *int64 `json:"pid"` + Title *string `json:"title"` + Path *string `json:"path"` + ComponentPath *string `json:"component_path"` + Icon *string `json:"icon"` + Sort *int64 `json:"sort"` + Status *int8 `json:"status"` + IsVisible *int8 `json:"is_visible"` + Views []int `json:"views"` + Platforms []string `json:"platforms"` // 展示端:["web"] ["app"] ["web","app"];空=继承所属模块 + Type *int8 `json:"type"` + Permission *string `json:"permission"` } func parseViews(raw *string) []int { @@ -86,7 +87,10 @@ func (c *BackendMenuController) GetBackendMenu() { _ = c.ServeJSON() return } - backendMenus := filterMenusByView(menus, 2) + // 先按 views 过滤租户端,再按展示端过滤出 Web 端可见菜单。 + // 菜单未单独配置 platforms 时继承所属模块,因此要传入全部模块。 + allModules, _ := services.ListAllModulesForScope() + backendMenus := services.FilterMenusByPlatformWithModules(filterMenusByView(menus, 2), allModules, models.PlatformWeb) // 按当前登录用户角色过滤可见功能菜单: // 角色 rights 为空(或未分配角色)→ 全权限,返回全部后端菜单; @@ -375,6 +379,7 @@ type menuNode struct { Status int8 `json:"status"` IsVisible *int8 `json:"is_visible,omitempty"` Views []int `json:"views,omitempty"` + Platforms []string `json:"platforms,omitempty"` // 展示端,空=继承所属模块 Type int8 `json:"type"` Permission string `json:"permission,omitempty"` Locked bool `json:"locked,omitempty"` @@ -393,6 +398,7 @@ func buildMenuTree(menus []models.SystemMenu, pid int64) []*menuNode { Status: m.Status, IsVisible: m.IsVisible, Views: parseViews(m.Views), + Platforms: services.ParsePlatforms(m.Platforms), Type: m.Type, } if m.Path != nil { @@ -469,6 +475,8 @@ func (c *BackendMenuController) CreateMenu() { ComponentPath: ptrString(valueString(payload.ComponentPath, "")), Icon: ptrString(valueString(payload.Icon, "")), Permission: ptrString(valueString(payload.Permission, "")), + // 空串表示「继承所属模块的展示端」(模块也未配置则全端) + Platforms: ptrString(services.NormalizePlatforms(payload.Platforms)), } id, err := models.Orm.Insert(&menu) @@ -510,6 +518,7 @@ func (c *BackendMenuController) UpdateMenu() { "status": valueInt8(payload.Status, 1), "is_visible": valueInt8(payload.IsVisible, 1), "views": string(viewsBytes), + "platforms": services.NormalizePlatforms(payload.Platforms), "type": valueInt8(payload.Type, 1), "permission": valueString(payload.Permission, ""), } diff --git a/go/controllers/backend_modules.go b/go/controllers/backend_modules.go index 54c6cf7..a423a17 100644 --- a/go/controllers/backend_modules.go +++ b/go/controllers/backend_modules.go @@ -75,6 +75,8 @@ func (c *BackendModulesController) GetTenantList() { c.jsonErr(500, 500, "获取失败:"+err.Error()) return } + // 租户后台是 Web 端:只保留 web 可见的模块(未配置视为全端) + rows = services.FilterModulesByPlatform(rows, models.PlatformWeb) // 查询当前租户已开通的模块 code 集合(套餐模块 + 已开通产品订单关联模块,编码已规范化) tid := uint64(claims.TenantId) diff --git a/go/controllers/platform_modules.go b/go/controllers/platform_modules.go index 6b5c684..f837a12 100644 --- a/go/controllers/platform_modules.go +++ b/go/controllers/platform_modules.go @@ -10,10 +10,39 @@ import ( "server/models" "server/pkg/jwtutil" + "server/services" beego "github.com/beego/beego/v2/server/web" ) +// validatePlatforms 校验展示端取值,返回空串表示合法。 +// 只允许 web / app,避免出现拼错的值导致菜单在某端永远不可见。 +func validatePlatforms(list []string) string { + for _, s := range list { + switch strings.ToLower(strings.TrimSpace(s)) { + case models.PlatformWeb, models.PlatformApp: + default: + if strings.TrimSpace(s) == "" { + continue + } + return "展示端取值非法,仅支持 web(Web端)与 app(移动端)" + } + } + return "" +} + +// platformList 把模块展示端配置输出给管理端:统一返回数组,空配置回落为全端 +func platformList(m *models.SystemModules) []string { + if m == nil { + return []string{models.PlatformWeb, models.PlatformApp} + } + list := services.ParsePlatforms(m.Platforms) + if len(list) == 0 { + return []string{models.PlatformWeb, models.PlatformApp} + } + return list +} + // PlatformModulesController 模块管理(yz_system_modules) type PlatformModulesController struct { beego.Controller @@ -70,12 +99,30 @@ func (c *PlatformModulesController) GetList() { c.jsonErr(500, 500, "获取失败:"+err.Error()) return } + // 展示端统一以数组形式下发(空配置回落为全端),方便管理端回填多选框 + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + list = append(list, map[string]interface{}{ + "id": rows[i].ID, + "mid": rows[i].Mid, + "name": rows[i].Name, + "code": rows[i].Code, + "path": rows[i].Path, + "icon": rows[i].Icon, + "description": rows[i].Description, + "type": rows[i].Type, + "sort": rows[i].Sort, + "status": rows[i].Status, + "is_show": rows[i].IsShow, + "platforms": platformList(&rows[i]), + }) + } c.Data["json"] = map[string]interface{}{ "code": 200, "msg": "获取成功", "data": map[string]interface{}{ - "list": rows, - "total": len(rows), + "list": list, + "total": len(list), }, } _ = c.ServeJSON() @@ -136,16 +183,17 @@ func (c *PlatformModulesController) GetDetail() { } type modulePayload struct { - Mid *uint64 `json:"mid"` - Name string `json:"name"` - Code string `json:"code"` - Path string `json:"path"` - Icon string `json:"icon"` - Description string `json:"description"` - Type int `json:"type"` - Sort int `json:"sort"` - Status int8 `json:"status"` - IsShow int8 `json:"is_show"` + Mid *uint64 `json:"mid"` + Name string `json:"name"` + Code string `json:"code"` + Path string `json:"path"` + Icon string `json:"icon"` + Description string `json:"description"` + Type int `json:"type"` + Sort int `json:"sort"` + Status int8 `json:"status"` + IsShow int8 `json:"is_show"` + Platforms []string `json:"platforms"` // 展示端:["web"] ["app"] ["web","app"];空=全端 } // Add POST /platform/modules @@ -164,6 +212,10 @@ func (c *PlatformModulesController) Add() { c.jsonErr(400, 400, "参数错误") return } + if errMsg := validatePlatforms(p.Platforms); errMsg != "" { + c.jsonErr(400, 400, errMsg) + return + } p.Name = strings.TrimSpace(p.Name) p.Code = strings.TrimSpace(p.Code) if p.Name == "" || p.Code == "" { @@ -180,6 +232,7 @@ func (c *PlatformModulesController) Add() { return } now := time.Now() + platforms := services.NormalizePlatforms(p.Platforms) row := &models.SystemModules{ Mid: p.Mid, Name: p.Name, @@ -194,6 +247,9 @@ func (c *PlatformModulesController) Add() { CreateTime: &now, UpdateTime: &now, } + if platforms != "" { + row.Platforms = &platforms + } id, err := models.Orm.Insert(row) if err != nil { c.jsonErr(500, 500, "添加失败:"+err.Error()) @@ -231,6 +287,10 @@ func (c *PlatformModulesController) Edit() { c.jsonErr(400, 400, "模块名称和编码不能为空") return } + if errMsg := validatePlatforms(p.Platforms); errMsg != "" { + c.jsonErr(400, 400, errMsg) + return + } // code 唯一(排除自身与软删) cnt, _ := models.Orm.QueryTable(new(models.SystemModules)). Filter("code", p.Code). @@ -242,6 +302,7 @@ func (c *PlatformModulesController) Edit() { return } now := time.Now() + // 展示端为空串表示「全端」,需显式落库以覆盖历史值 up := map[string]interface{}{ "mid": p.Mid, "name": p.Name, @@ -253,6 +314,7 @@ func (c *PlatformModulesController) Edit() { "sort": p.Sort, "status": p.Status, "is_show": p.IsShow, + "platforms": services.NormalizePlatforms(p.Platforms), "update_time": now, } n, err := models.Orm.QueryTable(new(models.SystemModules)). diff --git a/go/controllers/platform_wechat_mini.go b/go/controllers/platform_wechat_mini.go new file mode 100644 index 0000000..293030f --- /dev/null +++ b/go/controllers/platform_wechat_mini.go @@ -0,0 +1,131 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "server/pkg/jwtutil" + "server/services/wechatmp" + "server/services/wechatmini" + + beego "github.com/beego/beego/v2/server/web" +) + +// ============================================================= +// 平台端「微信小程序」配置:AppID / AppSecret / 启用开关 / 自动绑定 +// +// 配置就绪后,uniapp 小程序端的登录页才会展示「微信手机号一键登录」入口 +// (GET /auth/wechat/miniapp/config 返回 enabled)。 +// AppSecret 掩码回显,提交空串或掩码表示不修改。 +// ============================================================= + +// PlatformWechatMiniController 平台端微信小程序配置 +type PlatformWechatMiniController struct { + beego.Controller +} + +func (c *PlatformWechatMiniController) claims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "platform" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *PlatformWechatMiniController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *PlatformWechatMiniController) ok(data interface{}, msg string) { + if msg == "" { + msg = "success" + } + resp := map[string]interface{}{"code": 200, "msg": msg} + if data != nil { + resp["data"] = data + } + c.Data["json"] = resp + _ = c.ServeJSON() +} + +// GetConfig GET /platform/wechatMini/config +func (c *PlatformWechatMiniController) GetConfig() { + if _, err := c.claims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + data := map[string]interface{}{ + "app_id": "", + "app_secret": "", + "enabled": 0, + "auto_bind": 1, + "remark": "", + "configured": false, + } + if cfg, err := wechatmini.LoadConfig(); err == nil { + data["app_id"] = cfg.AppID + data["app_secret"] = wechatmp.MaskSecret(cfg.AppSecret) + data["remark"] = cfg.Remark + data["configured"] = cfg.AppID != "" && cfg.AppSecret != "" + if cfg.Enabled { + data["enabled"] = 1 + } + if cfg.AutoBind { + data["auto_bind"] = 1 + } else { + data["auto_bind"] = 0 + } + } + c.ok(data, "获取成功") +} + +// SaveConfig POST /platform/wechatMini/config +func (c *PlatformWechatMiniController) SaveConfig() { + if _, err := c.claims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + var p struct { + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` + Enabled int `json:"enabled"` + AutoBind int `json:"auto_bind"` + Remark string `json:"remark"` + } + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + if err := wechatmini.SaveConfig(wechatmini.SaveInput{ + AppID: p.AppID, + AppSecret: p.AppSecret, + Enabled: p.Enabled == 1, + AutoBind: p.AutoBind == 1, + Remark: p.Remark, + }); err != nil { + c.jsonErr(400, 400, err.Error()) + return + } + c.ok(nil, "保存成功") +} diff --git a/go/docs/sql/add_module_platforms.sql b/go/docs/sql/add_module_platforms.sql new file mode 100644 index 0000000..4cd55a0 --- /dev/null +++ b/go/docs/sql/add_module_platforms.sql @@ -0,0 +1,89 @@ +-- ============================================================= +-- 模块 / 菜单「展示端(platforms)」 +-- +-- 需求:后台管理模块层面需要能判断一个模块(及其菜单) +-- 是 Web 端显示、小程序显示,还是两端都显示。 +-- +-- 与已有的 views 字段是两个独立维度,不要混淆: +-- views —— [1]平台端 / [2]租户端(决定出现在哪个后台) +-- platforms —— ["web"] / ["app"] / ["web","app"](决定 Web 还是移动端可见) +-- +-- 取值约定: +-- ["web"] 仅 Web 端(租户管理后台)可见 +-- ["app"] 仅移动端(uniapp APP / 微信小程序)可见 +-- ["web","app"] 两端都可见 +-- '' 或 NULL 全端可见(默认,保证历史数据行为不变) +-- +-- 继承规则: +-- 菜单自身配置了 platforms → 以自身为准 +-- 菜单未配置 → 继承所属模块的 platforms(按 path 前缀归属) +-- 两者都未配置 → 全端可见 +-- +-- 本脚本可重复执行:MySQL 不支持 ADD COLUMN IF NOT EXISTS, +-- 故先用 information_schema 判断,再由动态 SQL 决定加不加列。 +-- 若已存在则只更新注释,不会报 1060 Duplicate column name。 +-- ============================================================= + +-- ---------- yz_system_modules ---------- +SET @db := DATABASE(); +SET @tbl := 'yz_system_modules'; +SET @col := 'platforms'; +SET @exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = @tbl AND COLUMN_NAME = @col +); +SET @sql := IF(@exists = 0, + CONCAT('ALTER TABLE `', @tbl, '` ADD COLUMN `', @col, + '` VARCHAR(64) NULL DEFAULT NULL COMMENT ''展示端:web/app JSON数组;空或NULL=全端'' AFTER `is_show`'), + CONCAT('ALTER TABLE `', @tbl, '` MODIFY COLUMN `', @col, + '` VARCHAR(64) NULL DEFAULT NULL COMMENT ''展示端:web/app JSON数组;空或NULL=全端''') +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------- yz_system_menu ---------- +SET @tbl := 'yz_system_menu'; +SET @exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = @tbl AND COLUMN_NAME = @col +); +SET @sql := IF(@exists = 0, + CONCAT('ALTER TABLE `', @tbl, '` ADD COLUMN `', @col, + '` VARCHAR(64) NULL DEFAULT NULL COMMENT ''展示端:web/app JSON数组;空或NULL=继承所属模块'' AFTER `views`'), + CONCAT('ALTER TABLE `', @tbl, '` MODIFY COLUMN `', @col, + '` VARCHAR(64) NULL DEFAULT NULL COMMENT ''展示端:web/app JSON数组;空或NULL=继承所属模块''') +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ---------- 校验 ---------- +SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() AND COLUMN_NAME = 'platforms' + AND TABLE_NAME IN ('yz_system_modules', 'yz_system_menu'); + +-- ============================================================= +-- 使用示例(按需执行) +-- 注意:MySQL 5.7+ 可直接用 JSON_ARRAY('web') 生成 '["web"]', +-- 比手写转义双引号更省事。 +-- ============================================================= + +-- 例1:把「客户关系管理」设为仅 Web 端可见(移动端不显示该子系统卡片) +-- UPDATE yz_system_modules SET platforms = JSON_ARRAY('web') WHERE code = 'crm'; + +-- 例2:把「便捷工具」设为仅移动端可见(Web 端后台不再展示) +-- UPDATE yz_system_modules SET platforms = JSON_ARRAY('app') WHERE code = 'bianjiegongju'; + +-- 例3:某功能两端都要 +-- UPDATE yz_system_modules SET platforms = JSON_ARRAY('web','app') WHERE code = 'cms'; + +-- 例4:某个具体功能只在小程序上用(如扫码打卡),单独给菜单配置 +-- UPDATE yz_system_menu SET platforms = JSON_ARRAY('app') WHERE path = '/tools/notebook'; + +-- 例5:恢复全端可见(清空即可,NULL 与 '' 都表示全端) +-- UPDATE yz_system_modules SET platforms = NULL WHERE code = 'crm'; +-- UPDATE yz_system_modules SET platforms = NULL; -- 全部恢复 + +-- ============================================================= +-- 回滚 +-- ============================================================= +-- ALTER TABLE yz_system_modules DROP COLUMN platforms; +-- ALTER TABLE yz_system_menu DROP COLUMN platforms; diff --git a/go/docs/sql/create_auth_app_login_ticket.sql b/go/docs/sql/create_auth_app_login_ticket.sql new file mode 100644 index 0000000..50a4733 --- /dev/null +++ b/go/docs/sql/create_auth_app_login_ticket.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS `yz_auth_app_login_ticket` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + `ticket` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '一次性登录票据', + `identity_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '认证中心身份ID', + `tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID(0=待选企业)', + `client_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '兑换方应用ID,默认 yz-backend', + `expire_at` DATETIME NOT NULL COMMENT '过期时间', + `used_at` DATETIME NULL DEFAULT NULL COMMENT '核销时间(一次性)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ticket` (`ticket`), + KEY `idx_expire_at` (`expire_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一认证-移动端免登录票据'; diff --git a/go/docs/sql/create_auth_tables.sql b/go/docs/sql/create_auth_tables.sql index c7b0c01..0eea0f9 100644 --- a/go/docs/sql/create_auth_tables.sql +++ b/go/docs/sql/create_auth_tables.sql @@ -254,5 +254,6 @@ INSERT INTO `yz_auth_client` VALUES ('yz-backend', 'backend', '租户管理后台', 2, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant'), ('yz-uniapp', 'uniapp', '移动端APP', 3, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant'), + ('yz-miniapp', 'miniapp', '微信小程序', 4, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant'), ('yz-website', 'website', '企业官网', 2, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant') ON DUPLICATE KEY UPDATE `name` = VALUES(`name`); diff --git a/go/docs/sql/create_wechat_mini_config.sql b/go/docs/sql/create_wechat_mini_config.sql new file mode 100644 index 0000000..fdc8e3e --- /dev/null +++ b/go/docs/sql/create_wechat_mini_config.sql @@ -0,0 +1,42 @@ +-- ============================================================= +-- 微信小程序配置(统一认证 · 小程序一键登录) +-- +-- 小程序与公众号是两套独立应用,AppID / AppSecret 不可混用, +-- 因此单独建表。app_secret 以 AES-256-GCM 密文存储(YZW1: 前缀)。 +-- +-- 关联链路: +-- uniapp 小程序 → wx.login / getPhoneNumber +-- → POST /auth/wechat/miniapp/login +-- → jscode2session 换 openid → 按手机号定位 yz_auth_identity +-- → 写入 yz_auth_identity_third(provider='wechat_mini') +-- → 复用统一认证的会话与令牌(yz_auth_session / yz_auth_refresh_token) +-- ============================================================= + +CREATE TABLE IF NOT EXISTS `yz_platform_wechat_mini_config` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + `app_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '小程序 AppID', + `app_secret` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '小程序 AppSecret(AES 密文,不回显)', + `enabled` TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用小程序登录:0否 1是', + `auto_bind` TINYINT NOT NULL DEFAULT 1 COMMENT '手机号验证通过后是否自动绑定小程序:0否 1是', + `remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NULL DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信小程序配置'; + +-- ------------------------------------------------------------- +-- 接入应用:微信小程序(app_type=4) +-- 小程序不走浏览器跳转,只用 POST /auth/wechat/miniapp/login 换令牌, +-- 因此无需登记 redirect_uris。 +-- ------------------------------------------------------------- +INSERT INTO `yz_auth_client` + (`client_id`, `app_code`, `name`, `app_type`, `grant_types`, `scope`, `access_ttl`, `refresh_ttl`, `realm`) +VALUES + ('yz-miniapp', 'miniapp', '微信小程序', 4, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant') + ON DUPLICATE KEY UPDATE `name` = VALUES(`name`); + +-- ------------------------------------------------------------- +-- 第三方身份源:允许 yz_auth_identity_third.provider 出现 'wechat_mini' +-- (表结构本身为 varchar,无需 DDL;此处仅作说明) +-- provider = 'wechat_mini',open_id = 小程序 openid,union_id = 小程序 unionid +-- ------------------------------------------------------------- diff --git a/go/models/auth_app_ticket.go b/go/models/auth_app_ticket.go new file mode 100644 index 0000000..80d799a --- /dev/null +++ b/go/models/auth_app_ticket.go @@ -0,0 +1,26 @@ +package models + +import "time" + +// AuthAppLoginTicket 移动端免登录票据:yz_auth_app_login_ticket +// +// 用途:移动端(uniapp APP / 小程序)已用统一认证登录后,打开 Web 端业务页面时 +// 通过本票据把登录态传递过去,避免 webview 里再次弹出统一登录页。 +// +// 流程(与公众号一次性登录令牌 yz_platform_wechat_mp_login_token 同构): +// POST /auth/app-ticket 移动端带 Bearer 令牌 → 签发一次性票据 +// POST /auth/app-ticket/exchange Web 端带票据 → 核销并换发 Web 端令牌 +// +// 票据一次性、短时效(默认 60 秒),核销用 CAS 防重放。 +type AuthAppLoginTicket struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Ticket string `orm:"column(ticket);size(64)" json:"ticket"` + IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"` + Tid uint64 `orm:"column(tid)" json:"tid"` + ClientID string `orm:"column(client_id);size(64);default('')" json:"client_id"` + ExpireAt time.Time `orm:"column(expire_at);type(datetime)" json:"expire_at"` + UsedAt *time.Time `orm:"column(used_at);type(datetime);null" json:"used_at"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` +} + +func (m *AuthAppLoginTicket) TableName() string { return "yz_auth_app_login_ticket" } diff --git a/go/models/init.go b/go/models/init.go index 84ae0a2..6300f75 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -200,6 +200,9 @@ func Init(_ string) { new(WechatMpLoginToken), new(WechatMpScanLogin), + // 微信小程序配置(docs/sql/create_wechat_mini_config.sql) + new(WechatMiniConfig), + // 统一认证中心(UAC):docs/sql/create_auth_tables.sql,人工执行建表 new(AuthIdentity), new(AuthIdentityThird), @@ -212,6 +215,7 @@ func Init(_ string) { new(AuthTenantIdp), new(AuthTenantAuthConfig), new(AuthLoginLog), + new(AuthAppLoginTicket), // 数据字典(docs/sql/create_dict.sql):平台端 / 租户端各一套 new(PlatformDict), diff --git a/go/models/system_menu.go b/go/models/system_menu.go index 2902cda..3effe89 100644 --- a/go/models/system_menu.go +++ b/go/models/system_menu.go @@ -2,6 +2,16 @@ package models import "time" +// 展示端(platforms 字段取值) +// +// 与 views(平台端 / 租户端)是两个独立维度: +// views —— 决定菜单出现在「平台后台」还是「租户后台」 +// platforms —— 决定菜单/模块出现在 Web 端还是移动端(APP / 小程序) +const ( + PlatformWeb = "web" // Web 端:租户管理后台(PC 浏览器) + PlatformApp = "app" // 移动端:uniapp APP / 微信小程序 +) + // SystemMenu 系统菜单表 yz_system_menu type SystemMenu struct { ID uint64 `orm:"column(id);pk;auto" json:"id"` // 菜单ID @@ -14,6 +24,7 @@ type SystemMenu struct { Status int8 `orm:"column(status);default(0)" json:"status"` // 状态:1-启用,0-禁用 IsVisible *int8 `orm:"column(is_visible);null" json:"isVisible"` // 是否显示:1-显示 0-不显示 Views *string `orm:"column(views);size(255);null" json:"views"` // 菜单显示端(JSON数组字符串):[1]=平台端 [2]=租户端 [1,2]=双端 + Platforms *string `orm:"column(platforms);size(64);null" json:"platforms"` // 展示端(JSON数组字符串):["web"] ["app"] ["web","app"];空/NULL=全端 Type int8 `orm:"column(type)" json:"type"` // 菜单类型:1-目录,2-页面,3-接口 Permission *string `orm:"column(permission);size(100);null" json:"permission"` // 权限标识(按钮类型时填写) Creater *string `orm:"column(creater);size(50);null" json:"creater"` // 创建者 diff --git a/go/models/system_modules.go b/go/models/system_modules.go index 1f067c0..92c3759 100644 --- a/go/models/system_modules.go +++ b/go/models/system_modules.go @@ -15,6 +15,9 @@ type SystemModules struct { Sort int `orm:"column(sort);default(0)" json:"sort"` Status int8 `orm:"column(status);default(1)" json:"status"` // 0禁用 1启用 IsShow int8 `orm:"column(is_show);default(1)" json:"is_show"` // 0否 1是 + // 展示端(JSON 数组字符串):["web"] ["app"] ["web","app"];空/NULL=全端显示。 + // 取值见 PlatformWeb / PlatformApp。菜单未单独配置时继承所属模块的展示端。 + Platforms *string `orm:"column(platforms);size(64);null" json:"platforms"` CreateTime *time.Time `orm:"column(create_time);type(datetime);null" json:"create_time"` UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` diff --git a/go/models/wechat_mini.go b/go/models/wechat_mini.go new file mode 100644 index 0000000..047ca2e --- /dev/null +++ b/go/models/wechat_mini.go @@ -0,0 +1,24 @@ +package models + +import "time" + +// ============================================================= +// 微信小程序配置(表结构见 docs/sql/create_wechat_mini_config.sql) +// +// 小程序与公众号是两套独立应用,AppID / AppSecret 不可混用, +// 因此单独建表;AppSecret 以 AES-256-GCM 加密存储(复用 wechatmp 的密钥)。 +// ============================================================= + +// WechatMiniConfig 微信小程序配置 yz_platform_wechat_mini_config(单行) +type WechatMiniConfig struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + AppID string `orm:"column(app_id);size(64)" json:"app_id"` + AppSecret string `orm:"column(app_secret);size(512)" json:"-"` // 加密存储,不回显 + Enabled int8 `orm:"column(enabled);default(0)" json:"enabled"` + AutoBind int8 `orm:"column(auto_bind);default(1)" json:"auto_bind"` // 1:手机号命中身份后自动绑定小程序 + Remark string `orm:"column(remark);size(255)" json:"remark"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` +} + +func (m *WechatMiniConfig) TableName() string { return "yz_platform_wechat_mini_config" } diff --git a/go/routers/app/app.go b/go/routers/app/app.go index 2a8048e..46e56d9 100644 --- a/go/routers/app/app.go +++ b/go/routers/app/app.go @@ -45,4 +45,7 @@ func Register() { // 活动日志 beego.Router("/app/activity/list", &controllers.AppActivityController{}, "get:GetList") + + // 工作台聚合(ERP/OA/CRM/CMS 应用门户首屏数据) + beego.Router("/app/home", &controllers.AppAggregateController{}, "get:Home") } diff --git a/go/routers/auth/auth.go b/go/routers/auth/auth.go index c051a6a..6bf0478 100644 --- a/go/routers/auth/auth.go +++ b/go/routers/auth/auth.go @@ -48,4 +48,12 @@ func Register() { // 微信扫码登录(未认证公众号:服务号二维码 + 登录码 + 公众号内确认链接) beego.Router("/auth/wechat/scan/start", &authctl.AuthLoginController{}, "post:WechatScanStart") beego.Router("/auth/wechat/scan/status", &authctl.AuthLoginController{}, "get:WechatScanStatus") + + // 微信小程序一键登录(wx.login code + getPhoneNumber 动态 code 换登录态) + beego.Router("/auth/wechat/miniapp/config", &authctl.AuthWechatMiniController{}, "get:Config") + beego.Router("/auth/wechat/miniapp/login", &authctl.AuthWechatMiniController{}, "post:Login") + + // 移动端免登录票据:把移动端登录态桥接到 Web 端,避免 webview 内二次登录 + beego.Router("/auth/app-ticket", &authctl.AuthAppTicketController{}, "post:Create") + beego.Router("/auth/app-ticket/exchange", &authctl.AuthAppTicketController{}, "post:Exchange") } diff --git a/go/routers/platform/wechat_mp.go b/go/routers/platform/wechat_mp.go index a542c1f..21326c4 100644 --- a/go/routers/platform/wechat_mp.go +++ b/go/routers/platform/wechat_mp.go @@ -38,4 +38,7 @@ func RegisterWechatMpRoutes() { // 消息推送(认证后可用:提醒 / 公告) beego.Router("/platform/wechatMp/push", &controllers.PlatformWechatMpController{}, "post:Push") beego.Router("/platform/wechatMp/push/test", &controllers.PlatformWechatMpController{}, "post:PushTest") + + // 小程序配置(一键登录开关;登录端点在认证中心 /auth/wechat/miniapp/*) + beego.Router("/platform/wechatMini/config", &controllers.PlatformWechatMiniController{}, "get:GetConfig;post:SaveConfig") } diff --git a/go/services/auth/app_ticket.go b/go/services/auth/app_ticket.go new file mode 100644 index 0000000..eff00c4 --- /dev/null +++ b/go/services/auth/app_ticket.go @@ -0,0 +1,97 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "strings" + "time" + + "server/models" +) + +// ============================================================= +// 移动端免登录票据 +// +// 背景:移动端用「密码直连」拿到的是 client_id=yz-uniapp、user_type=app 的令牌, +// Web 端(管理后台)只认自己 localStorage 里 client_id=yz-backend 的令牌, +// 两者登录态不共享,导致移动端打开 webview 时又要走一遍统一认证登录。 +// +// 方案:移动端令牌 → 一次性票据 → Web 端令牌。 +// 票据本身是一次性的短时效凭证,即使泄露也无法二次使用。 +// ============================================================= + +// AppTicketTTL 票据有效期。移动端拿到后应立即跳转,不需要长时效。 +const AppTicketTTL = 60 * time.Second + +var ( + ErrAppTicketInvalid = errors.New("登录票据无效") + ErrAppTicketUsed = errors.New("登录票据已被使用") + ErrAppTicketExpired = errors.New("登录票据已过期") +) + +// CreateAppTicket 签发一次性票据 +func CreateAppTicket(identityID, tid uint64, clientID string) (string, error) { + if identityID == 0 { + return "", errors.New("身份无效") + } + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + ticket := hex.EncodeToString(b) + if clientID == "" { + clientID = "yz-backend" + } + row := &models.AuthAppLoginTicket{ + Ticket: ticket, + IdentityID: identityID, + Tid: tid, + ClientID: clientID, + ExpireAt: time.Now().Add(AppTicketTTL), + } + if _, err := models.Orm.Insert(row); err != nil { + return "", err + } + return ticket, nil +} + +// ConsumeAppTicket 核销一次性票据(CAS 防重放),返回票据携带的身份与租户。 +func ConsumeAppTicket(ticket string) (*models.AuthAppLoginTicket, error) { + ticket = strings.TrimSpace(ticket) + if ticket == "" { + return nil, ErrAppTicketInvalid + } + var row models.AuthAppLoginTicket + if err := models.Orm.QueryTable(new(models.AuthAppLoginTicket)). + Filter("ticket", ticket).One(&row); err != nil { + return nil, ErrAppTicketInvalid + } + if row.UsedAt != nil { + return nil, ErrAppTicketUsed + } + if row.ExpireAt.Before(time.Now()) { + return nil, ErrAppTicketExpired + } + + now := time.Now() + affected, err := models.Orm.QueryTable(new(models.AuthAppLoginTicket)). + Filter("id", row.ID). + Filter("used_at__isnull", true). + Update(map[string]interface{}{"used_at": now}) + if err != nil { + return nil, err + } + if affected == 0 { + return nil, ErrAppTicketUsed + } + row.UsedAt = &now + return &row, nil +} + +// PurgeExpiredAppTickets 清理过期票据(防止表无限增长,由调用方定期触发) +func PurgeExpiredAppTickets() (int64, error) { + return models.Orm.QueryTable(new(models.AuthAppLoginTicket)). + Filter("expire_at__lt", time.Now().Add(-24*time.Hour)). + Delete() +} diff --git a/go/services/auth/token.go b/go/services/auth/token.go index 884086e..ca64fb9 100644 --- a/go/services/auth/token.go +++ b/go/services/auth/token.go @@ -70,7 +70,7 @@ func IssueTokens(opt TokenIssue) (*TokenPair, error) { userType := opt.UserType if userType == "" { userType = "backend" - if opt.ClientID == "yz-uniapp" { + if opt.ClientID == "yz-uniapp" || opt.ClientID == "yz-miniapp" { userType = "app" } } diff --git a/go/services/display_scope.go b/go/services/display_scope.go new file mode 100644 index 0000000..867a151 --- /dev/null +++ b/go/services/display_scope.go @@ -0,0 +1,154 @@ +// Package services 中本文件负责「展示端(platforms)」维度的读写与过滤。 +// +// 背景:模块与菜单原先只有 views([1]平台端 / [2]租户端)一个维度, +// 无法表达「这个子系统/功能只在 Web 端出现,还是只在小程序出现,还是两端都出现」。 +// 这里新增 platforms 维度: +// ["web"] —— 仅在 Web 端(租户管理后台)展示 +// ["app"] —— 仅在移动端(uniapp APP / 微信小程序)展示 +// ["web","app"] —— 两端都展示 +// 空串 / NULL —— 视为全端展示(保证历史数据行为不变) +package services + +import ( + "encoding/json" + "strings" + + "server/models" +) + +// ParsePlatforms 解析展示端配置(JSON 数组字符串)。 +// 兼容非数组的旧值(如单个字符串),解析失败一律返回空切片。 +func ParsePlatforms(raw *string) []string { + if raw == nil || strings.TrimSpace(*raw) == "" { + return nil + } + var arr []string + if err := json.Unmarshal([]byte(*raw), &arr); err == nil { + out := make([]string, 0, len(arr)) + for _, s := range arr { + s = strings.ToLower(strings.TrimSpace(s)) + if s != "" { + out = append(out, s) + } + } + return out + } + // 非数组格式:按单个值处理 + v := strings.ToLower(strings.TrimSpace(*raw)) + if v == "" { + return nil + } + return []string{v} +} + +// HasPlatform 判断是否在指定端展示。 +// 未配置(空)视为全端展示,保证历史数据与新建记录默认两端可见。 +func HasPlatform(list []string, platform string) bool { + if len(list) == 0 { + return true + } + p := strings.ToLower(strings.TrimSpace(platform)) + for _, s := range list { + if strings.ToLower(strings.TrimSpace(s)) == p { + return true + } + } + return false +} + +// NormalizePlatforms 规范化并去重展示端配置,返回待落库的 JSON 字符串。 +// 空输入返回空串(表示全端),便于前端「未配置 = 全端」的语义保持一致。 +func NormalizePlatforms(list []string) string { + set := map[string]bool{} + out := make([]string, 0, len(list)) + for _, s := range list { + v := strings.ToLower(strings.TrimSpace(s)) + if v == "" || set[v] { + continue + } + set[v] = true + out = append(out, v) + } + if len(out) == 0 { + return "" + } + if b, err := json.Marshal(out); err == nil { + return string(b) + } + return "" +} + +// ListAllModulesForScope 读取全部未删除模块(含停用与 type=2 系统设置)。 +// 展示端继承判定必须用全量模块:系统设置类模块默认不启用, +// 若只用启用模块,其菜单会落到"无归属"被误判为全端可见。 +func ListAllModulesForScope() ([]models.SystemModules, error) { + var rows []models.SystemModules + _, err := models.Orm.QueryTable(new(models.SystemModules)). + Filter("delete_time__isnull", true). + OrderBy("sort", "id"). + All(&rows) + return rows, err +} + +// FilterModulesByPlatform 按展示端过滤模块 +func FilterModulesByPlatform(rows []models.SystemModules, platform string) []models.SystemModules { + out := make([]models.SystemModules, 0, len(rows)) + for _, m := range rows { + if HasPlatform(ParsePlatforms(m.Platforms), platform) { + out = append(out, m) + } + } + return out +} + +// FilterMenusByPlatform 按展示端过滤菜单(只看菜单自身的 platforms,不做模块继承) +func FilterMenusByPlatform(rows []models.SystemMenu, platform string) []models.SystemMenu { + out := make([]models.SystemMenu, 0, len(rows)) + for _, m := range rows { + if HasPlatform(ParsePlatforms(m.Platforms), platform) { + out = append(out, m) + } + } + return out +} + +// SortedModulesForScope 把模块按 path 长度倒序,供 MenuVisibleOn 的归属判定复用。 +func SortedModulesForScope(modules []models.SystemModules) []models.SystemModules { + return sortModulesByPathDesc(modules) +} + +// MenuVisibleOn 判断单条菜单是否在指定端展示。 +// 判定顺序:菜单自身 platforms → 继承所属模块 platforms(按 path 前缀归属)→ 全端可见。 +// 这样后台只需在模块上配一次「仅 Web / 仅小程序」,其下未单独配置的菜单自动跟随。 +func MenuVisibleOn(m models.SystemMenu, sortedModules []models.SystemModules, allModules []models.SystemModules, platform string) bool { + if own := ParsePlatforms(m.Platforms); len(own) > 0 { + return HasPlatform(own, platform) + } + path := "" + if m.Path != nil { + path = strings.TrimSpace(*m.Path) + } + code, ok := ownerModuleCode(sortedModules, path) + if !ok { + return true // 不属于任何模块 → 全端 + } + for _, mod := range allModules { + if NormalizeModuleCode(mod.Code) == NormalizeModuleCode(code) { + return HasPlatform(ParsePlatforms(mod.Platforms), platform) + } + } + return true +} + +// FilterMenusByPlatformWithModules 按展示端过滤菜单,并支持继承所属模块的展示端。 +// modules 应传入全部模块(含停用与 type=2 系统设置),否则系统设置类菜单会误判为全端。 +func FilterMenusByPlatformWithModules(rows []models.SystemMenu, modules []models.SystemModules, platform string) []models.SystemMenu { + sorted := sortModulesByPathDesc(modules) + out := make([]models.SystemMenu, 0, len(rows)) + for _, m := range rows { + if MenuVisibleOn(m, sorted, modules, platform) { + out = append(out, m) + } + } + return out +} diff --git a/go/services/display_scope_test.go b/go/services/display_scope_test.go new file mode 100644 index 0000000..e1be1c8 --- /dev/null +++ b/go/services/display_scope_test.go @@ -0,0 +1,91 @@ +package services + +import ( + "testing" + + "server/models" +) + +func strPtr(s string) *string { return &s } + +func TestParsePlatforms(t *testing.T) { + cases := []struct { + name string + raw *string + want int + }{ + {"nil 视为全端", nil, 0}, + {"空串视为全端", strPtr(""), 0}, + {"空格视为全端", strPtr(" "), 0}, + {"单值 web", strPtr(`["web"]`), 1}, + {"双值", strPtr(`["web","app"]`), 2}, + {"大小写归一化", strPtr(`["WEB"]`), 1}, + {"非法 JSON 退化为单值", strPtr("web"), 1}, + } + for _, c := range cases { + got := ParsePlatforms(c.raw) + if len(got) != c.want { + t.Errorf("%s: got %d items (%v), want %d", c.name, len(got), got, c.want) + } + } +} + +func TestHasPlatform(t *testing.T) { + if !HasPlatform(nil, models.PlatformWeb) || !HasPlatform(nil, models.PlatformApp) { + t.Error("未配置时应当全端可见") + } + web := []string{"web"} + if !HasPlatform(web, "web") { + t.Error("web 配置应在 web 端可见") + } + if HasPlatform(web, "app") { + t.Error("web 配置不应在 app 端可见") + } + both := []string{"web", "app"} + if !HasPlatform(both, "web") || !HasPlatform(both, "app") { + t.Error("双端配置应两端都可见") + } +} + +func TestNormalizePlatforms(t *testing.T) { + if got := NormalizePlatforms(nil); got != "" { + t.Errorf("空输入应返回空串(表示全端),got %q", got) + } + if got := NormalizePlatforms([]string{"WEB", " app ", "web"}); got != `["web","app"]` { + t.Errorf("应去重并小写归一化,got %q", got) + } +} + +// 模块级过滤:覆盖三种业务场景 +func TestFilterModulesByPlatform(t *testing.T) { + web := `["web"]` + app := `["app"]` + both := `["web","app"]` + rows := []models.SystemModules{ + {Code: "crm", Platforms: &web}, // 仅 Web + {Code: "oa", Platforms: &app}, // 仅小程序 + {Code: "cms", Platforms: &both}, // 双端 + {Code: "erp", Platforms: nil}, // 未配置 → 全端 + } + + got := FilterModulesByPlatform(rows, models.PlatformWeb) + if len(got) != 3 { + t.Errorf("Web 端应看到 crm/cms/erp 共 3 个,got %d", len(got)) + } + gotApp := FilterModulesByPlatform(rows, models.PlatformApp) + if len(gotApp) != 3 { + t.Errorf("移动端应看到 oa/cms/erp 共 3 个,got %d", len(gotApp)) + } +} + +func TestFilterMenusByPlatform(t *testing.T) { + app := `["app"]` + rows := []models.SystemMenu{ + {Title: "线索", Platforms: strPtr(`["web"]`)}, + {Title: "日程", Platforms: &app}, + {Title: "客户", Platforms: nil}, + } + if got := FilterMenusByPlatform(rows, models.PlatformApp); len(got) != 2 { + t.Errorf("移动端应看到 日程/客户 共 2 条,got %d", len(got)) + } +} diff --git a/go/services/wechatmini/api.go b/go/services/wechatmini/api.go new file mode 100644 index 0000000..7050e7e --- /dev/null +++ b/go/services/wechatmini/api.go @@ -0,0 +1,234 @@ +package wechatmini + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sync" + "time" +) + +// ============================================================= +// 微信小程序服务端 API +// 1. GET /sns/jscode2session code → openid / unionid / session_key +// 2. POST /wxa/business/getuserphonenumber phone_code → 手机号(需 access_token) +// ============================================================= + +const apiBase = "https://api.weixin.qq.com" + +var httpClient = &http.Client{Timeout: 10 * time.Second} + +// APIError 微信接口业务错误(HTTP 200 但 errcode 非 0) +type APIError struct { + API string + Code int + Msg string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("%s 失败:errcode=%d errmsg=%s", e.API, e.Code, e.Msg) +} + +// Explain 返回常见 errcode 的中文排查建议;未知错误码返回空串 +func (e *APIError) Explain() string { + switch e.Code { + case 40029: + return "code 无效或已过期:请重新调用 wx.login / uni.login 获取 code 后立即重试" + case 40163: + return "code 已被使用:同一次 code 只能兑换一次,请重新获取" + case 40013: + return "AppID 无效:请核对小程序后台「开发 → 开发管理」中的 AppID" + case 40125: + return "AppSecret 错误:请在小程序后台重置后复制新的 AppSecret 填入本页" + case 40226: + return "该小程序未开通手机号快速验证能力:请在「开发管理 → 接口设置」中开通" + case 45009: + return "接口调用频次超限:请稍后重试" + } + return "" +} + +// ExplainError 提取中文排查建议;无可用建议时返回 ("", false) +func ExplainError(err error) (string, bool) { + if ae, ok := err.(*APIError); ok { + if hint := ae.Explain(); hint != "" { + return hint, true + } + } + return "", false +} + +// ============================ code 换 openid ============================ + +// Session jscode2session 返回结果 +type Session struct { + OpenID string `json:"openid"` + UnionID string `json:"unionid"` + SessionKey string `json:"session_key"` +} + +// Code2Session 用 wx.login / uni.login 拿到的 code 换 openid(+ unionid) +func Code2Session(cfg *Config, code string) (*Session, error) { + if cfg == nil || cfg.AppID == "" || cfg.AppSecret == "" { + return nil, ErrNotConfigured + } + api := fmt.Sprintf("%s/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", + apiBase, url.QueryEscape(cfg.AppID), url.QueryEscape(cfg.AppSecret), url.QueryEscape(code)) + + var resp struct { + OpenID string `json:"openid"` + UnionID string `json:"unionid"` + SessionKey string `json:"session_key"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := httpGetJSON(api, &resp); err != nil { + return nil, err + } + if resp.OpenID == "" { + return nil, &APIError{API: "code 换取 openid", Code: resp.ErrCode, Msg: resp.ErrMsg} + } + return &Session{OpenID: resp.OpenID, UnionID: resp.UnionID, SessionKey: resp.SessionKey}, nil +} + +// ============================ 手机号快速验证 ============================ + +// PhoneNumber getuserphonenumber 返回结果 +type PhoneNumber struct { + PhoneNumber string `json:"phoneNumber"` + PurePhoneNumber string `json:"purePhoneNumber"` + CountryCode string `json:"countryCode"` +} + +// GetPhoneNumber 用 getPhoneNumber 事件返回的动态 code 换真实手机号。 +// 仅企业主体小程序可用;未开通该能力时微信返回 40226。 +func GetPhoneNumber(cfg *Config, phoneCode string) (*PhoneNumber, error) { + if cfg == nil || cfg.AppID == "" || cfg.AppSecret == "" { + return nil, ErrNotConfigured + } + token, err := getAccessToken(cfg) + if err != nil { + return nil, err + } + api := fmt.Sprintf("%s/wxa/business/getuserphonenumber?access_token=%s", apiBase, url.QueryEscape(token)) + payload := map[string]interface{}{"code": phoneCode} + + var resp struct { + PhoneInfo struct { + PhoneNumber string `json:"phoneNumber"` + PurePhoneNumber string `json:"purePhoneNumber"` + CountryCode string `json:"countryCode"` + } `json:"phone_info"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := httpPostJSON(api, payload, &resp); err != nil { + return nil, err + } + if resp.ErrCode != 0 || resp.PhoneInfo.PhoneNumber == "" { + return nil, &APIError{API: "获取手机号", Code: resp.ErrCode, Msg: resp.ErrMsg} + } + return &PhoneNumber{ + PhoneNumber: resp.PhoneInfo.PhoneNumber, + PurePhoneNumber: resp.PhoneInfo.PurePhoneNumber, + CountryCode: resp.PhoneInfo.CountryCode, + }, nil +} + +// ============================ access_token 缓存 ============================ + +type tokenEntry struct { + appID string + token string + expiresAt time.Time +} + +var ( + tokenMu sync.Mutex + tokenCache tokenEntry + tokenMinTTL = 5 * time.Minute +) + +// GetAccessToken 获取小程序 access_token(内存缓存,提前 5 分钟刷新) +func GetAccessToken(cfg *Config) (string, error) { return getAccessToken(cfg) } + +func getAccessToken(cfg *Config) (string, error) { + tokenMu.Lock() + if tokenCache.appID == cfg.AppID && tokenCache.token != "" && time.Now().Before(tokenCache.expiresAt) { + t := tokenCache.token + tokenMu.Unlock() + return t, nil + } + tokenMu.Unlock() + + api := fmt.Sprintf("%s/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s", + apiBase, url.QueryEscape(cfg.AppID), url.QueryEscape(cfg.AppSecret)) + var resp struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := httpGetJSON(api, &resp); err != nil { + return "", err + } + if resp.AccessToken == "" { + return "", &APIError{API: "获取 access_token", Code: resp.ErrCode, Msg: resp.ErrMsg} + } + ttl := time.Duration(resp.ExpiresIn) * time.Second + if ttl <= tokenMinTTL { + ttl = time.Hour + } + tokenMu.Lock() + tokenCache = tokenEntry{appID: cfg.AppID, token: resp.AccessToken, expiresAt: time.Now().Add(ttl - tokenMinTTL)} + tokenMu.Unlock() + return resp.AccessToken, nil +} + +// ============================ HTTP 辅助 ============================ + +func httpGetJSON(api string, out interface{}) error { + resp, err := httpClient.Get(api) + if err != nil { + return fmt.Errorf("请求微信接口失败: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("读取微信响应失败: %w", err) + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("解析微信响应失败: %w(body: %s)", err, truncate(string(body), 200)) + } + return nil +} + +func httpPostJSON(api string, payload interface{}, out interface{}) error { + bs, err := json.Marshal(payload) + if err != nil { + return err + } + resp, err := httpClient.Post(api, "application/json; charset=utf-8", bytes.NewReader(bs)) + if err != nil { + return fmt.Errorf("请求微信接口失败: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("读取微信响应失败: %w", err) + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("解析微信响应失败: %w(body: %s)", err, truncate(string(body), 200)) + } + return nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/go/services/wechatmini/config.go b/go/services/wechatmini/config.go new file mode 100644 index 0000000..e80e21c --- /dev/null +++ b/go/services/wechatmini/config.go @@ -0,0 +1,126 @@ +// Package wechatmini 微信小程序服务端能力:配置、code 换 openid、手机号快速验证。 +// +// 与 services/wechatmp(公众号)分开:小程序走 sns/jscode2session 与 +// wxa/business/getuserphonenumber,两者的 AppID / AppSecret 互不相通。 +package wechatmini + +import ( + "errors" + "strings" + "time" + + "github.com/beego/beego/v2/client/orm" + "server/models" + "server/services/wechatmp" +) + +// Config 小程序运行配置(敏感字段已解密) +type Config struct { + ID uint64 + AppID string + AppSecret string // 明文 + Enabled bool + AutoBind bool + Remark string +} + +// ErrNotConfigured 未配置或未启用 +var ErrNotConfigured = errors.New("微信小程序未配置或未启用") + +// LoadConfig 读取小程序配置(单行,敏感字段解密);未配置返回 ErrNotConfigured +func LoadConfig() (*Config, error) { + var row models.WechatMiniConfig + err := models.Orm.QueryTable(new(models.WechatMiniConfig)).OrderBy("id").One(&row) + if err != nil { + if err == orm.ErrNoRows { + return nil, ErrNotConfigured + } + return nil, err + } + cfg := &Config{ + ID: row.ID, + AppID: strings.TrimSpace(row.AppID), + Enabled: row.Enabled == 1, + AutoBind: row.AutoBind != 0, + Remark: row.Remark, + } + if row.AppSecret != "" { + plain, derr := wechatmp.DecryptSecret(row.AppSecret) + if derr != nil { + return nil, derr + } + cfg.AppSecret = plain + } + return cfg, nil +} + +// LoadEnabledConfig 读取并校验启用状态 +func LoadEnabledConfig() (*Config, error) { + cfg, err := LoadConfig() + if err != nil { + return nil, err + } + if !cfg.Enabled || cfg.AppID == "" || cfg.AppSecret == "" { + return nil, ErrNotConfigured + } + return cfg, nil +} + +// SaveInput 保存配置入参(AppSecret 支持掩码表示不修改) +type SaveInput struct { + AppID string + AppSecret string // 掩码 / 空串 = 保持不变 + Enabled bool + AutoBind bool + Remark string +} + +// SaveConfig 保存小程序配置(不存在则创建) +func SaveConfig(in SaveInput) error { + if strings.TrimSpace(in.AppID) == "" { + return errors.New("AppID 不能为空") + } + + var row models.WechatMiniConfig + err := models.Orm.QueryTable(new(models.WechatMiniConfig)).OrderBy("id").One(&row) + now := time.Now() + isNew := false + if err != nil { + if err != orm.ErrNoRows { + return err + } + isNew = true + row = models.WechatMiniConfig{} + } + + secret := strings.TrimSpace(in.AppSecret) + if secret != "" && !wechatmp.IsMasked(secret) { + enc, eerr := wechatmp.EncryptSecret(secret) + if eerr != nil { + return eerr + } + row.AppSecret = enc + } + + row.AppID = strings.TrimSpace(in.AppID) + row.Remark = strings.TrimSpace(in.Remark) + if in.Enabled { + row.Enabled = 1 + } else { + row.Enabled = 0 + } + if in.AutoBind { + row.AutoBind = 1 + } else { + row.AutoBind = 0 + } + row.UpdateTime = &now + + if isNew { + row.CreateTime = now + _, err = models.Orm.Insert(&row) + return err + } + _, err = models.Orm.Update(&row) + return err +} diff --git a/go/views/auth/login.tpl b/go/views/auth/login.tpl index e64d221..9eae823 100644 --- a/go/views/auth/login.tpl +++ b/go/views/auth/login.tpl @@ -535,7 +535,9 @@ + diff --git a/platform/components.d.ts b/platform/components.d.ts index 4cc7537..5d2371a 100644 --- a/platform/components.d.ts +++ b/platform/components.d.ts @@ -62,8 +62,6 @@ declare module 'vue' { ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] - ElStep: typeof import('element-plus/es')['ElStep'] - ElSteps: typeof import('element-plus/es')['ElSteps'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] diff --git a/uniapp/App.vue b/uniapp/App.vue index 01e2406..b8262da 100644 --- a/uniapp/App.vue +++ b/uniapp/App.vue @@ -17,14 +17,15 @@ diff --git a/uniapp/components/sub/SubFunctions.vue b/uniapp/components/sub/SubFunctions.vue new file mode 100644 index 0000000..22e2e3c --- /dev/null +++ b/uniapp/components/sub/SubFunctions.vue @@ -0,0 +1,315 @@ + + + + + diff --git a/uniapp/components/sub/SubHome.vue b/uniapp/components/sub/SubHome.vue new file mode 100644 index 0000000..7ee684c --- /dev/null +++ b/uniapp/components/sub/SubHome.vue @@ -0,0 +1,427 @@ + + + + + diff --git a/uniapp/components/sub/SubMine.vue b/uniapp/components/sub/SubMine.vue new file mode 100644 index 0000000..6d3251a --- /dev/null +++ b/uniapp/components/sub/SubMine.vue @@ -0,0 +1,485 @@ + + + + + diff --git a/uniapp/components/sub/SubTodo.vue b/uniapp/components/sub/SubTodo.vue new file mode 100644 index 0000000..39be80e --- /dev/null +++ b/uniapp/components/sub/SubTodo.vue @@ -0,0 +1,297 @@ + + + + + diff --git a/uniapp/config/subsystems.js b/uniapp/config/subsystems.js new file mode 100644 index 0000000..4cbe063 --- /dev/null +++ b/uniapp/config/subsystems.js @@ -0,0 +1,85 @@ +/** + * 子系统注册表 + * + * nav.vue(导航页,无 tabbar)按这里配置渲染子系统宫格; + * 点击卡片进入 pages/{slug}/home.vue,该子系统拥有自己的页面组与 tabbar。 + * + * module 对应后端 yz_system_modules.code(菜单归属靠 path 前缀匹配), + * slug 是端上路由目录名,务必与 pages/{slug}/ 保持一致。 + */ +export const SUBSYSTEMS = [ + { + slug: 'crm', + module: 'crm', + name: '客户关系', + title: '客户关系管理', + icon: 'handshake', + tint: 'tint-0', + desc: '线索 · 客户 · 商机 · 合同 · 回款', + // 该子系统在 Web 端有完整系统,概览页展示「进入完整系统」入口 + webEntry: true + }, + { + slug: 'erp', + module: 'erp', + name: '进销存', + title: '进销存管理', + icon: 'boxes-stacked', + tint: 'tint-1', + desc: '客户 · 供应商 · 账套 · 通讯录', + webEntry: true + }, + { + slug: 'oa', + module: 'oa', + name: 'OA 办公', + title: '协同办公', + icon: 'clipboard-check', + tint: 'tint-2', + desc: '日程 · 通知 · 文档 · 报销 · 人事', + webEntry: true + }, + { + slug: 'cms', + module: 'cms', + name: '网站管理', + title: '网站内容管理', + icon: 'globe', + tint: 'tint-3', + desc: '文章 · 产品 · 解决方案 · 站点', + webEntry: true + }, + { + slug: 'toolbox', + module: 'bianjiegongju', + name: '便捷工具', + title: '便捷工具', + icon: 'wrench', + tint: 'tint-4', + desc: '记事本 · 日程 · 密码库', + // 便捷工具的记事本 / 日程 / 密码库在端上都有原生页面, + // Web 端没有对应系统,因此不展示「进入完整系统」入口 + webEntry: false + } +] + +/** 按 slug 取子系统配置 */ +export function getSubsystem(slug) { + return SUBSYSTEMS.find((s) => s.slug === slug) || null +} + +/** 按后端模块 code 取子系统配置(menus[].module 与 yz_system_modules.code 对应) */ +export function getSubsystemByModule(module) { + const key = String(module || '').toLowerCase() + return SUBSYSTEMS.find((s) => String(s.module).toLowerCase() === key) || null +} + +/** + * 进入某子系统的概览页。 + * 后端模块没有对应端上页面时(未在 SUBSYSTEMS 注册),返回 false 让调用方自行降级。 + */ +export function goSubsystem(slug) { + if (!getSubsystem(slug)) return false + uni.redirectTo({ url: `/pages/${slug}/home/home` }) + return true +} diff --git a/uniapp/manifest.json b/uniapp/manifest.json index 64600f2..3ba9e44 100644 --- a/uniapp/manifest.json +++ b/uniapp/manifest.json @@ -55,6 +55,8 @@ "quickapp" : {}, /* 小程序特有相关 */ "mp-weixin" : { + /* 微信小程序 AppID:需与平台端「微信小程序配置」(/platform/wechatMini/config)中的 AppID 一致, + 否则「微信手机号一键登录」会因 jscode2session 报 40029 而失败 */ "appid" : "", "setting" : { "urlCheck" : false, diff --git a/uniapp/pages.json b/uniapp/pages.json index 655576c..a3e2662 100644 --- a/uniapp/pages.json +++ b/uniapp/pages.json @@ -8,6 +8,153 @@ } }, "pages": [ + { + "path": "pages/nav/nav", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "选择子系统" + } + }, + { + "path": "pages/crm/home/home", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "客户关系管理" + } + }, + { + "path": "pages/crm/functions/functions", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "客户关系 · 功能" + } + }, + { + "path": "pages/crm/todo/todo", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "客户关系 · 待办" + } + }, + { + "path": "pages/crm/mine/mine", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "客户关系 · 我的" + } + }, + { + "path": "pages/erp/home/home", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "进销存管理" + } + }, + { + "path": "pages/erp/functions/functions", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "进销存 · 功能" + } + }, + { + "path": "pages/erp/todo/todo", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "进销存 · 待办" + } + }, + { + "path": "pages/erp/mine/mine", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "进销存 · 我的" + } + }, + { + "path": "pages/oa/home/home", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "协同办公" + } + }, + { + "path": "pages/oa/functions/functions", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "OA · 功能" + } + }, + { + "path": "pages/oa/todo/todo", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "OA · 待办" + } + }, + { + "path": "pages/oa/mine/mine", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "OA · 我的" + } + }, + { + "path": "pages/cms/home/home", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "网站内容管理" + } + }, + { + "path": "pages/cms/functions/functions", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "网站管理 · 功能" + } + }, + { + "path": "pages/cms/todo/todo", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "网站管理 · 待办" + } + }, + { + "path": "pages/cms/mine/mine", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "网站管理 · 我的" + } + }, + { + "path": "pages/toolbox/home/home", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "便捷工具" + } + }, + { + "path": "pages/toolbox/functions/functions", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "便捷工具 · 功能" + } + }, + { + "path": "pages/toolbox/todo/todo", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "便捷工具 · 待办" + } + }, + { + "path": "pages/toolbox/mine/mine", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "便捷工具 · 我的" + } + }, { "path": "pages/login/login", "style": { @@ -54,7 +201,15 @@ "path": "pages/features/features", "style": { "navigationStyle": "custom", - "navigationBarTitleText": "功能" + "navigationBarTitleText": "全部应用" + } + }, + { + "path": "pages/apps/webview", + "style": { + "navigationBarTitleText": "功能", + "navigationBarBackgroundColor": "#ffffff", + "navigationBarTextStyle": "black" } }, { diff --git a/uniapp/pages/apps/webview.vue b/uniapp/pages/apps/webview.vue new file mode 100644 index 0000000..b80f05a --- /dev/null +++ b/uniapp/pages/apps/webview.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/uniapp/pages/cms/functions/functions.vue b/uniapp/pages/cms/functions/functions.vue new file mode 100644 index 0000000..50450aa --- /dev/null +++ b/uniapp/pages/cms/functions/functions.vue @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/uniapp/pages/cms/home/home.vue b/uniapp/pages/cms/home/home.vue new file mode 100644 index 0000000..27275f6 --- /dev/null +++ b/uniapp/pages/cms/home/home.vue @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/uniapp/pages/cms/mine/mine.vue b/uniapp/pages/cms/mine/mine.vue new file mode 100644 index 0000000..63accf5 --- /dev/null +++ b/uniapp/pages/cms/mine/mine.vue @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/uniapp/pages/cms/todo/todo.vue b/uniapp/pages/cms/todo/todo.vue new file mode 100644 index 0000000..0e2398f --- /dev/null +++ b/uniapp/pages/cms/todo/todo.vue @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/uniapp/pages/crm/functions/functions.vue b/uniapp/pages/crm/functions/functions.vue new file mode 100644 index 0000000..11a0445 --- /dev/null +++ b/uniapp/pages/crm/functions/functions.vue @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/uniapp/pages/crm/home/home.vue b/uniapp/pages/crm/home/home.vue new file mode 100644 index 0000000..eb2cf2c --- /dev/null +++ b/uniapp/pages/crm/home/home.vue @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/uniapp/pages/crm/mine/mine.vue b/uniapp/pages/crm/mine/mine.vue new file mode 100644 index 0000000..8c5ffe3 --- /dev/null +++ b/uniapp/pages/crm/mine/mine.vue @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/uniapp/pages/crm/todo/todo.vue b/uniapp/pages/crm/todo/todo.vue new file mode 100644 index 0000000..e592cf7 --- /dev/null +++ b/uniapp/pages/crm/todo/todo.vue @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/uniapp/pages/dashboard/dashboard.vue b/uniapp/pages/dashboard/dashboard.vue index 2bdd7c6..16a824a 100644 --- a/uniapp/pages/dashboard/dashboard.vue +++ b/uniapp/pages/dashboard/dashboard.vue @@ -1,58 +1,157 @@