批量优化

This commit is contained in:
hero920103 committed 2026-10-08 15:30:37 +08:00
1 parent 438145cae7
commit c925b198d1
50 files changed
+5716 -291

No files matched your search

+428
View File
@@ -0,0 +1,428 @@
package auth
import (
"encoding/json"
"io"
"net/url"
"strings"
"time"
"server/services"
"server/services/wechatmp"
beego "github.com/beego/beego/v2/server/web"
)
// AuthAccountController 统一认证中心自助账户:两步式注册 / 找回密码
//
// 注册两步:
// 1. 核验身份:手机号(短信验证码)或 账号(服务号验证码),通过即创建身份并签发 register_token;
// 2. 企业归属:绑定企业(join 申请) / 创建企业(资料+营业执照,平台审核) / 暂不加入。
type AuthAccountController struct {
beego.Controller
}
func (c *AuthAccountController) serveJSON(data map[string]interface{}) {
c.Data["json"] = data
_ = c.ServeJSON()
}
func (c *AuthAccountController) readJSON(v interface{}) bool {
body := c.Ctx.Input.RequestBody
if len(body) == 0 {
var err error
body, err = io.ReadAll(c.Ctx.Request.Body)
if err != nil || len(body) == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return false
}
}
if err := json.Unmarshal(body, v); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return false
}
return true
}
// loginBackURL 组装回登录页地址,透传 client_id/redirect 与提示参数
func loginBackURL(clientID, redirect, notice, warn string) string {
params := url.Values{}
if clientID != "" {
params.Set("client_id", clientID)
}
if redirect != "" {
params.Set("redirect", redirect)
}
if notice != "" {
params.Set("notice", notice)
}
if warn != "" {
params.Set("warn", warn)
}
qs := params.Encode()
target := "/auth/login"
if qs != "" {
target += "?" + qs
}
return target
}
// RegisterPage 注册页
// GET /auth/register?client_id=xxx&redirect=<base64>
func (c *AuthAccountController) RegisterPage() {
c.Data["ClientID"] = strings.TrimSpace(c.GetString("client_id"))
c.Data["Redirect"] = c.GetString("redirect")
c.Data["Error"] = c.GetString("error")
c.TplName = "auth/register.tpl"
}
// ==================== 第一步:核验身份 ====================
// SendRegisterCode 发送手机号注册短信验证码
// POST /auth/register/send-code { phone }
func (c *AuthAccountController) SendRegisterCode() {
var req struct {
Phone string `json:"phone"`
}
if !c.readJSON(&req) {
return
}
if err := services.SendIdentityCode("register", req.Phone); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "验证码已发送,请查收短信"})
}
// WechatStart 账号注册:获取服务号带参二维码(扫码关注后服务号回复验证码)。
// 未认证公众号无法生成带参二维码时回退:qr_url 为空,notice 引导用户
// 在公众号内发送「注册」获取验证码。
// GET /auth/register/wechat/start
func (c *AuthAccountController) WechatStart() {
scene, qrURL, notice, expireAt, err := wechatmp.StartRegisterVerify(wechatmp.DefaultVerifyTTL)
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
return
}
c.serveJSON(map[string]interface{}{
"code": 200,
"data": map[string]interface{}{
"scene": scene,
"qr_url": qrURL,
"notice": notice,
"expire_at": expireAt.Format(time.RFC3339),
},
})
}
// WechatStatus 账号注册:轮询扫码状态(展示「已扫码」)
// GET /auth/register/wechat/status?scene=xxx
func (c *AuthAccountController) WechatStatus() {
scene := strings.TrimSpace(c.GetString("scene"))
scanned, expireAt, err := wechatmp.GetRegisterVerifyStatus(scene)
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
return
}
c.serveJSON(map[string]interface{}{
"code": 200,
"data": map[string]interface{}{
"scanned": scanned,
"expire_at": expireAt.Format(time.RFC3339),
},
})
}
// RegisterVerify 第一步核验:验证码通过即创建统一身份,返回 register_token
// POST /auth/register/verify
//
// mode=phone { mode, phone, code, password, confirm }
// mode=account { mode, account, scene, code, password, confirm }
func (c *AuthAccountController) RegisterVerify() {
var req struct {
Mode string `json:"mode"`
Phone string `json:"phone"`
Account string `json:"account"`
Scene string `json:"scene"`
Code string `json:"code"`
Password string `json:"password"`
Confirm string `json:"confirm"`
}
if !c.readJSON(&req) {
return
}
openID := ""
if req.Mode == "account" {
// 核销服务号验证码,得到扫码人 openid(注册完成后绑定微信登录):
// 有 scene 走二维码会话核销;无 scene(公众号发送「注册」)按验证码认领
var err error
if strings.TrimSpace(req.Scene) != "" {
openID, err = wechatmp.ConfirmRegisterVerify(req.Scene, req.Code)
} else {
openID, err = wechatmp.ConfirmRegisterVerifyByCode(req.Code)
}
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
return
}
}
identity, token, err := services.VerifyAndCreateIdentity(services.RegisterVerifyInput{
Mode: req.Mode,
Phone: req.Phone,
Account: req.Account,
Code: req.Code,
Password: req.Password,
Confirm: req.Confirm,
OpenID: openID,
})
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
return
}
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "验证通过",
"data": map[string]interface{}{"register_token": token, "identity_id": identity.ID},
})
}
// ==================== 第二步:企业归属 ====================
// TenantSearch 搜索企业(绑定企业卡片)
// GET /auth/register/tenant/search?keyword=xxx
func (c *AuthAccountController) TenantSearch() {
keyword := strings.TrimSpace(c.GetString("keyword"))
if keyword == "" {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "请输入企业名称或编码"})
return
}
list, err := services.SearchTenantsForRegister(keyword)
if err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "搜索失败,请稍后重试"})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "data": map[string]interface{}{"list": list}})
}
// ApplyJoin 绑定企业:提交加入申请,等待企业管理员审核
// POST /auth/register/apply-join { token, tid, client_id, redirect }
func (c *AuthAccountController) ApplyJoin() {
var req struct {
Token string `json:"token"`
Tid uint64 `json:"tid"`
ClientID string `json:"client_id"`
Redirect string `json:"redirect"`
}
if !c.readJSON(&req) {
return
}
identityID, openID, err := services.PeekRegisterToken(req.Token)
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
return
}
apply, err := services.CreateJoinApply(identityID, req.Tid)
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
return
}
services.ConsumeRegisterToken(req.Token)
services.BindRegisterWechat(identityID, openID)
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "申请已提交",
"redirect": loginBackURL(req.ClientID, req.Redirect, "registered_warn",
"注册成功!加入「"+apply.TenantName+"」的申请已提交,请等待企业管理员审核通过后登录"),
})
}
// ApplyCreate 创建企业:提交企业资料(营业执照),等待平台审核
// POST /auth/register/apply-create { token, tenant_name, credit_code, legal_person, license_file, contact_person, contact_phone, contact_email, address, client_id, redirect }
func (c *AuthAccountController) ApplyCreate() {
var req struct {
Token string `json:"token"`
TenantName string `json:"tenant_name"`
CreditCode string `json:"credit_code"`
LegalPerson string `json:"legal_person"`
LicenseFile string `json:"license_file"`
ContactPerson string `json:"contact_person"`
ContactPhone string `json:"contact_phone"`
ContactEmail string `json:"contact_email"`
Address string `json:"address"`
ClientID string `json:"client_id"`
Redirect string `json:"redirect"`
}
if !c.readJSON(&req) {
return
}
identityID, openID, err := services.PeekRegisterToken(req.Token)
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
return
}
if _, err := services.CreateTenantApply(identityID, services.TenantCreateApplyInput{
TenantName: req.TenantName,
CreditCode: req.CreditCode,
LegalPerson: req.LegalPerson,
LicenseFile: req.LicenseFile,
ContactPerson: req.ContactPerson,
ContactPhone: req.ContactPhone,
ContactEmail: req.ContactEmail,
Address: req.Address,
}); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
return
}
services.ConsumeRegisterToken(req.Token)
services.BindRegisterWechat(identityID, openID)
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "申请已提交",
"redirect": loginBackURL(req.ClientID, req.Redirect, "registered_warn",
"注册成功!创建企业的申请已提交,请等待平台审核,审核结果将以短信通知"),
})
}
// FinishSkip 暂不加入企业,直接完成注册
// POST /auth/register/finish { token, client_id, redirect }
func (c *AuthAccountController) FinishSkip() {
var req struct {
Token string `json:"token"`
ClientID string `json:"client_id"`
Redirect string `json:"redirect"`
}
if !c.readJSON(&req) {
return
}
identityID, openID, err := services.PeekRegisterToken(req.Token)
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
return
}
services.ConsumeRegisterToken(req.Token)
services.BindRegisterWechat(identityID, openID)
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "注册成功",
"redirect": loginBackURL(req.ClientID, req.Redirect, "registered", "注册成功!你当前未加入任何企业,可稍后在登录后申请加入"),
})
}
// UploadLicense 上传营业执照(注册页未登录,专用无鉴权端点)
// POST /auth/register/upload-license multipart 字段 file;仅 pdf/jpg/png,≤5MB
func (c *AuthAccountController) UploadLicense() {
file, header, err := c.GetFile("file")
if err != nil || file == nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "请选择要上传的文件"})
return
}
defer file.Close()
// 类型与大小限制
name := strings.ToLower(header.Filename)
ok := strings.HasSuffix(name, ".pdf") || strings.HasSuffix(name, ".jpg") ||
strings.HasSuffix(name, ".jpeg") || strings.HasSuffix(name, ".png")
if !ok {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "仅支持 PDF、JPG、PNG 格式"})
return
}
if header.Size > 5*1024*1024 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "文件大小不能超过 5MB"})
return
}
storage, err := services.GetStorageService()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "存储服务不可用,请稍后重试"})
return
}
staged, err := storage.Stage(file, header)
if err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "文件读取失败,请重试"})
return
}
result, err := storage.Commit(staged, services.UploadContext{Source: services.SourcePlatform, Tid: 0, Tuid: 0})
if err != nil {
_ = storage.Discard(staged)
c.serveJSON(map[string]interface{}{"code": 500, "msg": "上传失败,请重试"})
return
}
// 文件登记(平台文件管理可见)
_, _ = services.CreateFileRecord(services.FileUploadParams{
Source: services.SourcePlatform,
Scope: services.ScopeTenant,
Tid: 0,
Uid: 0,
Name: header.Filename,
Ext: staged.Ext,
Cate: 0,
Size: uint64(staged.Size),
Src: result.URL,
ObjectKey: result.Key,
Storage: storage.Type(),
MD5: staged.MD5,
})
c.serveJSON(map[string]interface{}{
"code": 200,
"data": map[string]interface{}{"url": result.URL, "name": header.Filename},
})
}
// ==================== 找回密码 ====================
// ForgotPage 找回密码页
// GET /auth/forgot?client_id=xxx&redirect=<base64>
func (c *AuthAccountController) ForgotPage() {
c.Data["ClientID"] = strings.TrimSpace(c.GetString("client_id"))
c.Data["Redirect"] = c.GetString("redirect")
c.Data["Error"] = c.GetString("error")
c.TplName = "auth/forgot.tpl"
}
// SendResetCode 发送找回密码验证码
// POST /auth/forgot/send-code { phone }
func (c *AuthAccountController) SendResetCode() {
var req struct {
Phone string `json:"phone"`
}
if !c.readJSON(&req) {
return
}
if err := services.SendIdentityCode("reset", req.Phone); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "验证码已发送,请查收短信"})
}
// ResetSubmit 提交重置密码
// POST /auth/forgot/reset { phone, code, password, confirm, client_id, redirect }
func (c *AuthAccountController) ResetSubmit() {
var req struct {
Phone string `json:"phone"`
Code string `json:"code"`
Password string `json:"password"`
Confirm string `json:"confirm"`
ClientID string `json:"client_id"`
Redirect string `json:"redirect"`
}
if !c.readJSON(&req) {
return
}
if err := services.ResetIdentityPasswordByMobile(req.Phone, req.Code, req.Password, req.Confirm); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
return
}
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "密码已重置",
"redirect": loginBackURL(req.ClientID, req.Redirect, "reset", ""),
})
}
+3
View File
@@ -69,6 +69,9 @@ func (c *AuthLoginController) LoginPage() {
c.Data["Step"] = step
c.Data["Error"] = errMsg
c.Data["Tenants"] = tenants
// 自助账户流程提示:registered=注册成功 / registered_warn=注册成功但加入企业失败 / reset=密码已重置
c.Data["Notice"] = c.GetString("notice")
c.Data["WarnMsg"] = c.GetString("warn")
c.TplName = "auth/login.tpl"
}
+18 -7
View File
@@ -10,13 +10,13 @@ import (
"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"
beego "github.com/beego/beego/v2/server/web"
)
// 小程序登录失败原因(前端按此分支给出不同引导)
const (
MiniReasonNotConfigured = "not_configured" // 服务端未配置小程序
MiniReasonNotConfigured = "not_configured" // 服务端未配置小程序
MiniReasonIdentityNotFound = "identity_not_found" // 微信未匹配到云泽账号
MiniReasonNoTenant = "no_tenant" // 账号未加入任何企业
MiniReasonWechatAPI = "wechat_api_error" // 微信接口报错
@@ -49,10 +49,19 @@ func (c *AuthWechatMiniController) fail(reason string, bizCode int, msg string)
c.serveJSON(map[string]interface{}{"code": bizCode, "msg": msg, "reason": reason})
}
// loadMiniConfig 按客户端传入的 appid 选择小程序配置(多应用支持),
// 未传 appid 时回退第一条启用配置
func loadMiniConfig(appID string) (*wechatmini.Config, error) {
if appID = strings.TrimSpace(appID); appID != "" {
return wechatmini.LoadEnabledAppByAppID(appID)
}
return wechatmini.LoadEnabledConfig()
}
// Config 小程序登录是否可用
// GET /auth/wechat/miniapp/config
// GET /auth/wechat/miniapp/config?appid=xxx(多应用时可指定,缺省取第一条启用配置)
func (c *AuthWechatMiniController) Config() {
cfg, err := wechatmini.LoadEnabledConfig()
cfg, err := loadMiniConfig(c.GetString("appid"))
if err != nil {
c.serveJSON(map[string]interface{}{
"code": 200,
@@ -72,6 +81,7 @@ func (c *AuthWechatMiniController) Login() {
Code string `json:"code"`
PhoneCode string `json:"phone_code"`
ClientID string `json:"client_id"`
AppID string `json:"appid"`
}
body := c.Ctx.Input.RequestBody
if len(body) == 0 {
@@ -92,7 +102,7 @@ func (c *AuthWechatMiniController) Login() {
return
}
cfg, err := wechatmini.LoadEnabledConfig()
cfg, err := loadMiniConfig(req.AppID)
if err != nil {
c.fail(MiniReasonNotConfigured, 400, "小程序登录未开通,请使用账号密码登录")
return
@@ -193,7 +203,8 @@ func (c *AuthWechatMiniController) Bind() {
}
var req struct {
Code string `json:"code"`
Code string `json:"code"`
AppID string `json:"appid"`
}
body := c.Ctx.Input.RequestBody
if len(body) == 0 {
@@ -209,7 +220,7 @@ func (c *AuthWechatMiniController) Bind() {
return
}
cfg, err := wechatmini.LoadEnabledConfig()
cfg, err := loadMiniConfig(req.AppID)
if err != nil {
c.fail(MiniReasonNotConfigured, 400, "小程序登录未开通")
return
+109 -24
View File
@@ -12,6 +12,7 @@ import (
"server/pkg/jwtutil"
"server/pkg/passwordutil"
"server/services"
authsvc "server/services/auth"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
@@ -56,11 +57,18 @@ type backendTenantUserPayload struct {
IsDefault *int8 `json:"is_default"`
Status *int8 `json:"status"`
Remark *string `json:"remark"`
// IdentityID + VerifyPassword:绑定已有认证身份时使用。
// 归并键(手机/邮箱/账号)命中已有身份时,接口返回 409 要求验证;
// 前端补上 identity_id 与该账号的登录密码(verify_password)重新提交,
// 验证通过只建企业绑定,不新建身份、不覆盖原密码。
IdentityID uint64 `json:"identity_id"`
VerifyPassword string `json:"verify_password"`
}
type backendChangePasswordPayload struct {
ID uint64 `json:"id"`
Password string `json:"password"`
By string `json:"by"`
}
func formatBackendBirth(birth *string) *string {
@@ -187,7 +195,12 @@ func (c *BackendAdminUserController) parseTenantUserPayload() (backendTenantUser
// yz_system_tenant_user.uid 仍是老 uid(老表不在 uid 全量迁移范围内),
// 因此必须先换算再按 uid 查;先按主键 id 查会把 identity_id 命中的是
// 主键恰好相同的**别人**的记录(个人中心显示他人资料就是这么来的)。
func findBackendTenantUser(idOrUid uint64, jwtTid uint64) (*models.SystemTenantUser, error) {
//
// byID=true 表示调用方明确传入的是 yz_system_tenant_user 主键
// (用户管理页的行数据,列表接口返回的 id 就是主键),
// 此时只按主键精确查询,禁止 identity_id/uid 换算——否则主键值
// 恰好等于别人老 uid 时会查到**别人**的记录(编辑李增炎却回显李志强)。
func findBackendTenantUser(idOrUid uint64, jwtTid uint64, byID bool) (*models.SystemTenantUser, error) {
if idOrUid == 0 {
return nil, errors.New("用户ID不能为空")
}
@@ -201,6 +214,14 @@ func findBackendTenantUser(idOrUid uint64, jwtTid uint64) (*models.SystemTenantU
return qs
}
if byID {
var row models.SystemTenantUser
if err := base().Filter("id", idOrUid).One(&row); err != nil {
return nil, err
}
return &row, nil
}
// 1) 当作 identity_id:换算成本租户老 uid 后匹配
var row models.SystemTenantUser
if uid := services.ResolveTenantUID(jwtTid, idOrUid); uid > 0 {
@@ -323,14 +344,18 @@ func (c *BackendAdminUserController) GetTenantUsers() {
}
// GetUserInfo 获取后台租户用户详情
// GET /backend/getUserInfo/:id
// GET /backend/getUserInfo/:id[?by=id]
// by=id:前端明确传主键(用户管理页),只按主键查询;
// 缺省时兼容 identity_id / 老 uid(个人中心等场景)。
func (c *BackendAdminUserController) GetUserInfo() {
jwtUid, jwtTid := c.getJWTUidTid()
idStr := c.Ctx.Input.Param(":id")
id, _ := strconv.ParseUint(idStr, 10, 64)
if id == 0 {
byID := c.GetString("by") == "id"
if id == 0 && !byID {
id = jwtUid
}
@@ -340,7 +365,7 @@ func (c *BackendAdminUserController) GetUserInfo() {
return
}
u, err := findBackendTenantUser(id, jwtTid)
u, err := findBackendTenantUser(id, jwtTid, byID)
if err != nil || u == nil {
c.Data["json"] = map[string]interface{}{"code": 404, "msg": "后台用户信息不存在"}
_ = c.ServeJSON()
@@ -389,16 +414,75 @@ func (c *BackendAdminUserController) AddUser() {
account := strings.TrimSpace(*p.Account)
p.Account = &account
hashed, err := passwordutil.Hash(strings.TrimSpace(*p.Password))
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()}
_ = c.ServeJSON()
return
}
p.Password = &hashed
// ── 统一认证:一人一套账号密码,跨企业只建绑定 ─────────────────────────
// 分支①:前端携带 identity_id(409 验证后的二次提交)——
// 验证该身份的登录密码,通过后只建企业绑定,不新建身份、不覆盖原密码。
if p.IdentityID > 0 {
if strings.TrimSpace(p.VerifyPassword) == "" {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "请输入该账号在原企业的登录密码以验证身份"}
_ = c.ServeJSON()
return
}
identity, err := authsvc.GetIdentityByID(p.IdentityID)
if err != nil || identity == nil {
c.Data["json"] = map[string]interface{}{"code": 404, "msg": "认证身份不存在"}
_ = c.ServeJSON()
return
}
if !authsvc.VerifyIdentityPassword(identity, strings.TrimSpace(p.VerifyPassword)) {
c.Data["json"] = map[string]interface{}{"code": 401, "msg": "密码不正确,无法确认是同一账号"}
_ = c.ServeJSON()
return
}
// uid 统一为 identity_id,与已全量迁移的业务表口径一致
p.Uid = identity.ID
// 老表镜像身份层现有密码哈希(登录仍以认证中心为准)
if identity.PasswordHash != nil && *identity.PasswordHash != "" {
h := *identity.PasswordHash
p.Password = &h
} else {
// 身份层无密码(历史数据),仅镜像到老表,不影响统一认证
hashed, hErr := passwordutil.Hash(strings.TrimSpace(*p.Password))
if hErr != nil {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": hErr.Error()}
_ = c.ServeJSON()
return
}
p.Password = &hashed
}
} else if p.Uid == 0 {
// 分支②:归并键(手机 > 邮箱 > 账号)命中已有身份 → 不覆盖,
// 返回 409 让前端提示「该账号已在哪些企业存在」,
// 用户输入原企业登录密码验证本人后,携 identity_id 重新提交(走分支①)。
if existed, hit := authsvc.LocateIdentityByKey(account, strOrEmpty(p.Phone), strOrEmpty(p.Email)); hit {
hints := make([]map[string]interface{}, 0)
if tenants, err := authsvc.ListTenantOptions(existed.ID); err == nil {
for _, t := range tenants {
hints = append(hints, map[string]interface{}{"tid": t.Tid, "tenant_name": t.TenantName})
}
}
c.Data["json"] = map[string]interface{}{
"code": 409,
"msg": "该账号已在其他企业存在,请输入原企业登录密码验证身份后绑定本企业",
"data": map[string]interface{}{
"need_verify": true,
"identity_id": existed.ID,
"tenants": hints,
},
}
_ = c.ServeJSON()
return
}
// 分支③:全新身份 —— password 必填,先建认证身份再拿 identity_id 当 uid
hashed, err := passwordutil.Hash(strings.TrimSpace(*p.Password))
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()}
_ = c.ServeJSON()
return
}
p.Password = &hashed
if p.Uid == 0 {
// 统一走 uid:优先用认证中心 identity_id,与已全量迁移的业务表口径一致
uid, err := ensureTenantUIDForNewUser(
p.Tid,
strOrEmpty(p.Account),
@@ -478,10 +562,11 @@ func (c *BackendAdminUserController) AddUser() {
}
}
// 双写:同步到统一认证中心(读回最新记录,确保 group_id 已落库)
// 双写:同步到统一认证中心(读回最新记录,确保 group_id 已落库);
// 身份若已存在,密码以身份层为准,不同步(避免覆盖)
var created models.SystemTenantUser
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&created); e == nil {
syncTenantUserToAuth(&created)
syncTenantUserToAuth(&created, false)
}
c.Data["json"] = map[string]interface{}{
@@ -511,7 +596,7 @@ func (c *BackendAdminUserController) EditUser() {
_, jwtTid := c.getJWTUidTid()
row, err := findBackendTenantUser(id, jwtTid)
row, err := findBackendTenantUser(id, jwtTid, c.GetString("by") == "id")
if err != nil || row == nil {
c.Data["json"] = map[string]interface{}{"code": 404, "msg": "用户不存在"}
_ = c.ServeJSON()
@@ -606,10 +691,10 @@ func (c *BackendAdminUserController) EditUser() {
_ = services.SetDefaultTenant(row.Uid, row.Tid)
}
// 双写:同步到统一认证中心
// 双写:同步到统一认证中心(编辑不改密码,不同步密码字段)
var edited models.SystemTenantUser
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", row.ID).One(&edited); e == nil {
syncTenantUserToAuth(&edited)
syncTenantUserToAuth(&edited, false)
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
@@ -620,8 +705,8 @@ func (c *BackendAdminUserController) EditUser() {
// DELETE /backend/deleteUser/:id
//
// 用户管理里的账号属于租户层面的登录凭证(yz_system_tenant_user),与员工
//(yz_backend_employee)、人事档案(yz_backend_employee_file)、通讯录
//(yz_backend_erp_contact)是三份不同用途的数据:账号只用于登录系统,员工与档案属于企业数据。
// (yz_backend_employee)、人事档案(yz_backend_employee_file)、通讯录
// (yz_backend_erp_contact)是三份不同用途的数据:账号只用于登录系统,员工与档案属于企业数据。
// 因此这里只解除登录绑定,不做任何级联操作——删除账号不会影响员工档案和通讯录记录。
// 员工侧的离职/禁用走 deleteEmployee,档案侧的离职走 employeefile/delete。
func (c *BackendAdminUserController) DeleteUser() {
@@ -636,7 +721,7 @@ func (c *BackendAdminUserController) DeleteUser() {
_, jwtTid := c.getJWTUidTid()
row, err := findBackendTenantUser(id, jwtTid)
row, err := findBackendTenantUser(id, jwtTid, c.GetString("by") == "id")
if err != nil || row == nil {
c.Data["json"] = map[string]interface{}{"code": 404, "msg": "用户不存在"}
_ = c.ServeJSON()
@@ -682,7 +767,7 @@ func (c *BackendAdminUserController) ChangePassword() {
_, jwtTid := c.getJWTUidTid()
row, err := findBackendTenantUser(p.ID, jwtTid)
row, err := findBackendTenantUser(p.ID, jwtTid, p.By == "id")
if err != nil || row == nil {
c.Data["json"] = map[string]interface{}{"code": 404, "msg": "用户不存在"}
_ = c.ServeJSON()
@@ -708,10 +793,10 @@ func (c *BackendAdminUserController) ChangePassword() {
return
}
// 双写:同步新密码到统一认证中心
// 双写:同步新密码到统一认证中心(改密场景,显式允许更新身份层密码)
var updated models.SystemTenantUser
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", row.ID).One(&updated); e == nil {
syncTenantUserToAuth(&updated)
syncTenantUserToAuth(&updated, true)
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "修改成功"}
+38 -2
View File
@@ -122,12 +122,23 @@ func tenantFlowToView(f models.TenantApprovalFlow) approvalFlowView {
}
edgeNodes := make([]flowEdgeNode, 0, len(nodes))
for _, n := range nodes {
approverIDs := parseIDSlice(n.ApproverIDs)
// 通知节点多类型格式:"类型:ID" CSV,原样下发给前端回显
var notifyTargets []string
if n.NodeType == models.ApprovalNodeNotify && strings.Contains(n.ApproverIDs, ":") {
for _, part := range strings.Split(n.ApproverIDs, ",") {
if trimmed := strings.TrimSpace(part); trimmed != "" {
notifyTargets = append(notifyTargets, trimmed)
}
}
}
view.Nodes = append(view.Nodes, approvalNodePayload{
NodeKey: n.NodeKey,
NodeName: n.NodeName,
NodeType: n.NodeType,
ApproverType: n.ApproverType,
ApproverIDs: parseIDSlice(n.ApproverIDs),
ApproverIDs: approverIDs,
NotifyTargets: notifyTargets,
MultiMode: n.MultiMode,
AllowReturn: n.AllowReturn,
TimeoutHours: n.TimeoutHours,
@@ -273,6 +284,11 @@ func (c *BackendApprovalController) FlowSave() {
if timeout <= 0 {
timeout = 48
}
// 通知节点多类型:以 "类型:ID" CSV 落库到 approver_ids,引擎按类型分组解析
approverCSV := joinIDs(nd.ApproverIDs)
if nodeType == models.ApprovalNodeNotify && len(nd.NotifyTargets) > 0 {
approverCSV = strings.Join(nd.NotifyTargets, ",")
}
node := models.TenantApprovalNode{
FlowID: flowID,
Tid: tid,
@@ -280,7 +296,7 @@ func (c *BackendApprovalController) FlowSave() {
NodeName: strings.TrimSpace(nd.NodeName),
NodeType: nodeType,
ApproverType: nd.ApproverType,
ApproverIDs: joinIDs(nd.ApproverIDs),
ApproverIDs: approverCSV,
MultiMode: multi,
AllowReturn: nd.AllowReturn,
TimeoutHours: timeout,
@@ -505,6 +521,26 @@ func validateTenantApprovalNodes(nodes []approvalNodePayload) error {
if strings.TrimSpace(nd.NodeName) == "" {
return fmt.Errorf("第 %d 个节点名称不能为空", i+1)
}
// 通知节点多类型格式:校验每条 "类型:ID",自动定位类型(5/6)无需对象
if nd.NodeType == models.ApprovalNodeNotify && len(nd.NotifyTargets) > 0 {
for _, tg := range nd.NotifyTargets {
kv := strings.SplitN(strings.TrimSpace(tg), ":", 2)
if len(kv) != 2 {
return fmt.Errorf("节点「%s」通知对象格式非法", nd.NodeName)
}
t, err := strconv.Atoi(strings.TrimSpace(kv[0]))
if err != nil || t < int(models.ApprovalApproverUser) || t > int(models.ApprovalApproverInitLeader) {
return fmt.Errorf("节点「%s」通知类型非法", nd.NodeName)
}
if t != int(models.ApprovalApproverInitiator) && t != int(models.ApprovalApproverInitLeader) {
id, err := strconv.ParseUint(strings.TrimSpace(kv[1]), 10, 64)
if err != nil || id == 0 {
return fmt.Errorf("节点「%s」未选择通知对象", nd.NodeName)
}
}
}
continue
}
if nd.ApproverType < models.ApprovalApproverUser || nd.ApproverType > models.ApprovalApproverInitLeader {
return fmt.Errorf("节点「%s」审批人类型非法", nd.NodeName)
}
+27 -16
View File
@@ -2,11 +2,13 @@ package controllers
import (
"encoding/json"
"strconv"
"strings"
"server/models"
"server/pkg/jwtutil"
"server/services"
"strconv"
"strings"
authsvc "server/services/auth"
beego "github.com/beego/beego/v2/server/web"
)
@@ -98,22 +100,31 @@ func (c *BackendMenuController) GetBackendMenu() {
idStr := c.Ctx.Input.Param(":id")
uid, _ := strconv.ParseUint(idStr, 10, 64)
tid := uint64(0)
groupID := uint64(0)
if uid > 0 {
var tu models.SystemTenantUser
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("id", uid).
Filter("delete_time__isnull", true).
One(&tu); e == nil {
// URL 参数是认证中心 identity_id,不能直接当 yz_system_tenant_user 主键查:
// 主键恰好与 identity_id 相同时会套用**别人**的角色权限——
// 如 identity_id=1(李增炎)命中主键 1(李志强 tid=1、group_id=0),
// 反而免过滤拿到全量菜单(越权);identity_id=2 则命中李增炎的受限角色,
// 菜单被错误过滤(基础设置 404)。
// 解析顺序:认证绑定表(identity_id+tid → group_id,与 /auth/userinfo 同源)
// → 业务表身份解析链(identity_id 换算 → 主键 → 老 uid)。
jwtTid := backendJWTTenantID(c.Ctx.Request.Header.Get("Authorization"))
if bind, e := authsvc.GetTenantUser(uid, jwtTid); e == nil {
tid = bind.Tid
groupID = bind.GroupID
} else if tu, e := findBackendTenantUser(uid, jwtTid, false); e == nil {
tid = tu.Tid
if tu.GroupID > 0 {
var role models.AdminRole
if re := models.Orm.QueryTable(new(models.AdminRole)).
Filter("id", tu.GroupID).
Filter("cid", 2).
One(&role); re == nil {
backendMenus = filterMenusByRights(backendMenus, role.Rights)
}
}
groupID = tu.GroupID
}
}
if groupID > 0 {
var role models.AdminRole
if re := models.Orm.QueryTable(new(models.AdminRole)).
Filter("id", groupID).
Filter("cid", 2).
One(&role); re == nil {
backendMenus = filterMenusByRights(backendMenus, role.Rights)
}
}
+51
View File
@@ -7,6 +7,7 @@ import (
"server/models"
"server/pkg/jwtutil"
"server/services"
authsvc "server/services/auth"
beego "github.com/beego/beego/v2/server/web"
)
@@ -48,6 +49,24 @@ func (c *BackendModulesController) jsonErr(httpStatus, bizCode int, msg string)
_ = c.ServeJSON()
}
// backendUserGroupID 解析当前登录者在当前租户的角色ID。
// claims.UserID 是认证中心 identity_id,不能直接当 yz_system_tenant_user 主键用;
// 优先按认证绑定表(identity_id+tid → group_id,与 /auth/userinfo 同源),
// 查不到再走业务表身份解析链(identity_id 换算 → 主键 → 老 uid)。
func (c *BackendModulesController) backendUserGroupID(claims *jwtutil.Claims, tid uint64) uint64 {
uid := uint64(claims.UserID)
if uid == 0 {
return 0
}
if bind, e := authsvc.GetTenantUser(uid, tid); e == nil {
return bind.GroupID
}
if tu, e := findBackendTenantUser(uid, tid, false); e == nil {
return tu.GroupID
}
return 0
}
// moduleWithEnabled 带套餐开通标记的模块输出
type moduleWithEnabled struct {
models.SystemModules
@@ -91,6 +110,38 @@ func (c *BackendModulesController) GetTenantList() {
enhanced = append(enhanced, moduleWithEnabled{SystemModules: m, Enabled: enabled})
}
// 角色权限过滤(与 /backend/menu/:id 同口径):
// 未分配角色或角色 rights 为空 → 全部可见;
// 否则仅保留「名下至少有一个被授权菜单」的模块,未授权模块直接不返回,
// 避免首页出现可点击却进 404 的模块卡片。
if groupID := c.backendUserGroupID(claims, tid); groupID > 0 {
var role models.AdminRole
if re := models.Orm.QueryTable(new(models.AdminRole)).
Filter("id", groupID).
Filter("cid", 2).
One(&role); re == nil && role.Rights != nil && strings.TrimSpace(*role.Rights) != "" {
if len(parseRightsToSet(*role.Rights)) > 0 {
var allMenus []models.SystemMenu
if _, me := models.Orm.QueryTable(new(models.SystemMenu)).Filter("status", 1).All(&allMenus); me == nil {
allowedMenus := filterMenusByRights(allMenus, role.Rights)
owningAny := services.ModuleCodesOwningMenus(rows, allMenus)
owningAllowed := services.ModuleCodesOwningMenus(rows, allowedMenus)
visible := make([]moduleWithEnabled, 0, len(enhanced))
for _, item := range enhanced {
code := services.NormalizeModuleCode(item.Code)
// 模块名下有菜单但一个都没授权 → 隐藏;
// 名下没有菜单(无法判定)→ 保持原行为不隐藏
if owningAny[code] && !owningAllowed[code] {
continue
}
visible = append(visible, item)
}
enhanced = visible
}
}
}
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "获取成功",
+188
View File
@@ -0,0 +1,188 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"server/models"
"server/pkg/jwtutil"
"server/services"
authsvc "server/services/auth"
beego "github.com/beego/beego/v2/server/web"
)
// BackendTenantApplyController 租户端「加入申请」审批
// 数据按 JWT tid 隔离:只处理目标企业为本租户的 join 申请。
type BackendTenantApplyController struct {
beego.Controller
}
func (c *BackendTenantApplyController) backendClaims() (*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 != "backend" {
return nil, fmt.Errorf("无权访问")
}
return claims, nil
}
func (c *BackendTenantApplyController) 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 *BackendTenantApplyController) readJSON(v interface{}) bool {
body := c.Ctx.Input.RequestBody
if len(body) == 0 {
var err error
body, err = io.ReadAll(c.Ctx.Request.Body)
if err != nil || len(body) == 0 {
c.jsonErr(400, 400, "参数错误")
return false
}
}
if err := json.Unmarshal(body, v); err != nil {
c.jsonErr(400, 400, "参数错误")
return false
}
return true
}
// reviewerName 当前审核人姓名(租户用户表 name,兜底 JWT username)
func (c *BackendTenantApplyController) reviewerName(claims *jwtutil.Claims) string {
var row models.SystemTenantUser
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", claims.TenantId).
Filter("uid", claims.UserID).
Filter("delete_time__isnull", true).
One(&row); err == nil && row.Name != nil && strings.TrimSpace(*row.Name) != "" {
return strings.TrimSpace(*row.Name)
}
return claims.Username
}
// List GET /backend/tenantApplies?page=1&pageSize=20&status=
func (c *BackendTenantApplyController) List() {
claims, err := c.backendClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
if tid == 0 {
c.jsonErr(403, 403, "无租户信息")
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
var status *int8
if s := strings.TrimSpace(c.GetString("status")); s != "" {
if v, e := strconv.ParseInt(s, 10, 8); e == nil {
tmp := int8(v)
status = &tmp
}
}
rows, total, err := services.ListTenantAppliesForTenant(tid, status, page, pageSize)
if err != nil {
c.jsonErr(500, 500, "获取申请列表失败")
return
}
// 补充申请人展示信息(昵称 / 手机号)
type applyDTO struct {
models.AuthTenantApply
IdentityNickname string `json:"identity_nickname"`
IdentityMobile string `json:"identity_mobile"`
}
list := make([]applyDTO, 0, len(rows))
for _, r := range rows {
dto := applyDTO{AuthTenantApply: r}
if identity, ierr := authsvc.GetIdentityByID(r.IdentityID); ierr == nil && identity != nil {
if identity.Nickname != nil {
dto.IdentityNickname = *identity.Nickname
}
if identity.Mobile != nil {
dto.IdentityMobile = *identity.Mobile
}
}
list = append(list, dto)
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
"data": map[string]interface{}{"list": list, "total": total},
}
_ = c.ServeJSON()
}
// Approve POST /backend/tenantApplies/approve/:id
func (c *BackendTenantApplyController) Approve() {
claims, err := c.backendClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
applyID, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || applyID == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
if err := services.ApproveJoinApply(applyID, tid, uint64(claims.UserID), c.reviewerName(claims)); err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "已通过,申请人已加入本企业"}
_ = c.ServeJSON()
}
// Reject POST /backend/tenantApplies/reject/:id { reason }
func (c *BackendTenantApplyController) Reject() {
claims, err := c.backendClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
applyID, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || applyID == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
var req struct {
Reason string `json:"reason"`
}
if !c.readJSON(&req) {
return
}
if err := services.RejectJoinApply(applyID, tid, uint64(claims.UserID), c.reviewerName(claims), strings.TrimSpace(req.Reason)); err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "已驳回"}
_ = c.ServeJSON()
}
+4
View File
@@ -35,6 +35,10 @@ type approvalNodePayload struct {
NodeType int8 `json:"node_type"` // 1开始 2审批 3结束 4判断 5通知
ApproverType int8 `json:"approver_type"` // 1指定用户 2指定角色 ...
ApproverIDs []uint64 `json:"approver_ids"`
// NotifyTargets 通知节点多类型存储:"类型:ID" 列表(如 ["1:52","5:0"]),
// 类型取值同 approver_type;自动定位类型(5/6)ID 占位 0。
// 仅通知节点使用;空列表时按 approver_type + approver_ids 旧格式处理。
NotifyTargets []string `json:"notify_targets"`
MultiMode int8 `json:"multi_mode"` // 1或签 2会签
AllowReturn int8 `json:"allow_return"` // 是否允许退回
TimeoutHours int `json:"timeout_hours"` // 超时小时
+131 -32
View File
@@ -2,6 +2,7 @@ package controllers
import (
"encoding/json"
"errors"
"fmt"
"io"
"math"
@@ -11,6 +12,7 @@ import (
"server/models"
"server/services"
authsvc "server/services/auth"
beego "github.com/beego/beego/v2/server/web"
)
@@ -21,27 +23,27 @@ type PlatformTenantController struct {
}
type tenantDTO struct {
ID uint64 `json:"id"`
TenantCode string `json:"tenant_code"`
TenantName string `json:"tenant_name"`
ContactPerson string `json:"contact_person"`
ContactPhone string `json:"contact_phone"`
ContactEmail string `json:"contact_email"`
Address string `json:"address"`
Worktime string `json:"worktime"`
Status int8 `json:"status"`
ID uint64 `json:"id"`
TenantCode string `json:"tenant_code"`
TenantName string `json:"tenant_name"`
ContactPerson string `json:"contact_person"`
ContactPhone string `json:"contact_phone"`
ContactEmail string `json:"contact_email"`
Address string `json:"address"`
Worktime string `json:"worktime"`
Status int8 `json:"status"`
// 套餐与用户数配额
PackageID uint64 `json:"package_id"`
PackageName string `json:"package_name"`
// PackageExpireTime 套餐计费到期时间(NULL-永久有效);DaysRemaining 剩余天数
PackageExpireTime *time.Time `json:"package_expire_time"`
DaysRemaining int `json:"days_remaining"`
UserQuota int `json:"user_quota"`
UserUsed int `json:"user_used"`
Remark string `json:"remark"`
CreateTime *time.Time `json:"create_time,omitempty"`
UpdateTime *time.Time `json:"update_time,omitempty"`
DeleteTime *time.Time `json:"delete_time,omitempty"`
UserQuota int `json:"user_quota"`
UserUsed int `json:"user_used"`
Remark string `json:"remark"`
CreateTime *time.Time `json:"create_time,omitempty"`
UpdateTime *time.Time `json:"update_time,omitempty"`
DeleteTime *time.Time `json:"delete_time,omitempty"`
}
// tenantPackageNameMap 套餐ID -> 套餐名称
@@ -94,23 +96,23 @@ func toTenantDTO(t models.SystemTenant) tenantDTO {
}
}
return tenantDTO{
ID: t.ID,
TenantCode: t.TenantCode,
TenantName: t.TenantName,
ContactPerson: stringValue(t.ContactPerson),
ContactPhone: stringValue(t.ContactPhone),
ContactEmail: stringValue(t.ContactEmail),
Address: stringValue(t.Address),
Worktime: stringValue(t.Worktime),
Status: t.Status,
PackageID: t.PackageID,
ID: t.ID,
TenantCode: t.TenantCode,
TenantName: t.TenantName,
ContactPerson: stringValue(t.ContactPerson),
ContactPhone: stringValue(t.ContactPhone),
ContactEmail: stringValue(t.ContactEmail),
Address: stringValue(t.Address),
Worktime: stringValue(t.Worktime),
Status: t.Status,
PackageID: t.PackageID,
PackageExpireTime: t.PackageExpireTime,
DaysRemaining: daysRemaining,
UserQuota: t.UserQuota,
Remark: stringValue(t.Remark),
CreateTime: &ct,
UpdateTime: &ut,
DeleteTime: t.DeleteTime,
UserQuota: t.UserQuota,
Remark: stringValue(t.Remark),
CreateTime: &ct,
UpdateTime: &ut,
DeleteTime: t.DeleteTime,
}
}
@@ -234,6 +236,14 @@ type tenantPayload struct {
PackageID *uint64 `json:"package_id"`
UserQuota *int `json:"user_quota"`
Remark string `json:"remark"`
// 指定租户管理员(可选):把已有认证身份绑定为该租户管理员。
// AdminIdentityID 直接指定身份;也可只传 AdminAccount/AdminPhone/AdminEmail 按归并键定位。
// 统一认证原则:不新建账号、不覆盖原密码,只建「身份-企业」绑定并授予租户管理员角色。
AdminIdentityID uint64 `json:"admin_identity_id"`
AdminAccount string `json:"admin_account"`
AdminPhone string `json:"admin_phone"`
AdminEmail string `json:"admin_email"`
}
func (c *PlatformTenantController) parseTenantPayload() (tenantPayload, error) {
@@ -264,6 +274,14 @@ func (c *PlatformTenantController) parseTenantPayload() (tenantPayload, error) {
p.UserQuota = &v
}
}
if s := strings.TrimSpace(c.GetString("admin_identity_id")); s != "" {
if v, err := strconv.ParseUint(s, 10, 64); err == nil {
p.AdminIdentityID = v
}
}
p.AdminAccount = strings.TrimSpace(c.GetString("admin_account"))
p.AdminPhone = strings.TrimSpace(c.GetString("admin_phone"))
p.AdminEmail = strings.TrimSpace(c.GetString("admin_email"))
// 如果关键字段为空,尝试从 JSON body 解析(editTenant 靘莤 JSONďź?
if p.TenantName == "" && p.TenantCode == "" {
@@ -275,6 +293,72 @@ func (c *PlatformTenantController) parseTenantPayload() (tenantPayload, error) {
return p, nil
}
// bindTenantAdminFromPayload 把已有认证身份绑定为新租户的管理员(不新建账号、不覆盖密码)。
// 优先 AdminIdentityID;否则按 AdminAccount/AdminPhone/AdminEmail 归并键(手机>邮箱>账号)定位。
// 返回 (是否绑定, 错误);未指定管理员时返回 (false, nil)。
func bindTenantAdminFromPayload(tid uint64, p tenantPayload) (bool, error) {
identityID := p.AdminIdentityID
if identityID == 0 && (p.AdminAccount != "" || p.AdminPhone != "" || p.AdminEmail != "") {
existed, hit := authsvc.LocateIdentityByKey(p.AdminAccount, p.AdminPhone, p.AdminEmail)
if !hit {
return false, errors.New("未找到该账号对应的认证身份")
}
identityID = existed.ID
}
if identityID == 0 {
return false, nil // 未指定管理员,跳过
}
identity, err := authsvc.GetIdentityByID(identityID)
if err != nil || identity == nil {
return false, errors.New("认证身份不存在")
}
// 企业内账号:显式指定 > 身份手机号 > user{id};姓名取身份昵称
account := p.AdminAccount
if account == "" && identity.Mobile != nil {
account = strings.TrimSpace(*identity.Mobile)
}
if account == "" {
account = fmt.Sprintf("user%d", identity.ID)
}
name := "租户管理员"
if identity.Nickname != nil && strings.TrimSpace(*identity.Nickname) != "" {
name = strings.TrimSpace(*identity.Nickname)
}
// 老表镜像身份层现有密码哈希(登录仍以认证中心为准,不在建租户时改密)
var pwd *string
if identity.PasswordHash != nil {
h := *identity.PasswordHash
pwd = &h
}
// isDefault=0:不改变该用户当前的默认企业
if _, err := services.BindTenantUser(tid, identity.ID, &account, &name, identity.Mobile, identity.Email, nil, nil, pwd, 0, 1, nil); err != nil {
return false, err
}
// 绑定全局「租户管理员」角色(拥有 backend 全权限)
if adminRoleID, err := models.GetTenantAdminRole(); err == nil && adminRoleID > 0 {
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).
Filter("uid", identity.ID).
Update(map[string]interface{}{"group_id": adminRoleID})
}
// 双写:同步到统一认证中心(建 yz_auth_tenant_user 绑定)
var row models.SystemTenantUser
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).
Filter("uid", identity.ID).
One(&row); e == nil {
syncTenantUserToAuth(&row, false)
}
return true, nil
}
// CreateTenant 创建租户
// POST /platform/tenant/createTenant
func (c *PlatformTenantController) CreateTenant() {
@@ -369,10 +453,25 @@ func (c *PlatformTenantController) CreateTenant() {
return
}
// 指定已有身份为租户管理员(可选):只建绑定,不新建账号、不改密码。
// 绑定失败不影响租户创建结果,仅返回警告信息。
adminBound := false
adminWarn := ""
if bound, bErr := bindTenantAdminFromPayload(uint64(id), p); bErr != nil {
adminWarn = bErr.Error()
} else {
adminBound = bound
}
msg := "success"
if adminWarn != "" {
msg = "租户已创建,但指定管理员绑定失败: " + adminWarn
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
"data": map[string]interface{}{"id": id},
"msg": msg,
"data": map[string]interface{}{"id": id, "admin_bound": adminBound},
}
_ = c.ServeJSON()
}
+215
View File
@@ -0,0 +1,215 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"server/models"
"server/pkg/jwtutil"
"server/services"
authsvc "server/services/auth"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformTenantApplyController 平台端「企业创建审核」
// 审批注册用户提交的创建企业申请:通过 → 自动建租户并把申请人绑定为租户管理员。
type PlatformTenantApplyController struct {
beego.Controller
}
func (c *PlatformTenantApplyController) platformClaims() (*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("token 格式错误")
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
return nil, fmt.Errorf("token 无效")
}
return claims, nil
}
func (c *PlatformTenantApplyController) 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 *PlatformTenantApplyController) readJSON(v interface{}) bool {
body := c.Ctx.Input.RequestBody
if len(body) == 0 {
var err error
body, err = io.ReadAll(c.Ctx.Request.Body)
if err != nil || len(body) == 0 {
c.jsonErr(400, 400, "参数错误")
return false
}
}
if err := json.Unmarshal(body, v); err != nil {
c.jsonErr(400, 400, "参数错误")
return false
}
return true
}
// reviewerName 平台审核人姓名
func (c *PlatformTenantApplyController) reviewerName(claims *jwtutil.Claims) string {
var admin models.AdminUser
if err := models.Orm.QueryTable(new(models.AdminUser)).
Filter("id", claims.UserID).
One(&admin); err == nil && admin.Name != nil && strings.TrimSpace(*admin.Name) != "" {
return strings.TrimSpace(*admin.Name)
}
return claims.Username
}
// List GET /platform/tenantApply/list?page=1&pageSize=20&type=&status=
func (c *PlatformTenantApplyController) List() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
_ = claims
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
var applyType *int8
if s := strings.TrimSpace(c.GetString("type")); s != "" {
if v, e := strconv.ParseInt(s, 10, 8); e == nil {
tmp := int8(v)
applyType = &tmp
}
}
var status *int8
if s := strings.TrimSpace(c.GetString("status")); s != "" {
if v, e := strconv.ParseInt(s, 10, 8); e == nil {
tmp := int8(v)
status = &tmp
}
}
rows, total, err := services.ListTenantAppliesForPlatform(applyType, status, page, pageSize)
if err != nil {
c.jsonErr(500, 500, "获取申请列表失败")
return
}
type applyDTO struct {
models.AuthTenantApply
IdentityNickname string `json:"identity_nickname"`
IdentityMobile string `json:"identity_mobile"`
}
list := make([]applyDTO, 0, len(rows))
for _, r := range rows {
dto := applyDTO{AuthTenantApply: r}
if identity, ierr := authsvc.GetIdentityByID(r.IdentityID); ierr == nil && identity != nil {
if identity.Nickname != nil {
dto.IdentityNickname = *identity.Nickname
}
if identity.Mobile != nil {
dto.IdentityMobile = *identity.Mobile
}
}
list = append(list, dto)
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
"data": map[string]interface{}{"list": list, "total": total},
}
_ = c.ServeJSON()
}
// Detail GET /platform/tenantApply/detail/:id
func (c *PlatformTenantApplyController) Detail() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
_ = claims
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
var apply models.AuthTenantApply
if err := models.Orm.QueryTable(new(models.AuthTenantApply)).
Filter("id", id).
Filter("delete_time__isnull", true).
One(&apply); err != nil {
c.jsonErr(404, 404, "申请不存在")
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": apply}
_ = c.ServeJSON()
}
// Approve POST /platform/tenantApply/approve/:id
func (c *PlatformTenantApplyController) Approve() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
tid, err := services.ApproveCreateApply(id, uint64(claims.UserID), c.reviewerName(claims))
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
msg := "已通过,租户创建成功,申请人已绑定为该企业管理员"
if tid > 0 {
msg = fmt.Sprintf("已通过:租户已创建(ID:%d),申请人已绑定为企业管理员", tid)
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": msg, "data": map[string]interface{}{"tid": tid}}
_ = c.ServeJSON()
}
// Reject POST /platform/tenantApply/reject/:id { reason }
func (c *PlatformTenantApplyController) Reject() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
_ = claims
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
var req struct {
Reason string `json:"reason"`
}
if !c.readJSON(&req) {
return
}
if err := services.RejectCreateApply(id, uint64(claims.UserID), c.reviewerName(claims), strings.TrimSpace(req.Reason)); err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "已驳回"}
_ = c.ServeJSON()
}
+15 -11
View File
@@ -21,17 +21,20 @@ import (
)
// syncTenantUserToAuth 把租户用户同步到统一认证中心(双写)。
// updatePassword:身份已存在时是否允许同步密码(仅改密场景传 true;
// 新增/编辑/绑定场景传 false,避免把身份层密码覆盖回旧值)。
// 同步失败只记日志,不影响平台端主流程(认证中心为新增能力)。
func syncTenantUserToAuth(row *models.SystemTenantUser) {
func syncTenantUserToAuth(row *models.SystemTenantUser, updatePassword bool) {
if row == nil {
return
}
in := authsvc.SyncTenantUserInput{
Tid: row.Tid,
GroupID: row.GroupID,
OrgID: row.OrgID,
Status: row.Status,
IsDefault: row.IsDefault,
Tid: row.Tid,
GroupID: row.GroupID,
OrgID: row.OrgID,
Status: row.Status,
IsDefault: row.IsDefault,
UpdatePassword: updatePassword,
}
if row.Account != nil {
in.Account = strings.TrimSpace(*row.Account)
@@ -293,10 +296,11 @@ func (c *PlatformTenantUserController) CreateTenantUser() {
}
}
// 双写:同步到统一认证中心(读回最新记录,确保 group_id/org_id 已落库)
// 双写:同步到统一认证中心(读回最新记录,确保 group_id/org_id 已落库);
// 新建身份时密码已随身份创建写入;已存在身份不覆盖密码
var created models.SystemTenantUser
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&created); err == nil {
syncTenantUserToAuth(&created)
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&created); e == nil {
syncTenantUserToAuth(&created, false)
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": map[string]interface{}{"id": id}}
@@ -376,10 +380,10 @@ func (c *PlatformTenantUserController) EditTenantUser() {
_ = services.SetDefaultTenant(p.Uid, p.Tid)
}
// 双写:同步到统一认证中心
// 双写:同步到统一认证中心(仅本次显式修改了密码时才同步密码字段)
var updated models.SystemTenantUser
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&updated); err == nil {
syncTenantUserToAuth(&updated)
syncTenantUserToAuth(&updated, p.Password != nil)
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
+88 -1
View File
@@ -4,11 +4,13 @@ import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"server/models"
"server/pkg/jwtutil"
"server/services/wechatmp"
"server/services/wechatmini"
"server/services/wechatmp"
beego "github.com/beego/beego/v2/server/web"
)
@@ -129,3 +131,88 @@ func (c *PlatformWechatMiniController) SaveConfig() {
}
c.ok(nil, "保存成功")
}
// ListApps GET /platform/wechatMini/apps 多小程序应用列表(AppSecret 不回显)
func (c *PlatformWechatMiniController) ListApps() {
if _, err := c.claims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
rows, err := wechatmini.ListApps()
if err != nil {
c.jsonErr(500, 500, "获取应用列表失败")
return
}
type appDTO struct {
models.WechatMiniApp
Configured bool `json:"configured"` // AppSecret 已配置
}
list := make([]appDTO, 0, len(rows))
for _, r := range rows {
dto := appDTO{WechatMiniApp: r, Configured: r.AppSecret != ""}
list = append(list, dto)
}
c.ok(map[string]interface{}{"list": list}, "获取成功")
}
// SaveApp POST /platform/wechatMini/app/save 新增/更新应用
func (c *PlatformWechatMiniController) SaveApp() {
if _, err := c.claims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
var p struct {
ID uint64 `json:"id"`
Name string `json:"name"`
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 strings.TrimSpace(p.Name) == "" {
c.jsonErr(400, 400, "应用名称不能为空")
return
}
id, err := wechatmini.SaveApp(wechatmini.SaveAppInput{
ID: p.ID,
Name: p.Name,
AppID: p.AppID,
AppSecret: p.AppSecret,
Enabled: p.Enabled == 1,
AutoBind: p.AutoBind == 1,
Remark: p.Remark,
})
if err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.ok(map[string]interface{}{"id": id}, "保存成功")
}
// DeleteApp POST /platform/wechatMini/app/delete/:id 软删应用并停用
func (c *PlatformWechatMiniController) DeleteApp() {
if _, err := c.claims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
if err := wechatmini.DeleteApp(id); err != nil {
c.jsonErr(400, 400, err.Error())
return
}
c.ok(nil, "已删除")
}