package controllers import ( "encoding/json" "fmt" "io" "regexp" "strconv" "strings" "time" "server/models" "server/pkg/jwtutil" "github.com/beego/beego/v2/client/orm" beego "github.com/beego/beego/v2/server/web" ) // BackendDictController 租户端数据字典:字典列表 + 字典项 // // 表:yz_backend_dict / yz_backend_dict_item(建表 SQL:docs/sql/create_dict.sql)。 // 按 tenant_id 隔离(取 JWT 里的租户ID,前端不传),与 yz_backend_erp_supplier 一致。 // 平台端字典见 PlatformDictController(/platform/dict/*)。 type BackendDictController struct { beego.Controller } // globalDictTenantID 全局内置字典的租户标记:所有租户可见、只读, // 与 yz_system_admin_role 中 tenant_id=0 的「全局租户角色」同一套约定。 // 租户要改动时就按相同 dict_code 建一份自己的字典覆盖它。 const globalDictTenantID = "0" var backendDictCodeRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{0,99}$`) var backendDictTagTypes = map[string]bool{ "": true, "primary": true, "success": true, "warning": true, "danger": true, "info": true, } func (c *BackendDictController) dictClaims() (*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 *BackendDictController) dictErr(httpStatus, bizCode int, msg string) { c.Ctx.Output.SetStatus(httpStatus) c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} _ = c.ServeJSON() } func (c *BackendDictController) dictOk(data interface{}, msg string) { if msg == "" { msg = "success" } resp := map[string]interface{}{"code": 200, "msg": msg} if data != nil { resp["data"] = data } c.Data["json"] = resp _ = c.ServeJSON() } type backendDictTypePayload struct { DictName string `json:"dict_name"` DictCode string `json:"dict_code"` Remark string `json:"remark"` Sort int `json:"sort"` Status *int8 `json:"status"` } type backendDictItemPayload struct { DictID uint64 `json:"dict_id"` ItemLabel string `json:"item_label"` ItemValue string `json:"item_value"` TagType string `json:"tag_type"` Remark string `json:"remark"` Sort int `json:"sort"` Status *int8 `json:"status"` IsDefault *int8 `json:"is_default"` } // backendDictRow 字典列表行:在实体上追加「是否全局内置」,供前端禁用编辑/删除 type backendDictRow struct { models.BackendDict IsGlobal bool `json:"is_global"` } // dictTenantScope 当前请求可见的字典租户范围:全局内置 + 本租户自己的 func dictTenantScope(claims *jwtutil.Claims) []string { return []string{globalDictTenantID, strconv.Itoa(claims.TenantId)} } // findVisibleDict 按编码取当前租户可见的字典:优先本租户自己的,没有再回落全局内置 func findVisibleDict(tenantID, code string) (*models.BackendDict, error) { var dicts []models.BackendDict if _, err := models.Orm.QueryTable(new(models.BackendDict)). Filter("dict_code", code). Filter("tenant_id__in", []string{globalDictTenantID, tenantID}). Filter("status", 1). Filter("delete_time__isnull", true). All(&dicts); err != nil { return nil, err } for i := range dicts { if dicts[i].TenantID == tenantID { return &dicts[i], nil } } if len(dicts) > 0 { return &dicts[0], nil } return nil, orm.ErrNoRows } // isGlobalDictRow 全局内置字典:任何租户都不允许修改 / 删除 func isGlobalDictRow(tenantID string) bool { return tenantID == globalDictTenantID } /* ---------------- 字典列表 ---------------- */ // ListTypes GET /backend/dict/types func (c *BackendDictController) ListTypes() { claims, err := c.dictClaims() if err != nil { c.dictErr(401, 401, err.Error()) return } page, pageSize, offset := dictPageSize(&c.Controller) keyword := strings.TrimSpace(c.GetString("keyword")) status := strings.TrimSpace(c.GetString("status")) // 可见范围:全局内置 + 本租户自己的(同编码时两条都会出现,各自带 is_global 标记) cond := orm.NewCondition(). And("tenant_id__in", dictTenantScope(claims)). And("delete_time__isnull", true) if keyword != "" { cond = cond.AndCond(orm.NewCondition(). Or("dict_name__contains", keyword). Or("dict_code__contains", keyword)) } if status != "" { if v, err := strconv.Atoi(status); err == nil { cond = cond.And("status", v) } } qs := models.Orm.QueryTable(new(models.BackendDict)).SetCond(cond) total, err := qs.Count() if err != nil { c.dictErr(500, 500, "查询失败: "+err.Error()) return } var list []models.BackendDict if _, err := qs.OrderBy("sort", "id").Limit(pageSize, offset).All(&list); err != nil { c.dictErr(500, 500, "查询失败: "+err.Error()) return } rows := make([]backendDictRow, 0, len(list)) for _, r := range list { rows = append(rows, backendDictRow{BackendDict: r, IsGlobal: isGlobalDictRow(r.TenantID)}) } c.dictOk(map[string]interface{}{ "list": rows, "total": total, "page": page, "pageSize": pageSize, }, "") } // CreateType POST /backend/dict/types func (c *BackendDictController) CreateType() { claims, err := c.dictClaims() if err != nil { c.dictErr(401, 401, err.Error()) return } body, _ := io.ReadAll(c.Ctx.Request.Body) var p backendDictTypePayload if err := json.Unmarshal(body, &p); err != nil { c.dictErr(400, 400, "参数错误") return } p.DictName = strings.TrimSpace(p.DictName) p.DictCode = strings.TrimSpace(p.DictCode) p.Remark = strings.TrimSpace(p.Remark) if p.DictName == "" { c.dictErr(400, 400, "字典名称不能为空") return } if !backendDictCodeRe.MatchString(p.DictCode) { c.dictErr(400, 400, "字典编码以字母开头,只能包含字母、数字与下划线") return } tenantID := fmt.Sprintf("%d", claims.TenantId) exist, _ := models.Orm.QueryTable(new(models.BackendDict)). Filter("tenant_id", tenantID). Filter("dict_code", p.DictCode). Filter("delete_time__isnull", true). Count() if exist > 0 { c.dictErr(400, 400, "字典编码已存在") return } status := int8(1) if p.Status != nil { status = *p.Status } now := time.Now() row := &models.BackendDict{ TenantID: tenantID, DictName: p.DictName, DictCode: p.DictCode, Remark: p.Remark, Sort: p.Sort, Status: status, CreateUserID: strconv.Itoa(claims.UserID), UpdateTime: &now, } id, err := models.Orm.Insert(row) if err != nil { c.dictErr(500, 500, "新增失败: "+err.Error()) return } row.ID = uint64(id) c.dictOk(row, "新增成功") } // UpdateType PUT /backend/dict/types/:id // 编码是业务取值依据,不允许修改。 func (c *BackendDictController) UpdateType() { claims, err := c.dictClaims() if err != nil { c.dictErr(401, 401, err.Error()) return } id := dictPathID(&c.Controller) if id == 0 { c.dictErr(400, 400, "无效的ID") return } body, _ := io.ReadAll(c.Ctx.Request.Body) var p backendDictTypePayload if err := json.Unmarshal(body, &p); err != nil { c.dictErr(400, 400, "参数错误") return } p.DictName = strings.TrimSpace(p.DictName) p.Remark = strings.TrimSpace(p.Remark) if p.DictName == "" { c.dictErr(400, 400, "字典名称不能为空") return } var row models.BackendDict if err := models.Orm.QueryTable(new(models.BackendDict)). Filter("id", id). Filter("tenant_id__in", dictTenantScope(claims)). Filter("delete_time__isnull", true). One(&row); err != nil { c.dictErr(404, 404, "字典不存在") return } if isGlobalDictRow(row.TenantID) { c.dictErr(403, 403, "系统内置字典不可修改,如需调整请在当前租户下按相同编码新建一份") return } now := time.Now() row.DictName = p.DictName row.Remark = p.Remark row.Sort = p.Sort if p.Status != nil { row.Status = *p.Status } row.UpdateTime = &now if _, err := models.Orm.Update(&row); err != nil { c.dictErr(500, 500, "保存失败: "+err.Error()) return } c.dictOk(row, "保存成功") } // DeleteType DELETE /backend/dict/types/:id // 字典下还有字典项时拒绝删除,避免留下孤儿数据。 func (c *BackendDictController) DeleteType() { claims, err := c.dictClaims() if err != nil { c.dictErr(401, 401, err.Error()) return } id := dictPathID(&c.Controller) if id == 0 { c.dictErr(400, 400, "无效的ID") return } var row models.BackendDict if err := models.Orm.QueryTable(new(models.BackendDict)). Filter("id", id). Filter("tenant_id__in", dictTenantScope(claims)). Filter("delete_time__isnull", true). One(&row); err != nil { c.dictErr(404, 404, "字典不存在") return } if isGlobalDictRow(row.TenantID) { c.dictErr(403, 403, "系统内置字典不可删除,如需调整请在当前租户下按相同编码新建一份") return } cnt, _ := models.Orm.QueryTable(new(models.BackendDictItem)). Filter("dict_id", id). Filter("delete_time__isnull", true). Count() if cnt > 0 { c.dictErr(400, 400, fmt.Sprintf("该字典下还有 %d 个字典项,请先删除字典项", cnt)) return } now := time.Now() if _, err := models.Orm.QueryTable(new(models.BackendDict)). Filter("id", id). Update(orm.Params{"delete_time": now, "update_time": now}); err != nil { c.dictErr(500, 500, "删除失败: "+err.Error()) return } c.dictOk(nil, "删除成功") } /* ---------------- 字典项 ---------------- */ // ListItems GET /backend/dict/items func (c *BackendDictController) ListItems() { claims, err := c.dictClaims() if err != nil { c.dictErr(401, 401, err.Error()) return } dictID, _ := c.GetInt64("dict_id") if dictID <= 0 { c.dictErr(400, 400, "dict_id 不能为空") return } // 字典必须在本租户可见范围内(自己的或全局内置),避免越权读取他人字典 var dict models.BackendDict if err := models.Orm.QueryTable(new(models.BackendDict)). Filter("id", dictID). Filter("tenant_id__in", dictTenantScope(claims)). Filter("delete_time__isnull", true). One(&dict); err != nil { c.dictErr(404, 404, "字典不存在") return } page, pageSize, offset := dictPageSize(&c.Controller) keyword := strings.TrimSpace(c.GetString("keyword")) status := strings.TrimSpace(c.GetString("status")) // 字典项跟随所属字典的租户:全局内置字典的项对所有租户可见 cond := orm.NewCondition(). And("tenant_id", dict.TenantID). And("dict_id", dictID). And("delete_time__isnull", true) if keyword != "" { cond = cond.AndCond(orm.NewCondition(). Or("item_label__contains", keyword). Or("item_value__contains", keyword)) } if status != "" { if v, err := strconv.Atoi(status); err == nil { cond = cond.And("status", v) } } qs := models.Orm.QueryTable(new(models.BackendDictItem)).SetCond(cond) total, err := qs.Count() if err != nil { c.dictErr(500, 500, "查询失败: "+err.Error()) return } var list []models.BackendDictItem if _, err := qs.OrderBy("sort", "id").Limit(pageSize, offset).All(&list); err != nil { c.dictErr(500, 500, "查询失败: "+err.Error()) return } c.dictOk(map[string]interface{}{ "list": list, "total": total, "page": page, "pageSize": pageSize, }, "") } // CreateItem POST /backend/dict/items func (c *BackendDictController) CreateItem() { claims, err := c.dictClaims() if err != nil { c.dictErr(401, 401, err.Error()) return } body, _ := io.ReadAll(c.Ctx.Request.Body) var p backendDictItemPayload if err := json.Unmarshal(body, &p); err != nil { c.dictErr(400, 400, "参数错误") return } p.ItemLabel = strings.TrimSpace(p.ItemLabel) p.ItemValue = strings.TrimSpace(p.ItemValue) p.TagType = strings.TrimSpace(p.TagType) p.Remark = strings.TrimSpace(p.Remark) if p.DictID == 0 { c.dictErr(400, 400, "dict_id 不能为空") return } if p.ItemLabel == "" { c.dictErr(400, 400, "字典项名称不能为空") return } if p.ItemValue == "" { c.dictErr(400, 400, "字典项值不能为空") return } if !backendDictTagTypes[p.TagType] { c.dictErr(400, 400, "标签色只能是 primary/success/warning/danger/info 之一") return } tenantID := fmt.Sprintf("%d", claims.TenantId) var dict models.BackendDict if err := models.Orm.QueryTable(new(models.BackendDict)). Filter("id", p.DictID). Filter("tenant_id__in", dictTenantScope(claims)). Filter("delete_time__isnull", true). One(&dict); err != nil { c.dictErr(404, 404, "字典不存在") return } if isGlobalDictRow(dict.TenantID) { c.dictErr(403, 403, "系统内置字典不可新增字典项,如需调整请在当前租户下按相同编码新建一份") return } cnt, _ := models.Orm.QueryTable(new(models.BackendDictItem)). Filter("tenant_id", tenantID). Filter("dict_id", p.DictID). Filter("item_value", p.ItemValue). Filter("delete_time__isnull", true). Count() if cnt > 0 { c.dictErr(400, 400, "同一字典下的字典项值不能重复") return } status := int8(1) if p.Status != nil { status = *p.Status } isDefault := int8(0) if p.IsDefault != nil { isDefault = *p.IsDefault } now := time.Now() row := &models.BackendDictItem{ TenantID: tenantID, DictID: p.DictID, ItemLabel: p.ItemLabel, ItemValue: p.ItemValue, TagType: p.TagType, Sort: p.Sort, Status: status, IsDefault: isDefault, Remark: p.Remark, UpdateTime: &now, } id, err := models.Orm.Insert(row) if err != nil { c.dictErr(500, 500, "新增失败: "+err.Error()) return } row.ID = uint64(id) c.dictOk(row, "新增成功") } // UpdateItem PUT /backend/dict/items/:id // 字典项值是业务入库值,不允许修改,避免存量数据对不上。 func (c *BackendDictController) UpdateItem() { claims, err := c.dictClaims() if err != nil { c.dictErr(401, 401, err.Error()) return } id := dictPathID(&c.Controller) if id == 0 { c.dictErr(400, 400, "无效的ID") return } body, _ := io.ReadAll(c.Ctx.Request.Body) var p backendDictItemPayload if err := json.Unmarshal(body, &p); err != nil { c.dictErr(400, 400, "参数错误") return } p.ItemLabel = strings.TrimSpace(p.ItemLabel) p.TagType = strings.TrimSpace(p.TagType) p.Remark = strings.TrimSpace(p.Remark) if p.ItemLabel == "" { c.dictErr(400, 400, "字典项名称不能为空") return } if !backendDictTagTypes[p.TagType] { c.dictErr(400, 400, "标签色只能是 primary/success/warning/danger/info 之一") return } var row models.BackendDictItem if err := models.Orm.QueryTable(new(models.BackendDictItem)). Filter("id", id). Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)). Filter("delete_time__isnull", true). One(&row); err != nil { c.dictErr(404, 404, "字典项不存在") return } now := time.Now() row.ItemLabel = p.ItemLabel row.TagType = p.TagType row.Sort = p.Sort row.Remark = p.Remark if p.Status != nil { row.Status = *p.Status } if p.IsDefault != nil { row.IsDefault = *p.IsDefault } row.UpdateTime = &now if _, err := models.Orm.Update(&row); err != nil { c.dictErr(500, 500, "保存失败: "+err.Error()) return } c.dictOk(row, "保存成功") } // ToggleItemStatus POST /backend/dict/items/:id/status func (c *BackendDictController) ToggleItemStatus() { claims, err := c.dictClaims() if err != nil { c.dictErr(401, 401, err.Error()) return } id := dictPathID(&c.Controller) if id == 0 { c.dictErr(400, 400, "无效的ID") return } body, _ := io.ReadAll(c.Ctx.Request.Body) var p struct { Status *int8 `json:"status"` } if err := json.Unmarshal(body, &p); err != nil || p.Status == nil { c.dictErr(400, 400, "参数错误") return } if *p.Status != 0 && *p.Status != 1 { c.dictErr(400, 400, "状态只能是 0 或 1") return } tenantID := fmt.Sprintf("%d", claims.TenantId) var row models.BackendDictItem if err := models.Orm.QueryTable(new(models.BackendDictItem)). Filter("id", id). Filter("tenant_id", tenantID). Filter("delete_time__isnull", true). One(&row); err != nil { c.dictErr(404, 404, "字典项不存在") return } now := time.Now() if _, err := models.Orm.QueryTable(new(models.BackendDictItem)). Filter("id", id). Update(orm.Params{"status": *p.Status, "update_time": now}); err != nil { c.dictErr(500, 500, "操作失败: "+err.Error()) return } c.dictOk(nil, "操作成功") } // DeleteItem DELETE /backend/dict/items/:id func (c *BackendDictController) DeleteItem() { claims, err := c.dictClaims() if err != nil { c.dictErr(401, 401, err.Error()) return } id := dictPathID(&c.Controller) if id == 0 { c.dictErr(400, 400, "无效的ID") return } var row models.BackendDictItem if err := models.Orm.QueryTable(new(models.BackendDictItem)). Filter("id", id). Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)). Filter("delete_time__isnull", true). One(&row); err != nil { c.dictErr(404, 404, "字典项不存在") return } now := time.Now() if _, err := models.Orm.QueryTable(new(models.BackendDictItem)). Filter("id", id). Update(orm.Params{"delete_time": now, "update_time": now}); err != nil { c.dictErr(500, 500, "删除失败: "+err.Error()) return } c.dictOk(nil, "删除成功") } /* ---------------- 业务读取 ---------------- */ // OptionsAll GET /backend/dict/options // 一次取回本租户可见的全部「启用」字典项(全局内置 + 本租户自己的,同编码租户优先), // 供前端登录后预热缓存:之后页面按任意 dict_code 都能同步读到。 func (c *BackendDictController) OptionsAll() { claims, err := c.dictClaims() if err != nil { c.dictErr(401, 401, err.Error()) return } tenantID := strconv.Itoa(claims.TenantId) result := map[string][]models.BackendDictItem{} var dicts []models.BackendDict if _, err := models.Orm.QueryTable(new(models.BackendDict)). Filter("tenant_id__in", dictTenantScope(claims)). Filter("status", 1). Filter("delete_time__isnull", true). All(&dicts); err != nil { c.dictErr(500, 500, "查询失败: "+err.Error()) return } // 同编码优先本租户:先记录全局,再用租户自己的覆盖 chosen := map[string]uint64{} for _, d := range dicts { if _, ok := chosen[d.DictCode]; !ok || d.TenantID == tenantID { chosen[d.DictCode] = d.ID } } if len(chosen) == 0 { c.dictOk(map[string]interface{}{"dicts": result}, "") return } ids := make([]uint64, 0, len(chosen)) codeByID := make(map[uint64]string, len(chosen)) for code, id := range chosen { ids = append(ids, id) codeByID[id] = code result[code] = []models.BackendDictItem{} } var items []models.BackendDictItem if _, err := models.Orm.QueryTable(new(models.BackendDictItem)). Filter("dict_id__in", ids). Filter("status", 1). Filter("delete_time__isnull", true). OrderBy("sort", "id"). All(&items); err != nil { c.dictErr(500, 500, "查询失败: "+err.Error()) return } for _, it := range items { code := codeByID[it.DictID] result[code] = append(result[code], it) } c.dictOk(map[string]interface{}{"dicts": result}, "") } // OptionsBatch POST /backend/dict/options // 一次取多个字典的启用项,供前端预加载(body: {"codes":[...]})。 // 同编码时优先本租户自己的字典,没有再回落全局内置字典。 func (c *BackendDictController) OptionsBatch() { claims, err := c.dictClaims() if err != nil { c.dictErr(401, 401, err.Error()) return } body, _ := io.ReadAll(c.Ctx.Request.Body) var p struct { Codes []string `json:"codes"` } if err := json.Unmarshal(body, &p); err != nil { c.dictErr(400, 400, "参数错误") return } result := map[string][]models.BackendDictItem{} codes := make([]string, 0, len(p.Codes)) for _, code := range p.Codes { code = strings.TrimSpace(code) if code == "" { continue } if _, ok := result[code]; ok { continue } result[code] = []models.BackendDictItem{} codes = append(codes, code) } if len(codes) == 0 { c.dictOk(map[string]interface{}{"dicts": result}, "") return } tenantID := strconv.Itoa(claims.TenantId) var dicts []models.BackendDict if _, err := models.Orm.QueryTable(new(models.BackendDict)). Filter("dict_code__in", codes). Filter("tenant_id__in", dictTenantScope(claims)). Filter("status", 1). Filter("delete_time__isnull", true). All(&dicts); err != nil { c.dictErr(500, 500, "查询失败: "+err.Error()) return } // 同编码优先本租户:先记录全局,再用租户自己的覆盖 chosen := map[string]uint64{} for _, d := range dicts { if _, ok := chosen[d.DictCode]; !ok || d.TenantID == tenantID { chosen[d.DictCode] = d.ID } } if len(chosen) == 0 { c.dictOk(map[string]interface{}{"dicts": result}, "") return } ids := make([]uint64, 0, len(chosen)) codeByID := make(map[uint64]string, len(chosen)) for code, id := range chosen { ids = append(ids, id) codeByID[id] = code } var items []models.BackendDictItem if _, err := models.Orm.QueryTable(new(models.BackendDictItem)). Filter("dict_id__in", ids). Filter("status", 1). Filter("delete_time__isnull", true). OrderBy("sort", "id"). All(&items); err != nil { c.dictErr(500, 500, "查询失败: "+err.Error()) return } for _, it := range items { code := codeByID[it.DictID] result[code] = append(result[code], it) } c.dictOk(map[string]interface{}{"dicts": result}, "") } // Options GET /backend/dict/options/:code // 业务页面按编码取选项:只返回「字典与字典项都启用」的项,按 sort 升序。 func (c *BackendDictController) Options() { claims, err := c.dictClaims() if err != nil { c.dictErr(401, 401, err.Error()) return } code := strings.TrimSpace(c.Ctx.Input.Param(":code")) if code == "" { c.dictErr(400, 400, "字典编码不能为空") return } // 优先取本租户自己的字典,没有再回落全局内置字典 dict, err := findVisibleDict(strconv.Itoa(claims.TenantId), code) if err != nil { c.dictErr(404, 404, "字典不存在或已停用") return } var list []models.BackendDictItem if _, err := models.Orm.QueryTable(new(models.BackendDictItem)). Filter("dict_id", dict.ID). Filter("status", 1). Filter("delete_time__isnull", true). OrderBy("sort", "id"). All(&list); err != nil { c.dictErr(500, 500, "查询失败: "+err.Error()) return } c.dictOk(map[string]interface{}{ "dict_id": dict.ID, "dict_code": dict.DictCode, "dict_name": dict.DictName, "list": list, }, "") }