做双端审批流

This commit is contained in:
hero920103 committed 2026-10-07 13:46:32 +08:00
1 parent 5b4dbb2b46
commit ffaadc26ae
69 files changed
+6662 -1071

No files matched your search

+264
View File
@@ -0,0 +1,264 @@
package controllers
import (
"strconv"
"strings"
"server/models"
"server/pkg/jwtutil"
"server/services"
"github.com/beego/beego/v2/client/orm"
)
// 移动端员工档案「全公司视角」接口(HR 使用)。
//
// 与 Web 端(/backend/oa/employeefile/*)的关系:
// - 数据口径完全一致:一人一档(yz_backend_employee_file)+ 教育/工作/合同/证照子记录,
// DTO 组装直接复用 Web 端 efAssembleDTOList,保证两端展示字段完全一致;
// - 只读:建档、改档、离职等写操作仍只在 Web 端提供;
// - 数据一律按 JWT 中的 TenantId 隔离,前端无法指定租户。
//
// 权限口径:租户角色的 rights 里勾选了「员工档案」菜单(或其祖先节点)即视为 HR,可见全公司档案;
// 租户管理员 / 未分配角色 / 角色未配置权限等既有「全权限」情形同样放行。
// 未放行的用户仍可查看本人档案(/api/oa/employee-file),前端据此展示不同视图。
// employeeFileMenuIDs 命中「员工档案」菜单的菜单ID集合(含其祖先节点)。
// 菜单表由租户自行维护,标题/路径可能被改写,因此按 路径关键字 + 标题关键字 双重匹配,
// 与移动端菜单跳转规则(uniapp/utils/nav.js 中的 employeefile 规则)保持同一识别口径。
func employeeFileMenuIDs() map[uint64]bool {
ids := map[uint64]bool{}
if models.Orm == nil {
return ids
}
var rows []models.SystemMenu
if _, err := models.Orm.QueryTable(new(models.SystemMenu)).
Filter("status", 1).
Filter("delete_time__isnull", true).
All(&rows); err != nil {
return ids
}
pidOf := make(map[uint64]int64, len(rows))
hit := map[uint64]bool{}
for _, m := range rows {
pidOf[m.ID] = m.Pid
hay := strings.ToLower(m.Title + " " + derefString(m.Path) + " " + derefString(m.ComponentPath))
if strings.Contains(hay, "employeefile") || strings.Contains(hay, "employee-file") ||
strings.Contains(m.Title, "员工档案") || strings.Contains(m.Title, "人事档案") {
hit[m.ID] = true
}
}
if len(hit) == 0 {
return ids
}
// 角色权限勾到父目录即代表含全部下级,因此把命中菜单的祖先一并纳入判定。
for id := range hit {
cur := id
for i := 0; i < 32; i++ { // 限制深度,防御脏数据造成的环
if _, ok := pidOf[cur]; !ok {
break
}
ids[cur] = true
pid := pidOf[cur]
if pid <= 0 {
break
}
cur = uint64(pid)
}
}
return ids
}
// canViewAllEmployeeFiles 判断当前用户是否有权查看全公司员工档案(HR 视角)。
func canViewAllEmployeeFiles(claims *jwtutil.Claims) bool {
if claims == nil || claims.TenantId <= 0 {
return false
}
// 平台管理员
if claims.UserType == "platform" {
return true
}
// 租户管理员 / 未分配角色 / 角色未配置权限 —— 与 Web 端 isTenantAdmin 同一口径
if isTenantAdmin(claims.UserID, claims.TenantId) {
return true
}
var tu models.SystemTenantUser
if models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("uid", tenantUIDOf(claims)).
Filter("tid", claims.TenantId).
Filter("delete_time__isnull", true).One(&tu) != nil {
return false
}
if tu.GroupID == 0 {
return true
}
var role models.AdminRole
if models.Orm.QueryTable(new(models.AdminRole)).
Filter("id", tu.GroupID).
Filter("cid", 2).One(&role) != nil {
return false
}
if strings.TrimSpace(role.Name) == "租户管理员" {
return true
}
rights := ""
if role.Rights != nil {
rights = strings.TrimSpace(*role.Rights)
}
if rights == "" || rights == "[]" || rights == "null" {
return true
}
allowed := parseRightsToSet(rights)
if len(allowed) == 0 {
return true
}
for id := range employeeFileMenuIDs() {
if allowed[id] {
return true
}
}
return false
}
// tenantUIDOf 令牌 user_id 是认证中心 identity_id,换算成本租户的老 uid。
func tenantUIDOf(claims *jwtutil.Claims) uint64 {
return services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID))
}
// EmployeeFileAccess GET /api/oa/employee-file/access
// 返回当前用户是否具备 HR 视角(可见全公司档案),前端据此决定展示列表还是本人档案。
func (c *AppOaController) EmployeeFileAccess() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
c.oaOk(map[string]interface{}{"can_view_all": canViewAllEmployeeFiles(claims)})
}
// EmployeeFileList GET /api/oa/employee-file/list 全公司档案分页列表(需 HR 权限)。
// 支持 keyword(档案编号/身份证/员工姓名/账号/手机)、status(在职状态)、org_id(部门,含下级)。
func (c *AppOaController) EmployeeFileList() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
if !canViewAllEmployeeFiles(claims) {
c.oaErr(403, 403, "您没有查看全部员工档案的权限")
return
}
tid := claims.TenantId
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
keyword := strings.TrimSpace(c.GetString("keyword"))
statusRaw := strings.TrimSpace(c.GetString("status"))
orgID, _ := c.GetUint64("org_id")
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
web := &BackendEmployeeFileController{}
qs := web.efQuery(tid)
if orgID > 0 {
empIDs := web.efEmployeeIDsInOrgs(tid, web.efSubtreeOrgIDs(tid, orgID))
if len(empIDs) == 0 {
c.oaOk(map[string]interface{}{"list": []employeeFileDTO{}, "total": 0, "page": page, "pageSize": pageSize})
return
}
qs = qs.Filter("employee_id__in", empIDs)
}
if statusRaw != "" {
status, convErr := strconv.Atoi(statusRaw)
if convErr != nil || status < 1 || status > 3 {
c.oaErr(400, 400, "在职状态参数无效")
return
}
qs = qs.Filter("employment_status", int8(status))
}
if keyword != "" {
// 关键词同时命中档案自身字段(编号/身份证)与员工字段(姓名/账号/手机)
empIDs := web.efEmployeeIDsByKeyword(tid, keyword)
cond := orm.NewCondition().
Or("file_no__icontains", keyword).
Or("id_card__icontains", keyword)
if len(empIDs) > 0 {
cond = cond.Or("employee_id__in", empIDs)
}
qs = qs.SetCond(orm.NewCondition().AndCond(cond))
}
total, _ := qs.Count()
var files []models.BackendEmployeeFile
if _, err := qs.OrderBy("-id").Limit(pageSize).Offset((page - 1) * pageSize).All(&files); err != nil && err != orm.ErrNoRows {
c.oaErr(500, 500, "查询失败")
return
}
c.oaOk(map[string]interface{}{
"list": web.efAssembleDTOList(tid, files),
"total": total,
"page": page,
"pageSize": pageSize,
})
}
// EmployeeFileDetail GET /api/oa/employee-file/detail/:id 员工档案详情(含子记录)。
//
// 两种访问路径:
// - HR 视角:可查看任意员工的档案(租户内隔离);
// - 普通员工:仅可查看本人档案,看他人档案返回 403。
func (c *AppOaController) EmployeeFileDetail() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
tid := claims.TenantId
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
c.oaErr(400, 400, "无效的档案ID")
return
}
web := &BackendEmployeeFileController{}
var file models.BackendEmployeeFile
if web.efQuery(tid).Filter("id", id).One(&file) != nil {
c.oaErr(404, 404, "档案不存在")
return
}
if !canViewAllEmployeeFiles(claims) {
emp, ok := c.oaMyEmployee(claims)
if !ok || uint64(emp.ID) != file.EmployeeID {
c.oaErr(403, 403, "您没有查看他人档案的权限")
return
}
}
var records []models.BackendEmployeeFileRecord
if _, err := models.Orm.QueryTable(new(models.BackendEmployeeFileRecord)).
Filter("tid", tid).
Filter("is_deleted", 0).
Filter("employee_id", file.EmployeeID).
OrderBy("sort", "id").
All(&records); err != nil && err != orm.ErrNoRows {
c.oaErr(500, 500, "查询档案记录失败")
return
}
if records == nil {
records = []models.BackendEmployeeFileRecord{}
}
dtos := web.efAssembleDTOList(tid, []models.BackendEmployeeFile{file})
c.oaOk(map[string]interface{}{"file": dtos[0], "records": records})
}
+466
View File
@@ -0,0 +1,466 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"server/services"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// BackendApprovalController 租户端审批流配置(流程头 + 节点编排)。
//
// 与平台端差异:
// - 数据按登录租户隔离(tid 取自 JWT,写入/查询均带 tid);
// - 「功能」下拉取当前租户已开通的模块(SystemModules),流程 module_code / biz_type 均绑定模块 code,
// 实现「选功能 → 命名 → 把审批流引用到该功能」;
// - 审批人取本租户主体:租户用户 / 租户角色 / 部门主管 / 职位 / 发起人 / 发起人主管(引擎支持 6 种)。
type BackendApprovalController struct {
beego.Controller
}
// readTenantBody 读取并解析 JSON 请求体
func (c *BackendApprovalController) readTenantBody(dst interface{}) error {
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
return fmt.Errorf("参数错误")
}
if err := json.Unmarshal(raw, dst); err != nil {
return fmt.Errorf("参数错误: %s", err.Error())
}
return nil
}
// tenantFlowList GET /backend/approval/flow/list?module=oa
// 当前租户的流程列表;按分类(module_code)过滤,附带节点数。
func (c *BackendApprovalController) FlowList() {
claims, err := requireBackend(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
module := strings.TrimSpace(c.GetString("module"))
qs := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
Filter("tid", tid).Filter("is_deleted", 0)
if module != "" {
qs = qs.Filter("module_code", module)
}
var rows []models.TenantApprovalFlow
_, _ = qs.OrderBy("-is_default", "id").All(&rows)
list := make([]approvalFlowView, 0, len(rows))
for _, r := range rows {
cnt, _ := models.Orm.QueryTable(new(models.TenantApprovalNode)).
Filter("flow_id", r.ID).Filter("is_deleted", 0).Count()
list = append(list, approvalFlowView{
ID: r.ID,
ModuleCode: r.ModuleCode,
BizType: r.BizType,
FlowCode: r.FlowCode,
FlowName: r.FlowName,
Description: r.Description,
IsDefault: r.IsDefault,
Status: r.Status,
NodeCount: int(cnt),
CreateTime: r.CreateTime.Format("2006-01-02 15:04:05"),
})
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": list}
_ = c.ServeJSON()
}
// FlowDetail GET /backend/approval/flow/detail/:id
// 返回本租户某流程及其全部节点(按 sort 升序),供编排界面回显。
func (c *BackendApprovalController) FlowDetail() {
claims, err := requireBackend(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
var f models.TenantApprovalFlow
if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
Filter("id", id).Filter("tid", tid).Filter("is_deleted", 0).One(&f); err != nil {
jsonErr(&c.Controller, 404, 404, "流程不存在")
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": tenantFlowToView(f)}
_ = c.ServeJSON()
}
// tenantFlowToView 组装租户流程视图(含节点与连线)
func tenantFlowToView(f models.TenantApprovalFlow) approvalFlowView {
var nodes []models.TenantApprovalNode
_, _ = models.Orm.QueryTable(new(models.TenantApprovalNode)).
Filter("flow_id", f.ID).Filter("is_deleted", 0).
OrderBy("sort", "id").All(&nodes)
view := approvalFlowView{
ID: f.ID,
ModuleCode: f.ModuleCode,
BizType: f.BizType,
FlowCode: f.FlowCode,
FlowName: f.FlowName,
Description: f.Description,
IsDefault: f.IsDefault,
Status: f.Status,
NodeCount: len(nodes),
CreateTime: f.CreateTime.Format("2006-01-02 15:04:05"),
Nodes: make([]approvalNodePayload, 0, len(nodes)),
}
edgeNodes := make([]flowEdgeNode, 0, len(nodes))
for _, n := range nodes {
view.Nodes = append(view.Nodes, approvalNodePayload{
NodeKey: n.NodeKey,
NodeName: n.NodeName,
NodeType: n.NodeType,
ApproverType: n.ApproverType,
ApproverIDs: parseIDSlice(n.ApproverIDs),
MultiMode: n.MultiMode,
AllowReturn: n.AllowReturn,
TimeoutHours: n.TimeoutHours,
Sort: n.Sort,
PosX: n.PosX,
PosY: n.PosY,
ConditionExpr: n.ConditionExpr,
})
edgeNodes = append(edgeNodes, flowEdgeNode{NodeKey: n.NodeKey, NextKeys: n.NextKeys, Condition: n.ConditionExpr})
}
view.Edges = rebuildEdges(edgeNodes)
return view
}
// FlowSave POST /backend/approval/flow/save
// 新建或更新本租户流程;节点采用「全量覆盖」语义(先软删旧节点,再按传入顺序重建)。
func (c *BackendApprovalController) FlowSave() {
claims, err := requireBackend(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
var p approvalFlowSave
if err := c.readTenantBody(&p); err != nil {
jsonErr(&c.Controller, 400, 400, err.Error())
return
}
if strings.TrimSpace(p.FlowName) == "" {
jsonErr(&c.Controller, 400, 400, "流程名称不能为空")
return
}
if strings.TrimSpace(p.ModuleCode) == "" {
jsonErr(&c.Controller, 400, 400, "请选择要配置的分类")
return
}
// 分类与单据类型对齐:运行期按 biz_type=分类 code 命中默认流
if strings.TrimSpace(p.BizType) == "" {
p.BizType = p.ModuleCode
}
if err := validateTenantApprovalNodes(p.Nodes); err != nil {
jsonErr(&c.Controller, 400, 400, err.Error())
return
}
if err := validateApprovalGraph(p.Nodes, p.Edges); err != nil {
jsonErr(&c.Controller, 400, 400, err.Error())
return
}
g := buildGraph(p.Nodes, p.Edges)
now := time.Now()
o := models.Orm
var flowID uint64
var flowCode string
if p.ID > 0 {
var exist models.TenantApprovalFlow
if err := o.QueryTable(new(models.TenantApprovalFlow)).
Filter("id", p.ID).Filter("tid", tid).Filter("is_deleted", 0).One(&exist); err != nil {
jsonErr(&c.Controller, 404, 404, "流程不存在")
return
}
flowID = p.ID
flowCode = exist.FlowCode
_, err := o.QueryTable(new(models.TenantApprovalFlow)).
Filter("id", flowID).Filter("tid", tid).Update(orm.Params{
"module_code": p.ModuleCode,
"biz_type": p.BizType,
"flow_name": p.FlowName,
"description": p.Description,
"is_default": p.IsDefault,
"status": p.Status,
"update_time": now,
})
if err != nil {
jsonErr(&c.Controller, 500, 500, "保存失败: "+err.Error())
return
}
} else {
row := models.TenantApprovalFlow{
Tid: tid,
ModuleCode: p.ModuleCode,
BizType: p.BizType,
FlowName: p.FlowName,
Description: p.Description,
IsDefault: p.IsDefault,
Status: p.Status,
CreateTime: now,
}
id, err := o.Insert(&row)
if err != nil {
jsonErr(&c.Controller, 500, 500, "保存失败: "+err.Error())
return
}
flowID = uint64(id)
flowCode = genApprovalFlowCode(flowID)
_, _ = o.QueryTable(new(models.TenantApprovalFlow)).Filter("id", flowID).Filter("tid", tid).
Update(orm.Params{"flow_code": flowCode})
}
// 默认流程唯一:同租户同单据类型下只允许一条 is_default=1
if p.IsDefault == 1 {
_, _ = o.QueryTable(new(models.TenantApprovalFlow)).
Filter("tid", tid).Filter("biz_type", p.BizType).Filter("is_deleted", 0).
Exclude("id", flowID).Update(orm.Params{"is_default": 0, "update_time": now})
}
// 覆盖节点:软删旧节点后按 sort 重建
_, _ = o.QueryTable(new(models.TenantApprovalNode)).
Filter("flow_id", flowID).Filter("is_deleted", 0).
Update(orm.Params{"is_deleted": 1, "delete_time": now})
for i, nd := range p.Nodes {
sort := nd.Sort
if sort == 0 {
sort = i + 1
}
nodeKey := strings.TrimSpace(nd.NodeKey)
if nodeKey == "" {
nodeKey = fmt.Sprintf("n%d", sort)
}
nodeType := nd.NodeType
if nodeType == 0 {
nodeType = models.ApprovalNodeApproval
}
multi := nd.MultiMode
if multi != models.ApprovalMultiAll {
multi = models.ApprovalMultiAny
}
timeout := nd.TimeoutHours
if timeout <= 0 {
timeout = 48
}
node := models.TenantApprovalNode{
FlowID: flowID,
Tid: tid,
NodeKey: nodeKey,
NodeName: strings.TrimSpace(nd.NodeName),
NodeType: nodeType,
ApproverType: nd.ApproverType,
ApproverIDs: joinIDs(nd.ApproverIDs),
MultiMode: multi,
AllowReturn: nd.AllowReturn,
TimeoutHours: timeout,
Sort: sort,
PosX: nd.PosX,
PosY: nd.PosY,
PrevKeys: strings.Join(g.prev[nodeKey], ","),
NextKeys: strings.Join(g.next[nodeKey], ","),
ConditionExpr: g.cond[nodeKey],
CreateTime: now,
}
if _, err := o.Insert(&node); err != nil {
jsonErr(&c.Controller, 500, 500, "保存节点失败: "+err.Error())
return
}
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功", "data": map[string]interface{}{"id": flowID, "flow_code": flowCode}}
_ = c.ServeJSON()
}
// Pick GET /backend/approval/flow/pick
// 轻量清单:按分类返回本租户启用流程的 {flow_code, flow_name},供其它功能“选/填编号调用”。
func (c *BackendApprovalController) Pick() {
claims, err := requireBackend(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
module := strings.TrimSpace(c.GetString("module"))
qs := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
Filter("tid", tid).Filter("is_deleted", 0).Filter("status", 1)
if module != "" {
qs = qs.Filter("module_code", module)
}
var rows []models.TenantApprovalFlow
_, _ = qs.OrderBy("module_code", "id").All(&rows)
list := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
list = append(list, map[string]interface{}{
"module_code": r.ModuleCode,
"flow_code": r.FlowCode,
"flow_name": r.FlowName,
})
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": list}
_ = c.ServeJSON()
}
// FlowDelete DELETE /backend/approval/flow/delete/:id
// 软删本租户流程及其节点。存在在途实例时禁止删除,避免审批中断。
func (c *BackendApprovalController) FlowDelete() {
claims, err := requireBackend(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
var f models.TenantApprovalFlow
if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
Filter("id", id).Filter("tid", tid).Filter("is_deleted", 0).One(&f); err != nil {
jsonErr(&c.Controller, 404, 404, "流程不存在")
return
}
running, _ := models.Orm.QueryTable(new(models.ApprovalInstance)).
Filter("flow_id", id).Filter("scope", models.ApprovalScopeTenant).
Filter("status", models.ApprovalInstanceRunning).Filter("is_deleted", 0).Count()
if running > 0 {
jsonErr(&c.Controller, 400, 400, fmt.Sprintf("该流程还有 %d 条在途审批,暂不能删除", running))
return
}
now := time.Now()
_, _ = models.Orm.QueryTable(new(models.TenantApprovalFlow)).Filter("id", id).Filter("tid", tid).
Update(orm.Params{"is_deleted": 1, "delete_time": now, "update_time": now})
_, _ = models.Orm.QueryTable(new(models.TenantApprovalNode)).Filter("flow_id", id).Filter("tid", tid).
Update(orm.Params{"is_deleted": 1, "delete_time": now})
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "已删除"}
_ = c.ServeJSON()
}
// Options GET /backend/approval/options
// 编排界面数据源:当前租户的功能模块 + 审批人候选(用户 / 角色 / 部门 / 职位)。
func (c *BackendApprovalController) Options() {
claims, err := requireBackend(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
// 功能模块:状态可用、Web 可见,并标记当前租户是否已开通
var mods []models.SystemModules
_, _ = models.Orm.QueryTable(new(models.SystemModules)).
Filter("delete_time__isnull", true).Filter("status", 1).Filter("is_show", 1).
OrderBy("sort", "id").All(&mods)
mods = services.FilterModulesByPlatform(mods, models.PlatformWeb)
allowed := services.GetTenantModuleCodes(tid)
moduleList := make([]map[string]interface{}, 0, len(mods))
for _, m := range mods {
enabled := true
if len(allowed) > 0 {
enabled = allowed[services.NormalizeModuleCode(m.Code)]
}
moduleList = append(moduleList, map[string]interface{}{
"code": m.Code, "name": m.Name, "enabled": enabled,
})
}
// 租户用户
var tu []models.SystemTenantUser
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).Filter("status", 1).Filter("delete_time__isnull", true).
OrderBy("id").All(&tu)
userList := make([]map[string]interface{}, 0, len(tu))
for _, u := range tu {
userList = append(userList, map[string]interface{}{
"id": u.Uid, "name": firstNonEmptyStr(u.Name, u.Account),
})
}
// 租户角色(cid=2,含平台预置 tenant_id=0 与本租户自定义)
var ar []models.AdminRole
_, _ = models.Orm.QueryTable(new(models.AdminRole)).
Filter("cid", 2).Filter("tenant_id__in", []uint64{0, tid}).
Filter("status", 1).Filter("delete_time__isnull", true).
OrderBy("id").All(&ar)
roleList := make([]map[string]interface{}, 0, len(ar))
for _, r := range ar {
roleList = append(roleList, map[string]interface{}{"id": r.ID, "name": r.Name})
}
// 部门 / 职位
var og []models.BackendOrganization
_, _ = models.Orm.QueryTable(new(models.BackendOrganization)).
Filter("tid", tid).Filter("status", 1).Filter("delete_time__isnull", true).
OrderBy("sort", "id").All(&og)
orgList := make([]map[string]interface{}, 0, len(og))
for _, o := range og {
orgList = append(orgList, map[string]interface{}{"id": o.ID, "name": o.OrgName})
}
var ps []models.BackendPosition
_, _ = models.Orm.QueryTable(new(models.BackendPosition)).
Filter("tid", tid).Filter("status", 1).Filter("delete_time__isnull", true).
OrderBy("sort", "id").All(&ps)
posList := make([]map[string]interface{}, 0, len(ps))
for _, p := range ps {
posList = append(posList, map[string]interface{}{"id": p.ID, "name": p.PositionName})
}
c.Data["json"] = map[string]interface{}{
"code": 200, "msg": "ok",
"data": map[string]interface{}{
"modules": moduleList,
"users": userList,
"roles": roleList,
"orgs": orgList,
"positions": posList,
},
}
_ = c.ServeJSON()
}
// ============================ 辅助函数 ============================
// firstNonEmptyStr 取字符串指针/普通字符串中第一个非空值
func firstNonEmptyStr(vals ...*string) string {
for _, v := range vals {
if v != nil && strings.TrimSpace(*v) != "" {
return strings.TrimSpace(*v)
}
}
return ""
}
// validateTenantApprovalNodes 校验租户节点:审批/条件节点须合法选择审批人来源。
// 租户侧支持 6 种审批人类型:1用户 2角色 3部门主管 4职位 5发起人本人 6发起人主管。
func validateTenantApprovalNodes(nodes []approvalNodePayload) error {
for i, nd := range nodes {
if nd.NodeType == models.ApprovalNodeStart || nd.NodeType == models.ApprovalNodeEnd ||
nd.NodeType == models.ApprovalNodeCondition {
continue
}
if strings.TrimSpace(nd.NodeName) == "" {
return fmt.Errorf("第 %d 个节点名称不能为空", i+1)
}
if nd.ApproverType < models.ApprovalApproverUser || nd.ApproverType > models.ApprovalApproverInitLeader {
return fmt.Errorf("节点「%s」审批人类型非法", nd.NodeName)
}
// 发起人相关类型无需指定对象;其余必须选择非空来源
needIDs := nd.ApproverType != models.ApprovalApproverInitiator &&
nd.ApproverType != models.ApprovalApproverInitLeader
if needIDs && len(nd.ApproverIDs) == 0 {
return fmt.Errorf("节点「%s」未选择审批人", nd.NodeName)
}
}
return nil
}
+4 -50
View File
@@ -564,58 +564,12 @@ func (c *BackendTenantDomainController) Apply() {
jsonErr(&c.Controller, 500, 500, "申请失败: "+err.Error())
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "申请提交成功,等待审核", "data": map[string]interface{}{"id": uint64(id)}}
_ = c.ServeJSON()
}
// Audit POST /backend/domain/tenant/audit body:{id,action} action=approve/reject
func (c *BackendTenantDomainController) Audit() {
if _, err := requireBackend(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
// 提交后进入平台侧审批流(backend 提起、platform 审核);严格:未配置流程/无有效审批人则提交失败并回滚
if errMsg := submitDomainApprovalChecked(uint64(id), tenantID, uint64(claims.UserID), claims.Username, full); errMsg != "" {
jsonErr(&c.Controller, 400, 400, errMsg)
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
jsonErr(&c.Controller, 400, 400, "参数错误")
return
}
var p struct {
ID uint64 `json:"id"`
Action string `json:"action"`
}
if err := json.Unmarshal(raw, &p); err != nil || p.ID == 0 {
jsonErr(&c.Controller, 400, 400, "参数错误")
return
}
var row models.SystemTenantDomain
if err := models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("id", p.ID).One(&row); err != nil {
jsonErr(&c.Controller, 404, 404, "域名不存在")
return
}
if row.DomainType == models.DomainTypeCustom {
jsonErr(&c.Controller, 400, 400, "自有域名无需人工审核,解析检测与证书签发通过后自动生效")
return
}
if row.Status != 0 {
jsonErr(&c.Controller, 400, 400, "该域名已审核过了")
return
}
newStatus := 2
msg := "已拒绝"
if strings.ToLower(strings.TrimSpace(p.Action)) == "approve" {
newStatus = 1
msg = "审核通过"
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("id", p.ID).Update(map[string]interface{}{
"status": newStatus,
"update_time": now,
})
if err != nil {
jsonErr(&c.Controller, 500, 500, "审核失败: "+err.Error())
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": msg}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "申请提交成功,等待审批", "data": map[string]interface{}{"id": uint64(id)}}
_ = c.ServeJSON()
}
@@ -677,6 +677,44 @@ func (c *BackendOnepageController) ListAll() {
_ = c.ServeJSON()
}
// GetOnePageByPath GET /backend/onepage/path/:path 按路径读取租户单页(后端端独立接口)
func (c *BackendOnepageController) GetOnePageByPath() {
claims, err := c.cmsClaims()
if err != nil {
c.cmsJSONErr(401, 401, err.Error())
return
}
tid := cmsEffectiveTid(&c.Controller, claims)
if tid == 0 {
c.cmsJSONErr(400, 400, "tid不能为空")
return
}
path := strings.TrimSpace(c.Ctx.Input.Param(":path"))
if path == "" {
c.cmsJSONErr(400, 400, "路径标识不能为空")
return
}
var row models.CmsFrontendOnepage
err = models.Orm.QueryTable(new(models.CmsFrontendOnepage)).
Filter("tid", tid).
Filter("path", path).
Filter("delete_time__isnull", true).
One(&row)
if err == orm.ErrNoRows {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": nil}
_ = c.ServeJSON()
return
}
if err != nil {
c.cmsJSONErr(500, 500, "获取单页失败")
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": cmsOnepageToMap(row)}
_ = c.ServeJSON()
}
// Create POST /backend/createonepage
func (c *BackendOnepageController) Create() {
claims, err := c.cmsClaims()
+65
View File
@@ -0,0 +1,65 @@
package controllers
import (
"strconv"
"server/models"
beego "github.com/beego/beego/v2/server/web"
)
// IndexUpgradeNoticeController 官网端「平台更新公告」公开接口。
// 数据同源于 models.PlatformUpgradeNotice,但作为官网端独立接口,
// 不与平台端 /platform、移动端 /api 复用同一 handler。
type IndexUpgradeNoticeController struct {
beego.Controller
}
// List GET /site/upgrade/list 官网首页公开展示的更新公告(仅启用、未删除)
func (c *IndexUpgradeNoticeController) List() {
limitStr := c.GetString("limit")
limit := 10
if limitStr != "" {
val, err := strconv.Atoi(limitStr)
if err == nil {
limit = val
}
}
if limit <= 0 || limit > 50 {
limit = 10
}
var upgradeList []models.PlatformUpgradeNotice
qs := models.Orm.QueryTable(new(models.PlatformUpgradeNotice)).
Filter("status", 1).
Filter("delete_time__isnull", true)
_, err := qs.OrderBy("-sort", "-create_time").Limit(limit).All(&upgradeList)
if err != nil {
c.Data["json"] = map[string]interface{}{
"code": 500,
"msg": "查询失败",
}
_ = c.ServeJSON()
return
}
data := make([]map[string]interface{}, 0, len(upgradeList))
for _, item := range upgradeList {
record := map[string]interface{}{
"id": item.ID,
"update_date": item.UpdateDate,
"title": item.Title,
"content": item.Content,
"create_time": item.CreateTime.Format("2006-01-02 15:04:05"),
}
data = append(data, record)
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
"data": data,
}
_ = c.ServeJSON()
}
+660
View File
@@ -0,0 +1,660 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"server/services"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformApprovalController 平台端审批流配置(流程头 + 节点编排)。
//
// 当期面向「域名申请」这类平台侧管控场景,按 module_code + biz_type 联合唯一组织流程;
// 平台账号不归属租户,故流程与节点均无 tid,审批人取平台用户 / 平台角色。
type PlatformApprovalController struct {
beego.Controller
}
// ---- 配置侧 JSON 结构 ----
// approvalNodePayload 单个节点(前端画布节点)。
// node_type:1开始 2审批 3结束 4条件网关;pos_x/pos_y 为画布坐标回显;
// condition_expr 为该节点作为「条件网关出边目标」时的进入条件(由 edges 反推落库)。
type approvalNodePayload struct {
NodeKey string `json:"node_key"`
NodeName string `json:"node_name"`
NodeType int8 `json:"node_type"` // 1开始 2审批 3结束 4条件网关
ApproverType int8 `json:"approver_type"` // 1指定用户 2指定角色 ...
ApproverIDs []uint64 `json:"approver_ids"`
MultiMode int8 `json:"multi_mode"` // 1或签 2会签
AllowReturn int8 `json:"allow_return"` // 是否允许退回
TimeoutHours int `json:"timeout_hours"` // 超时小时
Sort int `json:"sort"` // 顺序(越小越靠前)
PosX int `json:"pos_x"` // 画布 X 坐标
PosY int `json:"pos_y"` // 画布 Y 坐标
ConditionExpr string `json:"condition_expr"`
}
// approvalEdge 画布连线:from→to,condition 为该出边的分支条件(落到 to 节点的 condition_expr)
type approvalEdge struct {
From string `json:"from"`
To string `json:"to"`
Condition string `json:"condition"`
}
// approvalFlowSave 保存流程(图:节点 + 连线,节点全量覆盖)
type approvalFlowSave struct {
ID uint64 `json:"id"`
ModuleCode string `json:"module_code"`
BizType string `json:"biz_type"`
FlowName string `json:"flow_name"`
Description string `json:"description"`
IsDefault int8 `json:"is_default"`
Status int8 `json:"status"`
Nodes []approvalNodePayload `json:"nodes"`
Edges []approvalEdge `json:"edges"`
}
// approvalFlowView 流程 + 节点 + 连线(详情返回)
type approvalFlowView struct {
ID uint64 `json:"id"`
ModuleCode string `json:"module_code"`
BizType string `json:"biz_type"`
FlowCode string `json:"flow_code"`
FlowName string `json:"flow_name"`
Description string `json:"description"`
IsDefault int8 `json:"is_default"`
Status int8 `json:"status"`
NodeCount int `json:"node_count"`
CreateTime string `json:"create_time"`
Nodes []approvalNodePayload `json:"nodes"`
Edges []approvalEdge `json:"edges"`
}
// readApprovalBody 读取并解析 JSON 请求体
func (c *PlatformApprovalController) readApprovalBody(dst interface{}) error {
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
return fmt.Errorf("参数错误")
}
if err := json.Unmarshal(raw, dst); err != nil {
return fmt.Errorf("参数错误: %s", err.Error())
}
return nil
}
// FlowList GET /platform/approval/flow/list?biz_type=domain
// 平台端流程列表;可按单据类型过滤,附带节点数。
func (c *PlatformApprovalController) FlowList() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
bizType := strings.TrimSpace(c.GetString("biz_type"))
if bizType == "" {
bizType = strings.TrimSpace(c.GetString("module")) // 兼容:分类即业务类型
}
qs := models.Orm.QueryTable(new(models.PlatformApprovalFlow)).
Filter("is_deleted", 0)
if bizType != "" {
qs = qs.Filter("biz_type", bizType)
}
var rows []models.PlatformApprovalFlow
_, _ = qs.OrderBy("-is_default", "id").All(&rows)
list := make([]approvalFlowView, 0, len(rows))
for _, r := range rows {
cnt, _ := models.Orm.QueryTable(new(models.PlatformApprovalNode)).
Filter("flow_id", r.ID).Filter("is_deleted", 0).Count()
list = append(list, approvalFlowView{
ID: r.ID,
ModuleCode: r.ModuleCode,
BizType: r.BizType,
FlowCode: r.FlowCode,
FlowName: r.FlowName,
Description: r.Description,
IsDefault: r.IsDefault,
Status: r.Status,
NodeCount: int(cnt),
CreateTime: r.CreateTime.Format("2006-01-02 15:04:05"),
})
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": list}
_ = c.ServeJSON()
}
// FlowDetail GET /platform/approval/flow/detail/:id
// 返回流程及其全部节点(按 sort 升序),供编排界面回显。
func (c *PlatformApprovalController) FlowDetail() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
var f models.PlatformApprovalFlow
if err := models.Orm.QueryTable(new(models.PlatformApprovalFlow)).
Filter("id", id).Filter("is_deleted", 0).One(&f); err != nil {
jsonErr(&c.Controller, 404, 404, "流程不存在")
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": c.flowToView(f)}
_ = c.ServeJSON()
}
// flowToView 组装流程视图(含节点与连线)
func (c *PlatformApprovalController) flowToView(f models.PlatformApprovalFlow) approvalFlowView {
var nodes []models.PlatformApprovalNode
_, _ = models.Orm.QueryTable(new(models.PlatformApprovalNode)).
Filter("flow_id", f.ID).Filter("is_deleted", 0).
OrderBy("sort", "id").All(&nodes)
view := approvalFlowView{
ID: f.ID,
ModuleCode: f.ModuleCode,
BizType: f.BizType,
FlowCode: f.FlowCode,
FlowName: f.FlowName,
Description: f.Description,
IsDefault: f.IsDefault,
Status: f.Status,
NodeCount: len(nodes),
CreateTime: f.CreateTime.Format("2006-01-02 15:04:05"),
Nodes: make([]approvalNodePayload, 0, len(nodes)),
}
edgeNodes := make([]flowEdgeNode, 0, len(nodes))
for _, n := range nodes {
view.Nodes = append(view.Nodes, approvalNodePayload{
NodeKey: n.NodeKey,
NodeName: n.NodeName,
NodeType: n.NodeType,
ApproverType: n.ApproverType,
ApproverIDs: parseIDSlice(n.ApproverIDs),
MultiMode: n.MultiMode,
AllowReturn: n.AllowReturn,
TimeoutHours: n.TimeoutHours,
Sort: n.Sort,
PosX: n.PosX,
PosY: n.PosY,
ConditionExpr: n.ConditionExpr,
})
edgeNodes = append(edgeNodes, flowEdgeNode{NodeKey: n.NodeKey, NextKeys: n.NextKeys, Condition: n.ConditionExpr})
}
view.Edges = rebuildEdges(edgeNodes)
return view
}
// FlowSave POST /platform/approval/flow/save
// 新建或更新流程;节点采用「全量覆盖」语义(先软删旧节点,再按传入顺序重建)。
func (c *PlatformApprovalController) FlowSave() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
var p approvalFlowSave
if err := c.readApprovalBody(&p); err != nil {
jsonErr(&c.Controller, 400, 400, err.Error())
return
}
if strings.TrimSpace(p.FlowName) == "" {
jsonErr(&c.Controller, 400, 400, "流程名称不能为空")
return
}
// 分类(module_code)与单据类型(biz_type)对齐:二者留空时互补,至少其一非空
if strings.TrimSpace(p.ModuleCode) == "" {
p.ModuleCode = p.BizType
}
if strings.TrimSpace(p.BizType) == "" {
p.BizType = p.ModuleCode
}
if strings.TrimSpace(p.BizType) == "" {
jsonErr(&c.Controller, 400, 400, "请选择所属分类")
return
}
if err := validateApprovalNodes(p.Nodes); err != nil {
jsonErr(&c.Controller, 400, 400, err.Error())
return
}
if err := validateApprovalGraph(p.Nodes, p.Edges); err != nil {
jsonErr(&c.Controller, 400, 400, err.Error())
return
}
g := buildGraph(p.Nodes, p.Edges)
now := time.Now()
o := models.Orm
var flowID uint64
var flowCode string
if p.ID > 0 {
var exist models.PlatformApprovalFlow
if err := o.QueryTable(new(models.PlatformApprovalFlow)).
Filter("id", p.ID).Filter("is_deleted", 0).One(&exist); err != nil {
jsonErr(&c.Controller, 404, 404, "流程不存在")
return
}
flowID = p.ID
flowCode = exist.FlowCode
_, err := o.QueryTable(new(models.PlatformApprovalFlow)).Filter("id", flowID).Update(orm.Params{
"module_code": p.ModuleCode,
"biz_type": p.BizType,
"flow_name": p.FlowName,
"description": p.Description,
"is_default": p.IsDefault,
"status": p.Status,
"update_time": now,
})
if err != nil {
jsonErr(&c.Controller, 500, 500, "保存失败: "+err.Error())
return
}
} else {
row := models.PlatformApprovalFlow{
ModuleCode: p.ModuleCode,
BizType: p.BizType,
FlowName: p.FlowName,
Description: p.Description,
IsDefault: p.IsDefault,
Status: p.Status,
CreateTime: now,
}
id, err := o.Insert(&row)
if err != nil {
jsonErr(&c.Controller, 500, 500, "保存失败: "+err.Error())
return
}
flowID = uint64(id)
flowCode = genApprovalFlowCode(flowID)
_, _ = o.QueryTable(new(models.PlatformApprovalFlow)).Filter("id", flowID).
Update(orm.Params{"flow_code": flowCode})
}
// 默认流程唯一:同 biz_type 下只允许一条 is_default=1
if p.IsDefault == 1 {
_, _ = o.QueryTable(new(models.PlatformApprovalFlow)).
Filter("biz_type", p.BizType).Filter("is_deleted", 0).
Exclude("id", flowID).Update(orm.Params{"is_default": 0, "update_time": now})
}
// 覆盖节点:软删旧节点后按 sort 重建
_, _ = o.QueryTable(new(models.PlatformApprovalNode)).
Filter("flow_id", flowID).Filter("is_deleted", 0).
Update(orm.Params{"is_deleted": 1, "delete_time": now})
for i, nd := range p.Nodes {
sort := nd.Sort
if sort == 0 {
sort = i + 1
}
nodeKey := strings.TrimSpace(nd.NodeKey)
if nodeKey == "" {
nodeKey = fmt.Sprintf("n%d", sort)
}
nodeType := nd.NodeType
if nodeType == 0 {
nodeType = models.ApprovalNodeApproval
}
multi := nd.MultiMode
if multi != models.ApprovalMultiAll {
multi = models.ApprovalMultiAny
}
timeout := nd.TimeoutHours
if timeout <= 0 {
timeout = 48
}
node := models.PlatformApprovalNode{
FlowID: flowID,
NodeKey: nodeKey,
NodeName: strings.TrimSpace(nd.NodeName),
NodeType: nodeType,
ApproverType: nd.ApproverType,
ApproverIDs: joinIDs(nd.ApproverIDs),
MultiMode: multi,
AllowReturn: nd.AllowReturn,
TimeoutHours: timeout,
Sort: sort,
PosX: nd.PosX,
PosY: nd.PosY,
PrevKeys: strings.Join(g.prev[nodeKey], ","),
NextKeys: strings.Join(g.next[nodeKey], ","),
ConditionExpr: g.cond[nodeKey],
CreateTime: now,
}
if _, err := o.Insert(&node); err != nil {
jsonErr(&c.Controller, 500, 500, "保存节点失败: "+err.Error())
return
}
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功", "data": map[string]interface{}{"id": flowID, "flow_code": flowCode}}
_ = c.ServeJSON()
}
// Pick GET /platform/approval/flow/pick
// 轻量清单:按分类返回启用流程的 {flow_code, flow_name},供其它功能“选/填编号调用”。
func (c *PlatformApprovalController) Pick() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
module := strings.TrimSpace(c.GetString("module"))
if module == "" {
module = strings.TrimSpace(c.GetString("biz_type"))
}
qs := models.Orm.QueryTable(new(models.PlatformApprovalFlow)).
Filter("is_deleted", 0).Filter("status", 1)
if module != "" {
qs = qs.Filter("biz_type", module)
}
var rows []models.PlatformApprovalFlow
_, _ = qs.OrderBy("biz_type", "id").All(&rows)
list := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
list = append(list, map[string]interface{}{
"module_code": r.ModuleCode,
"biz_type": r.BizType,
"flow_code": r.FlowCode,
"flow_name": r.FlowName,
})
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": list}
_ = c.ServeJSON()
}
// FlowDelete DELETE /platform/approval/flow/delete/:id
// 软删流程及其节点。存在在途实例时禁止删除,避免审批中断。
func (c *PlatformApprovalController) FlowDelete() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
var f models.PlatformApprovalFlow
if err := models.Orm.QueryTable(new(models.PlatformApprovalFlow)).
Filter("id", id).Filter("is_deleted", 0).One(&f); err != nil {
jsonErr(&c.Controller, 404, 404, "流程不存在")
return
}
running, _ := models.Orm.QueryTable(new(models.ApprovalInstance)).
Filter("flow_id", id).Filter("scope", models.ApprovalScopePlatform).
Filter("status", models.ApprovalInstanceRunning).Filter("is_deleted", 0).Count()
if running > 0 {
jsonErr(&c.Controller, 400, 400, fmt.Sprintf("该流程还有 %d 条在途审批,暂不能删除", running))
return
}
now := time.Now()
_, _ = models.Orm.QueryTable(new(models.PlatformApprovalFlow)).Filter("id", id).
Update(orm.Params{"is_deleted": 1, "delete_time": now, "update_time": now})
_, _ = models.Orm.QueryTable(new(models.PlatformApprovalNode)).Filter("flow_id", id).
Update(orm.Params{"is_deleted": 1, "delete_time": now})
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "已删除"}
_ = c.ServeJSON()
}
// Options GET /platform/approval/options
// 编排界面「选审批人」用:返回平台角色(cid=1)与平台用户列表。
func (c *PlatformApprovalController) Options() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
var roles []models.AdminRole
_, _ = models.Orm.QueryTable(new(models.AdminRole)).
Filter("cid", 1).Filter("status", 1).Filter("delete_time__isnull", true).
OrderBy("id").All(&roles)
roleList := make([]map[string]interface{}, 0, len(roles))
for _, r := range roles {
roleList = append(roleList, map[string]interface{}{"id": r.ID, "name": r.Name})
}
var users []models.AdminUser
_, _ = models.Orm.QueryTable(new(models.AdminUser)).
Filter("status", 1).Filter("delete_time__isnull", true).
OrderBy("id").All(&users)
userList := make([]map[string]interface{}, 0, len(users))
for _, u := range users {
userList = append(userList, map[string]interface{}{
"id": u.ID,
"name": approverDisplayName(u.Name, u.Account),
"role": u.RoleID,
})
}
// 分类(复用功能模块):Web 可见、启用、未删除
var mods []models.SystemModules
_, _ = models.Orm.QueryTable(new(models.SystemModules)).
Filter("delete_time__isnull", true).Filter("status", 1).Filter("is_show", 1).
OrderBy("sort", "id").All(&mods)
mods = services.FilterModulesByPlatform(mods, models.PlatformWeb)
moduleList := make([]map[string]interface{}, 0, len(mods))
for _, m := range mods {
moduleList = append(moduleList, map[string]interface{}{
"code": m.Code, "name": m.Name, "enabled": true,
})
}
c.Data["json"] = map[string]interface{}{
"code": 200, "msg": "ok",
"data": map[string]interface{}{"roles": roleList, "users": userList, "modules": moduleList},
}
_ = c.ServeJSON()
}
// BizTypes GET /platform/approval/biz-types
// 可绑定业务类型字典(当期平台侧为「域名申请」),供配置界面顶级下拉。
func (c *PlatformApprovalController) BizTypes() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": []map[string]interface{}{
{"value": models.ApprovalBizDomain, "label": "域名申请"},
}}
_ = c.ServeJSON()
}
// ============================ 辅助函数 ============================
// approverDisplayName 优先姓名,回退账号(AdminUser.Account 为普通字符串)
func approverDisplayName(name *string, account string) string {
if name != nil && strings.TrimSpace(*name) != "" {
return strings.TrimSpace(*name)
}
return strings.TrimSpace(account)
}
// validateApprovalNodes 校验审批节点:必须选定审批人来源且非空(开始/结束/条件网关跳过)
func validateApprovalNodes(nodes []approvalNodePayload) error {
for i, nd := range nodes {
if nd.NodeType == models.ApprovalNodeStart || nd.NodeType == models.ApprovalNodeEnd ||
nd.NodeType == models.ApprovalNodeCondition {
continue
}
if strings.TrimSpace(nd.NodeName) == "" {
return fmt.Errorf("第 %d 个节点名称不能为空", i+1)
}
if nd.ApproverType != models.ApprovalApproverUser && nd.ApproverType != models.ApprovalApproverRole {
return fmt.Errorf("节点「%s」审批人类型非法(仅支持指定用户/指定角色)", nd.NodeName)
}
if len(nd.ApproverIDs) == 0 {
return fmt.Errorf("节点「%s」未选择审批人", nd.NodeName)
}
}
return nil
}
// joinIDs 将 ID 数组拼成逗号分隔串(存库口径)
func joinIDs(ids []uint64) string {
parts := make([]string, 0, len(ids))
for _, id := range ids {
if id > 0 {
parts = append(parts, strconv.FormatUint(id, 10))
}
}
return strings.Join(parts, ",")
}
// parseIDSlice 解析逗号分隔 ID 串为数组
func parseIDSlice(raw string) []uint64 {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
out := make([]uint64, 0)
for _, p := range strings.Split(raw, ",") {
if v, err := strconv.ParseUint(strings.TrimSpace(p), 10, 64); err == nil && v > 0 {
out = append(out, v)
}
}
return out
}
// ============================ 图(连线)辅助 ============================
// flowEdgeNode 连线重建输入:节点标识 + 出边(next_keys) + 本节点进入条件
type flowEdgeNode struct {
NodeKey string
NextKeys string
Condition string
}
// splitKeyList 拆分逗号分隔 node_key 列表
func splitKeyList(raw string) []string {
out := make([]string, 0, 4)
for _, p := range strings.Split(raw, ",") {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
}
}
return out
}
// containsStr 列表是否包含某字符串
func containsStr(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// approvalGraph 由画布连线反推的邻接与条件
type approvalGraph struct {
next map[string][]string
prev map[string][]string
cond map[string]string
}
// buildGraph 由 edges 反推每个节点的 prev_keys/next_keys 与进入条件(条件落到目标节点)
func buildGraph(nodes []approvalNodePayload, edges []approvalEdge) approvalGraph {
g := approvalGraph{next: map[string][]string{}, prev: map[string][]string{}, cond: map[string]string{}}
for _, e := range edges {
f := strings.TrimSpace(e.From)
t := strings.TrimSpace(e.To)
if f == "" || t == "" || f == t {
continue
}
if !containsStr(g.next[f], t) {
g.next[f] = append(g.next[f], t)
}
if !containsStr(g.prev[t], f) {
g.prev[t] = append(g.prev[t], f)
}
if c := strings.TrimSpace(e.Condition); c != "" {
g.cond[t] = c
}
}
return g
}
// rebuildEdges 由节点 next_keys 邻接重建画布连线;连线条件取目标节点 condition_expr
func rebuildEdges(nodes []flowEdgeNode) []approvalEdge {
condByKey := make(map[string]string, len(nodes))
for _, n := range nodes {
condByKey[n.NodeKey] = n.Condition
}
edges := make([]approvalEdge, 0)
for _, n := range nodes {
for _, to := range splitKeyList(n.NextKeys) {
edges = append(edges, approvalEdge{From: n.NodeKey, To: to, Condition: condByKey[to]})
}
}
return edges
}
// validateApprovalGraph 校验画布图的完整性:单一开始、审批节点≥1、网关出边≥2、全部可达、连线端点有效
func validateApprovalGraph(nodes []approvalNodePayload, edges []approvalEdge) error {
if len(nodes) == 0 {
return fmt.Errorf("流程至少需包含开始与审批节点")
}
keySet := map[string]bool{}
var startCount, approvalCount int
var condKeys []string
var startKey string
for _, n := range nodes {
k := strings.TrimSpace(n.NodeKey)
if k == "" {
return fmt.Errorf("存在缺少标识的节点,请重新连线后再保存")
}
if keySet[k] {
return fmt.Errorf("节点标识重复:%s", k)
}
keySet[k] = true
switch n.NodeType {
case models.ApprovalNodeStart:
startCount++
startKey = k
case models.ApprovalNodeApproval:
approvalCount++
case models.ApprovalNodeCondition:
condKeys = append(condKeys, k)
}
}
if startCount != 1 {
return fmt.Errorf("流程需有且仅有一个「开始」节点")
}
if approvalCount < 1 {
return fmt.Errorf("流程需至少包含一个「审批」节点")
}
nextAdj := map[string][]string{}
for _, e := range edges {
f := strings.TrimSpace(e.From)
t := strings.TrimSpace(e.To)
if !keySet[f] || !keySet[t] {
return fmt.Errorf("存在连向未知节点的连线,请检查画布")
}
nextAdj[f] = append(nextAdj[f], t)
}
for _, k := range condKeys {
if len(nextAdj[k]) < 2 {
return fmt.Errorf("条件网关需至少两条分支连线")
}
}
visited := map[string]bool{}
queue := []string{startKey}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
if visited[cur] {
continue
}
visited[cur] = true
queue = append(queue, nextAdj[cur]...)
}
for _, n := range nodes {
if !visited[strings.TrimSpace(n.NodeKey)] {
return fmt.Errorf("节点「%s」未接入流程,请检查连线", strings.TrimSpace(n.NodeName))
}
}
return nil
}
// genApprovalFlowCode 由自增 id 生成审批流编号(全局唯一、供各程序按编号发起)
func genApprovalFlowCode(id uint64) string {
return fmt.Sprintf("FL%06d", id)
}
+37 -18
View File
@@ -463,7 +463,8 @@ func (c *PlatformTenantDomainController) CustomDomainGuide() {
// Apply POST /platform/domain/tenant/apply body:{tid,sub_domain,main_domain}
func (c *PlatformTenantDomainController) Apply() {
if _, err := requirePlatform(&c.Controller); err != nil {
claims, err := requirePlatform(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
@@ -551,13 +552,21 @@ func (c *PlatformTenantDomainController) Apply() {
jsonErr(&c.Controller, 500, 500, "申请失败: "+err.Error())
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "申请提交成功,等待审核", "data": map[string]interface{}{"id": uint64(id)}}
// 进入平台侧审批流(严格:未配置流程/无有效审批人则提交失败并回滚)
if errMsg := submitDomainApprovalChecked(uint64(id), p.Tid, uint64(claims.UserID), claims.Username, full); errMsg != "" {
jsonErr(&c.Controller, 400, 400, errMsg)
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "申请提交成功,等待审批", "data": map[string]interface{}{"id": uint64(id)}}
_ = c.ServeJSON()
}
// Audit POST /platform/domain/tenant/audit body:{id,action} action=approve/reject
// Audit POST /platform/domain/tenant/audit body:{id,action,comment} action=approve/reject
// 引擎驱动:已配置审批流则走多级流转,仅当前待办人可操作;
// 未配置流程(无在途实例)则回退到单级直改状态,保证兼容。
func (c *PlatformTenantDomainController) Audit() {
if _, err := requirePlatform(&c.Controller); err != nil {
claims, err := requirePlatform(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
@@ -567,8 +576,9 @@ func (c *PlatformTenantDomainController) Audit() {
return
}
var p struct {
ID uint64 `json:"id"`
Action string `json:"action"`
ID uint64 `json:"id"`
Action string `json:"action"`
Comment string `json:"comment"`
}
if err := json.Unmarshal(raw, &p); err != nil || p.ID == 0 {
jsonErr(&c.Controller, 400, 400, "参数错误")
@@ -587,25 +597,34 @@ func (c *PlatformTenantDomainController) Audit() {
jsonErr(&c.Controller, 400, 400, "该域名已审核过了")
return
}
newStatus := 2
msg := "已拒绝"
if strings.ToLower(strings.TrimSpace(p.Action)) == "approve" {
newStatus = 1
msg = "审核通过"
pass := strings.ToLower(strings.TrimSpace(p.Action)) == "approve"
var bizTid uint64
if row.Tid != nil {
bizTid = *row.Tid
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("id", p.ID).Update(map[string]interface{}{
"status": newStatus,
"update_time": now,
})
if err != nil {
jsonErr(&c.Controller, 500, 500, "审核失败: "+err.Error())
// 严格引擎驱动:必须命中在途实例与当前待办,否则拒绝(已移除单级兼容回退)
msg, code := engineDomainAudit(p.ID, uint64(claims.UserID), pass, p.Comment, bizTid)
if code != 200 {
jsonErr(&c.Controller, code, code, msg)
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": msg}
_ = c.ServeJSON()
}
// AuditProgress GET /platform/domain/tenant/audit/progress/:id
// 返回该域名的审批进度(节点 + 各审批人状态),供平台端展示流转。
func (c *PlatformTenantDomainController) AuditProgress() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": domainApprovalProgress(id)}
_ = c.ServeJSON()
}
// ToggleStatus POST /platform/domain/tenant/toggleStatus body:{id}
func (c *PlatformTenantDomainController) ToggleStatus() {
if _, err := requirePlatform(&c.Controller); err != nil {
+171
View File
@@ -0,0 +1,171 @@
package controllers
import (
"fmt"
"time"
"server/models"
"server/services/approval"
"github.com/beego/beego/v2/client/orm"
)
// =============================================================
// 域名申请 × 审批流引擎 的接入层(严格模式,无单级兼容)
//
// 口径:
// - 域名申请提交必须命中已配置的默认流程,走引擎多级流转;
// - 未配置流程 / 节点无有效审批人 → 提交失败并回滚刚创建的域名行,绝不留下无法审批的脏数据;
// - 审核只能通过引擎:必须存在在途实例且命中当前审批人的待办,否则拒绝操作。
// =============================================================
// submitDomainApprovalChecked 将域名提交进平台侧审批流。
// 成功返回空串;失败则软删回滚刚创建的域名行,并返回给前端的错误文案。
func submitDomainApprovalChecked(domainRowID, bizTid, submitUID uint64, submitName, fullDomain string) string {
_, err := approval.Submit(approval.SubmitInput{
Scope: models.ApprovalScopePlatform,
BizTid: bizTid,
BizType: models.ApprovalBizDomain,
BizID: domainRowID,
BizTitle: fullDomain,
SubmitUID: submitUID,
SubmitName: submitName,
})
if err == nil {
return ""
}
// 提交失败:回滚刚创建的域名行(软删除),避免出现无法审批的待审僵尸数据
_, _ = models.Orm.QueryTable(new(models.SystemTenantDomain)).
Filter("id", domainRowID).Update(map[string]interface{}{"delete_time": time.Now()})
if _, ok := err.(*approval.ErrNoFlow); ok {
return "平台尚未配置域名审批流程,无法提交申请"
}
if _, ok := err.(*approval.ErrNoApprover); ok {
return "域名审批流程未配置有效审批人,无法提交申请"
}
return "提交审批失败:" + err.Error()
}
// findRunningDomainInstance 取某域名当前在途的平台侧审批实例
func findRunningDomainInstance(domainID uint64) (*models.ApprovalInstance, bool) {
var inst models.ApprovalInstance
err := models.Orm.QueryTable(new(models.ApprovalInstance)).
Filter("scope", models.ApprovalScopePlatform).
Filter("biz_type", models.ApprovalBizDomain).
Filter("biz_id", domainID).
Filter("status", models.ApprovalInstanceRunning).
Filter("is_deleted", 0).
OrderBy("-id").One(&inst)
if err == orm.ErrNoRows {
return nil, false
}
if err != nil {
return nil, false
}
return &inst, true
}
// myPendingDomainTask 取指定审批人在该实例上的当前待办任务
func myPendingDomainTask(instID, approverID uint64) (*models.ApprovalTask, bool) {
var task models.ApprovalTask
err := models.Orm.QueryTable(new(models.ApprovalTask)).
Filter("instance_id", instID).
Filter("approver_id", approverID).
Filter("status", models.ApprovalTaskPending).
Filter("is_deleted", 0).
OrderBy("sort", "id").One(&task)
if err != nil {
return nil, false
}
return &task, true
}
// applyDomainApprovalResult 依据引擎结果回写域名状态(仅实例终结时改动)
func applyDomainApprovalResult(domainID uint64, res *approval.ApproveResult) {
if res == nil || !res.Finished {
return
}
newStatus := 2 // 默认拒绝/驳回
if res.InstanceStat == models.ApprovalInstanceApproved {
newStatus = 1 // 通过生效
}
now := time.Now()
_, _ = models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("id", domainID).
Update(orm.Params{"status": newStatus, "update_time": now})
}
// domainApprovalProgress 组装域名审批进度(节点 + 任务),供平台端展示
func domainApprovalProgress(domainID uint64) map[string]interface{} {
inst, ok := findRunningDomainInstance(domainID)
if !ok {
// 无在途实例:可能未走引擎(单级审核)或已结束
return map[string]interface{}{"engine": false}
}
flow, err := approval.LoadFlow(models.ApprovalScopePlatform, 0, models.ApprovalBizDomain, inst.FlowID)
nodes := []map[string]interface{}{}
if err == nil && flow != nil {
for _, nd := range flow.Nodes {
nodes = append(nodes, map[string]interface{}{
"node_key": nd.NodeKey,
"node_name": nd.NodeName,
"node_type": nd.NodeType,
"sort": nd.Sort,
})
}
}
var tasks []models.ApprovalTask
_, _ = models.Orm.QueryTable(new(models.ApprovalTask)).
Filter("instance_id", inst.ID).Filter("is_deleted", 0).
OrderBy("sort", "id").All(&tasks)
taskList := make([]map[string]interface{}, 0, len(tasks))
for _, t := range tasks {
taskList = append(taskList, map[string]interface{}{
"id": t.ID,
"node_key": t.NodeKey,
"node_name": t.NodeName,
"approver_id": t.ApproverID,
"approver_name": t.ApproverName,
"status": t.Status,
"action": t.Action,
"comment": t.Comment,
})
}
return map[string]interface{}{
"engine": true,
"instance_id": inst.ID,
"current_node": inst.CurrentNode,
"status": inst.Status,
"nodes": nodes,
"tasks": taskList,
}
}
// engineDomainAudit 平台端审批入口(严格引擎驱动):
// 必须存在在途实例且命中当前审批人的待办;否则拒绝,不做任何单级回退。
func engineDomainAudit(domainID, adminID uint64, pass bool, comment string, bizTid uint64) (msg string, code int) {
inst, ok := findRunningDomainInstance(domainID)
if !ok {
return "该域名申请未进入审批流程(未配置流程或已处理)", 400
}
task, ok := myPendingDomainTask(inst.ID, adminID)
if !ok {
return "该单据在审批流程中,当前不在您的待办节点", 403
}
res, err := approval.Approve(task.ID, approval.ApproveInput{
ApproverID: adminID,
Pass: pass,
Comment: comment,
BizTid: bizTid,
})
if err != nil {
return err.Error(), 400
}
applyDomainApprovalResult(domainID, res)
if res.Finished {
if res.InstanceStat == models.ApprovalInstanceApproved {
return "审批通过,域名已生效", 200
}
return "已驳回", 200
}
return fmt.Sprintf("已%s,流程继续(%s)", map[bool]string{true: "通过", false: "驳回"}[pass], res.Message), 200
}
@@ -9,14 +9,15 @@ import (
beego "github.com/beego/beego/v2/server/web"
)
// ApiPlatformUpgradeController 平台更新内容接口
type ApiPlatformUpgradeController struct {
// PlatformUpgradeNoticeController 平台端「平台更新公告」接口。
// 数据同源于 models.PlatformUpgradeNotice,但作为平台端独立接口,
// 不与移动端 /api、官网 /index 复用同一 handler。
type PlatformUpgradeNoticeController struct {
beego.Controller
}
// List GET /api/platform/upgrade/list 获取平台更新列表(公开接口)
func (c *ApiPlatformUpgradeController) List() {
// 可选参数:limit - 返回数量限制,默认 10
// List GET /platform/upgrade/list 获取平台更新列表
func (c *PlatformUpgradeNoticeController) List() {
limitStr := c.GetString("limit")
limit := 10
if limitStr != "" {
@@ -29,7 +30,6 @@ func (c *ApiPlatformUpgradeController) List() {
limit = 10
}
// 查询显示状态的更新记录,按排序字段和创建时间倒序
var upgradeList []models.PlatformUpgradeNotice
qs := models.Orm.QueryTable(new(models.PlatformUpgradeNotice)).
Filter("status", 1).
@@ -45,7 +45,6 @@ func (c *ApiPlatformUpgradeController) List() {
return
}
// 转换为前端需要的格式
data := make([]map[string]interface{}, 0, len(upgradeList))
for _, item := range upgradeList {
record := map[string]interface{}{
@@ -66,8 +65,8 @@ func (c *ApiPlatformUpgradeController) List() {
_ = c.ServeJSON()
}
// Detail GET /api/platform/upgrade/detail?id={id} 获取单条更新详情
func (c *ApiPlatformUpgradeController) Detail() {
// Detail GET /platform/upgrade/detail?id={id} 获取单条更新详情
func (c *PlatformUpgradeNoticeController) Detail() {
idStr := strings.TrimSpace(c.GetString("id"))
if idStr == "" {
c.Data["json"] = map[string]interface{}{
+27
View File
@@ -6,6 +6,7 @@ import (
"server/models"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
@@ -552,6 +553,32 @@ func (c *PlatformWebsitePageController) Get() {
websiteOK(&c.Controller, row)
}
// GetByPath GET /platform/website/page/path/:path — 按路径读取单页(平台端独立接口)
func (c *PlatformWebsitePageController) GetByPath() {
if _, ok := websiteAuth(&c.Controller); !ok {
return
}
path := normalizePath(c.Ctx.Input.Param(":path"))
if path == "" {
jsonErr(&c.Controller, 400, 400, "路径标识不能为空")
return
}
var row models.PlatformWebsitePage
err := models.Orm.QueryTable(new(models.PlatformWebsitePage)).
Filter("path", path).
Filter("delete_time__isnull", true).
One(&row)
if err == orm.ErrNoRows {
websiteOK(&c.Controller, nil)
return
}
if err != nil {
jsonErr(&c.Controller, 500, 500, "获取单页失败: "+err.Error())
return
}
websiteOK(&c.Controller, row)
}
// Create POST /platform/website/page
func (c *PlatformWebsitePageController) Create() {
if _, ok := websiteAuth(&c.Controller); !ok {