package services import ( "errors" "fmt" "sort" "strconv" "strings" "time" "server/models" "github.com/beego/beego/v2/client/orm" ) // OA 文档管理业务逻辑层(文档库 + 文档图谱)。 // 只处理业务与数据访问,不感知 HTTP;错误以 error 向上抛出,由控制器转译。 // 业务错误 var ( ErrDocCategoryNotFound = errors.New("分类不存在") ErrDocCategoryHasChild = errors.New("存在子分类,请先删除子分类") ErrDocCategoryHasDoc = errors.New("分类下仍有文档,请先移动或删除") ErrDocCategoryNameEmpty = errors.New("请输入分类名称") ErrDocCategoryCircular = errors.New("不能将分类移动到自己或其子分类下") ErrDocCategorySystem = errors.New("系统内置分类不可删除") ErrDocCategoryNameKept = errors.New("该分类名称为系统保留,不可使用") ErrDocCategorySystemLocked = errors.New("系统内置分类固定为一级分类,不可移动") ErrDocNotFound = errors.New("文档不存在") ErrDocTitleEmpty = errors.New("请输入文档标题") ErrDocCategoryInvalid = errors.New("所属分类不存在") ErrDocLinkSelf = errors.New("不能关联文档自身") ErrDocLinkDuplicate = errors.New("该关联关系已存在") ErrDocLinkTargetNotFound = errors.New("被关联的文档不存在") ErrDocLinkNotFound = errors.New("关联关系不存在") ErrDocIDEmpty = errors.New("请选择要操作的文档") ErrDocNoPermission = errors.New("无权操作该私密文档") ) // ---------------- 访问者上下文与可见性 ---------------- // OaDocActor 文档访问者上下文。 // // 两级隔离: // 1. 租户隔离:所有查询强制带 tid,tid 只取自 JWT,前端无法指定; // 2. 用户隔离:visibility=1 的私密文档,仅创建者、被共享者(用户/部门)可见; // group_id=0 的用户沿用系统既有约定(未分配角色视为全权限),可查看全部私密文档。 type OaDocActor struct { Tid int // 租户ID UID uint64 // 登录用户ID(yz_system_tenant_user.uid) OrgIDs []uint64 // 所属部门及全部子部门ID IsAdmin bool // 是否全权限(group_id=0) } // NewOaDocActor 构造访问者上下文。 // uid 为 backend 端 JWT 的 UserID,即 yz_system_tenant_user.uid。 func NewOaDocActor(tid int, uid uint64) OaDocActor { // 补建租户自己的系统内置分类(项目文档),按 tid 隔离 models.EnsureOaDocDefaultCategories(tid) actor := OaDocActor{Tid: tid, UID: uid, OrgIDs: []uint64{}} var user models.SystemTenantUser if err := models.Orm.QueryTable(new(models.SystemTenantUser)). Filter("tid", tid). Filter("uid", uid). Filter("delete_time__isnull", true). One(&user); err != nil { // 查不到归属信息时退化为"普通用户",仅能看公开文档 return actor } actor.IsAdmin = user.GroupID == 0 if user.OrgID > 0 { actor.OrgIDs = oaDocOrgSubTree(tid, user.OrgID) } return actor } // oaDocOrgSubTree 返回指定组织及其全部子组织 ID(含自身)。 func oaDocOrgSubTree(tid int, rootID uint64) []uint64 { var orgs []models.BackendOrganization if _, err := models.Orm.QueryTable(new(models.BackendOrganization)). Filter("tid", tid). Filter("delete_time__isnull", true). All(&orgs); err != nil { return []uint64{rootID} } children := make(map[uint64][]uint64, len(orgs)) for _, o := range orgs { children[o.ParentID] = append(children[o.ParentID], o.ID) } result := []uint64{rootID} var walk func(id uint64) walk = func(id uint64) { for _, cid := range children[id] { if cid == rootID { continue } result = append(result, cid) walk(cid) } } walk(rootID) return result } // oaDocSharedDocIDs 查询共享给该访问者(本人或所属部门)的文档 ID。 func oaDocSharedDocIDs(a OaDocActor) []uint64 { if a.UID == 0 && len(a.OrgIDs) == 0 { return nil } orgIDs := a.OrgIDs if len(orgIDs) == 0 { orgIDs = []uint64{0} // 避免 SQL 出现 IN () } userCond := orm.NewCondition(). And("share_type", models.DocShareTypeUser). And("target_id", a.UID) orgCond := orm.NewCondition(). And("share_type", models.DocShareTypeOrg). And("target_id__in", orgIDs) cond := orm.NewCondition(). And("tid", a.Tid). AndCond(orm.NewCondition().OrCond(userCond).OrCond(orgCond)) var rows []models.OaDocShare if _, err := models.Orm.QueryTable(new(models.OaDocShare)).SetCond(cond).All(&rows); err != nil { return nil } seen := make(map[uint64]bool, len(rows)) ids := make([]uint64, 0, len(rows)) for _, r := range rows { if !seen[r.DocID] { seen[r.DocID] = true ids = append(ids, r.DocID) } } return ids } // oaDocVisibleCond 生成可见性条件;管理员返回 nil 表示不限制。 // 可见 = 公开 OR 本人创建 OR 已共享给本人/所属部门 func oaDocVisibleCond(a OaDocActor) *orm.Condition { if a.IsAdmin { return nil } shared := oaDocSharedDocIDs(a) if len(shared) == 0 { shared = []uint64{0} // 避免 SQL 出现 IN () } return orm.NewCondition(). Or("visibility", models.DocVisibilityPublic). Or("creator_id", a.UID). Or("id__in", shared) } // oaDocApplyVisible 给既有查询集叠加可见性条件(保留此前设置的过滤条件)。 func oaDocApplyVisible(qs orm.QuerySeter, a OaDocActor) orm.QuerySeter { cond := oaDocVisibleCond(a) if cond == nil { return qs } return qs.SetCond(qs.GetCond().AndCond(cond)) } // ---------------- 可见性(共享 / 私密) ---------------- // // 文档库按「共享文档 / 私密文档」两个空间组织,每个空间各自一棵分类树; // 文档可见性由 visibility 决定,并与所在空间一一对应:0=租户公开(共享空间),1=私密(私密空间,仅创建者及被共享者可见)。 // "我的文档"由 creator_id 决定,不与空间字段强耦合。 // oaDocVisibilityRestrictSQL 生成 Raw SQL 场景复用的「用户级可见性」条件片段 // (非管理员才附加:公开 OR 本人创建 OR 已共享给我)。prefix 为表别名前缀。 func oaDocVisibilityRestrictSQL(a OaDocActor, prefix string) (string, []interface{}) { if a.IsAdmin { return "", nil } shared := oaDocSharedDocIDs(a) if len(shared) == 0 { shared = []uint64{0} // 避免 SQL 出现 IN () } placeholders := make([]string, len(shared)) for i := range shared { placeholders[i] = "?" } args := []interface{}{a.UID} for _, id := range shared { args = append(args, id) } return fmt.Sprintf( " AND (%svisibility = 0 OR %screator_id = ? OR %sid IN (%s))", prefix, prefix, prefix, strings.Join(placeholders, ",")), args } // oaDocApplyVisibility 给查询集叠加绝对可见性过滤(按文档自身的 visibility 字段)。 // v<0 不过滤;0=公开;1=私密。 func oaDocApplyVisibility(qs orm.QuerySeter, v int) orm.QuerySeter { switch v { case 0: return qs.Filter("visibility", models.DocVisibilityPublic) case 1: return qs.Filter("visibility", models.DocVisibilityPrivate) default: return qs } } // oaDocVisibleIDSet 从给定文档 ID 中筛出当前访问者可见的部分。 func oaDocVisibleIDSet(a OaDocActor, ids []uint64) map[uint64]bool { out := make(map[uint64]bool, len(ids)) if len(ids) == 0 { return out } var idList orm.ParamsList qs := oaDocApplyVisible(oaDocBase(a.Tid).Filter("id__in", ids), a) if _, err := qs.ValuesFlat(&idList, "Id"); err != nil { return out } for _, v := range idList { if id, err := strconv.ParseUint(fmt.Sprint(v), 10, 64); err == nil { out[id] = true } } return out } // oaDocCanManage 私密文档的管理权限:仅创建者与全权限用户可改删。 // 公开文档沿用租户内共享编辑的既有行为。 func oaDocCanManage(a OaDocActor, doc *models.OaDoc) bool { if a.IsAdmin || doc.Visibility != models.DocVisibilityPrivate { return true } return doc.CreatorID == a.UID } // 文档关系中文名 var docRelationLabel = map[string]string{ models.DocRelationReference: "引用", models.DocRelationRelated: "相关", models.DocRelationVersion: "版本", models.DocRelationBelong: "从属", } // DocRelationLabel 返回关系的中文名,未知关系原样返回。 func DocRelationLabel(relation string) string { if v, ok := docRelationLabel[relation]; ok { return v } return relation } // ---------------- 分类 ---------------- // OaDocCategoryItem 分类树节点(附带文档数量) type OaDocCategoryItem struct { models.OaDocCategory DocCount int64 `json:"doc_count"` Children []*OaDocCategoryItem `json:"children"` } // ---------------- 范围(共享 / 私密) ---------------- // oaDocScopeOf 归一化范围参数:空或未知一律视为共享文档空间。 func oaDocScopeOf(scope string) string { if scope == models.DocScopePersonal { return models.DocScopePersonal } if scope == models.DocScopeAll { return models.DocScopeAll } return models.DocScopeShared } // oaDocScopeVisibility 返回某范围对应的文档可见性:私密→私密(1),其余→租户公开(0)。 func oaDocScopeVisibility(scope string) int8 { if scope == models.DocScopePersonal { return models.DocVisibilityPrivate } return models.DocVisibilityPublic } // oaDocScopeOfVisibility 由文档可见性反推范围:私密(1)→私密文档空间,其余→共享文档空间。 func oaDocScopeOfVisibility(v int8) string { if v == models.DocVisibilityPrivate { return models.DocScopePersonal } return models.DocScopeShared } // oaDocCategoryUserID 返回某范围下分类的归属用户:共享→0(租户级),私密→当前用户(绑定到用户级别)。 func oaDocCategoryUserID(a OaDocActor, scope string) uint64 { if scope == models.DocScopePersonal { return a.UID } return 0 } // oaDocCategoryBaseAny 租户内未删除分类的基础查询集(不限范围,用于按 ID 检索 / 权限校验) func oaDocCategoryBaseAny(tid int) orm.QuerySeter { return models.Orm.QueryTable(new(models.OaDocCategory)). Filter("tid", tid). Filter("is_deleted", 0) } // oaDocCategoryBase 返回指定范围内的未删除分类基础查询集。 // 共享空间:scope=shared 且 user_id=0(租户级,所有人共用); // 私密空间:scope=personal 且 user_id=当前用户(绑定到用户级别,互不干扰); // all:跨两个空间(仅图谱等全局视图使用)。 func oaDocCategoryBase(a OaDocActor, scope string) orm.QuerySeter { s := oaDocScopeOf(scope) if s == models.DocScopeAll { // 跨空间:不加 scope / user_id 过滤 return oaDocCategoryBaseAny(a.Tid) } qs := oaDocCategoryBaseAny(a.Tid).Filter("scope", s) if s == models.DocScopePersonal { qs = qs.Filter("user_id", a.UID) } else if s == models.DocScopeShared { qs = qs.Filter("user_id", 0) } return qs } // oaDocCategoryIDs 返回指定范围内全部未删除分类的 ID。 func oaDocCategoryIDs(a OaDocActor, scope string) ([]uint64, error) { var list []models.OaDocCategory qs := oaDocCategoryBaseAny(a.Tid) if scope != models.DocScopeAll { s := oaDocScopeOf(scope) qs = qs.Filter("scope", s) if s == models.DocScopePersonal { qs = qs.Filter("user_id", a.UID) } else if s == models.DocScopeShared { qs = qs.Filter("user_id", 0) } } if _, err := qs.All(&list); err != nil && err != orm.ErrNoRows { return nil, err } ids := make([]uint64, 0, len(list)) for _, c := range list { ids = append(ids, c.ID) } return ids, nil } // oaDocApplyUncategorized 筛选"未分类"文档:category_id=0 或不属于当前范围任何分类。 func oaDocApplyUncategorized(qs orm.QuerySeter, a OaDocActor, scope string) orm.QuerySeter { ids, err := oaDocCategoryIDs(a, scope) if err != nil || len(ids) == 0 { return qs // 范围内没有任何分类,全部视为未分类 } return qs.Exclude("category_id__in", ids) } // OaDocCategoryTree 返回指定范围内的文档分类树,每个节点附带该分类下的文档数量。 // scope="shared" 返回共享空间树(租户级),scope="personal" 返回当前用户的私密空间树。 // 系统内置分类(项目文档)置顶;数量统计受可见性约束:无权限的私密文档不计入。 func OaDocCategoryTree(a OaDocActor, scope string) ([]*OaDocCategoryItem, error) { scope = oaDocScopeOf(scope) var list []models.OaDocCategory if _, err := oaDocCategoryBase(a, scope).OrderBy("-is_system", "sort", "id").All(&list); err != nil && err != orm.ErrNoRows { return nil, err } counts, err := OaDocCountByCategory(a, scope, -1) if err != nil { return nil, err } nodes := make(map[uint64]*OaDocCategoryItem, len(list)) roots := make([]*OaDocCategoryItem, 0, len(list)) for i := range list { item := &OaDocCategoryItem{ OaDocCategory: list[i], DocCount: counts[list[i].ID], Children: []*OaDocCategoryItem{}, } nodes[item.ID] = item } // 父节点必须也在本范围内(私密空间的父节点不会是共享空间的分类),否则归入根 for i := range list { item := nodes[list[i].ID] if parent, ok := nodes[list[i].ParentID]; ok && list[i].ParentID != 0 { parent.Children = append(parent.Children, item) } else { roots = append(roots, item) } } // 汇总子孙分类的文档数到父级:父级展示的是"包含所有子分类"的文档总数, // 避免出现父级有子分类却显示 0 的情况。 var rollup func(item *OaDocCategoryItem) int64 rollup = func(item *OaDocCategoryItem) int64 { total := item.DocCount for _, child := range item.Children { total += rollup(child) } item.DocCount = total return total } for _, root := range roots { rollup(root) } return roots, nil } // OaDocCountByCategory 统计指定范围内各分类下未删除文档的数量。 // visibility<0 时按范围自动取对应可见性(共享→公开,私密→私密);可见性约束同样生效:无权限的私密文档不计入。 // scope="all" 时跨两个空间统计(不限定 visibility)。 func OaDocCountByCategory(a OaDocActor, scope string, visibility int) (map[uint64]int64, error) { scope = oaDocScopeOf(scope) sql := "SELECT category_id, COUNT(*) AS cnt FROM yz_backend_oa_doc WHERE tid = ? AND is_deleted = 0" args := []interface{}{a.Tid} if scope != models.DocScopeAll { vis := int8(0) if visibility >= 0 { vis = int8(visibility) } else { vis = oaDocScopeVisibility(scope) } sql += fmt.Sprintf(" AND visibility = %d", vis) } extra, extraArgs := oaDocVisibilityRestrictSQL(a, "") sql += extra args = append(args, extraArgs...) sql += " GROUP BY category_id" var rows []struct { CategoryID uint64 `orm:"column(category_id)"` Cnt int64 `orm:"column(cnt)"` } if _, err := models.Orm.Raw(sql, args...).QueryRows(&rows); err != nil { return nil, err } result := make(map[uint64]int64, len(rows)) for _, r := range rows { result[r.CategoryID] = r.Cnt } return result, nil } // oaDocIsReservedName 判断分类名是否为系统保留名。 // 「未分类」是虚拟分类(文档 category_id=0),不允许新建同名真实分类; // 「项目文档」为系统内置分类名(共享空间),禁止重复创建。 func oaDocIsReservedName(name string) bool { name = strings.TrimSpace(name) return name == "未分类" || name == models.DocCategoryNameProject } // OaDocCategoryCreate 在指定空间下新建分类。 // scope="shared" 创建到共享空间(user_id=0,租户级);scope="personal" 创建到当前用户的私密空间(user_id=uid)。 // 父级分类必须与目标空间一致,否则视为不存在。 func OaDocCategoryCreate(a OaDocActor, scope string, parentID uint64, name, remark string, sort int) (*models.OaDocCategory, error) { scope = oaDocScopeOf(scope) name = strings.TrimSpace(name) if name == "" { return nil, ErrDocCategoryNameEmpty } if oaDocIsReservedName(name) { return nil, ErrDocCategoryNameKept } if parentID != 0 { ok, err := oaDocCategoryExists(a, scope, parentID) if err != nil { return nil, err } if !ok { return nil, ErrDocCategoryNotFound } } item := &models.OaDocCategory{ Tid: a.Tid, UserID: oaDocCategoryUserID(a, scope), ParentID: parentID, Name: name, Remark: strings.TrimSpace(remark), Sort: sort, Scope: scope, IsDeleted: 0, } if _, err := models.Orm.Insert(item); err != nil { return nil, err } return item, nil } // OaDocCategoryUpdate 修改分类名称/备注/排序,parentID < 0 表示不调整父级。 func OaDocCategoryUpdate(a OaDocActor, id uint64, name, remark string, sort int, parentID int64) (*models.OaDocCategory, error) { var item models.OaDocCategory if err := oaDocCategoryBaseAny(a.Tid).Filter("id", id).One(&item); err != nil { return nil, ErrDocCategoryNotFound } if err := oaDocCheckCategoryOwner(a, &item); err != nil { return nil, err } fields := []string{"UpdateTime"} if name = strings.TrimSpace(name); name != "" && name != item.Name { if item.IsSystem != models.DocCategorySystem && oaDocIsReservedName(name) { return nil, ErrDocCategoryNameKept } item.Name = name fields = append(fields, "Name") } if remark = strings.TrimSpace(remark); remark != item.Remark { item.Remark = remark fields = append(fields, "Remark") } if sort >= 0 && sort != item.Sort { item.Sort = sort fields = append(fields, "Sort") } if parentID >= 0 && uint64(parentID) != item.ParentID { if item.IsSystem == models.DocCategorySystem { return nil, ErrDocCategorySystemLocked } newParent := uint64(parentID) if newParent == id { return nil, ErrDocCategoryCircular } if newParent != 0 { ok, err := oaDocCategoryExists(a, item.Scope, newParent) if err != nil { return nil, err } if !ok { return nil, ErrDocCategoryNotFound } childIDs, err := OaDocCategoryDescendants(a, newParent) if err != nil { return nil, err } for _, cid := range childIDs { if cid == newParent { return nil, ErrDocCategoryCircular } } } item.ParentID = newParent fields = append(fields, "ParentID") } if len(fields) == 1 { return &item, nil } now := time.Now() item.UpdateTime = &now if _, err := models.Orm.Update(&item, fields...); err != nil { return nil, err } return &item, nil } // OaDocCategoryDescendants 收集某分类的全部子孙 ID(不含自身)。 // 遍历租户内全部未删除分类(跨空间),用于移动时的环路检测。 func OaDocCategoryDescendants(a OaDocActor, rootID uint64) ([]uint64, error) { var list []models.OaDocCategory if _, err := oaDocCategoryBaseAny(a.Tid).All(&list); err != nil && err != orm.ErrNoRows { return nil, err } children := make(map[uint64][]uint64) for _, c := range list { children[c.ParentID] = append(children[c.ParentID], c.ID) } result := make([]uint64, 0, len(list)) var walk func(id uint64) walk = func(id uint64) { for _, cid := range children[id] { if cid == rootID { continue } result = append(result, cid) walk(cid) } } walk(rootID) return result, nil } // oaDocCategoryIDsWithChildren 返回指定分类及其所有子孙 ID;rootID=0 时返回 nil 表示不限制。 func oaDocCategoryIDsWithChildren(a OaDocActor, scope string, rootID uint64) ([]uint64, error) { if rootID == 0 { return nil, nil } children, err := OaDocCategoryDescendants(a, rootID) if err != nil { return nil, err } return append([]uint64{rootID}, children...), nil } // OaDocCategoryDelete 删除分类:系统内置分类、存在子分类或文档时拒绝删除。 // 共享空间分类任何租户成员均可删除(内置分类除外);私密空间分类仅本人可删除。 func OaDocCategoryDelete(a OaDocActor, id uint64) error { var item models.OaDocCategory if err := oaDocCategoryBaseAny(a.Tid).Filter("id", id).One(&item); err != nil { return ErrDocCategoryNotFound } // 内置分类(如「项目文档」)为默认分类,任何租户都不可删除 if item.IsSystem == models.DocCategorySystem { return ErrDocCategorySystem } if err := oaDocCheckCategoryOwner(a, &item); err != nil { return err } childCount, err := oaDocCategoryBase(a, item.Scope).Filter("parent_id", id).Count() if err != nil { return err } if childCount > 0 { return ErrDocCategoryHasChild } docCount, err := oaDocBase(a.Tid).Filter("category_id", id).Count() if err != nil { return err } if docCount > 0 { return ErrDocCategoryHasDoc } now := time.Now() _, err = oaDocCategoryBaseAny(a.Tid).Filter("id", id).Update(orm.Params{ "IsDeleted": 1, "DeleteTime": now, "UpdateTime": now, }) return err } // oaDocCategoryExists 判断指定范围内是否存在该分类(私密空间按 user_id 隔离)。 func oaDocCategoryExists(a OaDocActor, scope string, id uint64) (bool, error) { n, err := oaDocCategoryBase(a, scope).Filter("id", id).Count() if err != nil { return false, err } return n > 0, nil } // oaDocCheckCategoryOwner 校验分类是否可被该访问者操作。 // 共享分类(user_id=0)租户内任何成员均可操作;私密分类(user_id>0)仅归属用户本人可操作。 func oaDocCheckCategoryOwner(a OaDocActor, item *models.OaDocCategory) error { if item.UserID == 0 { return nil } if item.UserID == a.UID && a.UID != 0 { return nil } return ErrDocCategoryNotFound } // oaDocCategoryUsable 读取指定范围内的分类并校验当前访问者是否可以使用它(用于文档归类)。 // 不存在或不属于当前范围/用户时返回 ok=false。 func oaDocCategoryUsable(a OaDocActor, scope string, id uint64) (models.OaDocCategory, bool, error) { var item models.OaDocCategory if err := oaDocCategoryBase(a, scope).Filter("id", id).One(&item); err != nil { return item, false, nil } return item, true, nil } // ---------------- 文档 ---------------- // oaDocBase 租户内未删除文档的基础查询集 func oaDocBase(tid int) orm.QuerySeter { return models.Orm.QueryTable(new(models.OaDoc)). Filter("tid", tid). Filter("is_deleted", 0) } // OaDocListParams 文档列表查询条件。 // Scope 限定空间:"shared"=共享文档(租户公开),"personal"=私密文档(绑定用户级别),"all"=跨两个空间。 // Status/DocType/Star 使用 -1 表示"全部";CategoryID > 0 时包含其全部子孙分类(须与 Scope 同空间)。 // Mine 为 true 时只看本人创建的文档。Actor 用于租户内用户级可见性过滤。 type OaDocListParams struct { Actor OaDocActor Scope string Keyword string CategoryID uint64 Uncategorized bool // true 时只看未分类(category_id = 0)的文档 Visibility int // -1 不限(由 Scope 推导);0 公开;1 私密 Mine bool // true 时只看本人创建的文档 Status int DocType int Tag string Star int Page int PageSize int } // OaDocList 分页查询文档(自动应用可见性与范围过滤)。 func OaDocList(a OaDocActor, p OaDocListParams) ([]models.OaDoc, int64, error) { scope := oaDocScopeOf(p.Scope) qs := oaDocApplyVisible(oaDocBase(a.Tid), a) if scope != models.DocScopeAll { qs = qs.Filter("visibility", oaDocScopeVisibility(scope)) } if p.Mine { qs = qs.Filter("creator_id", a.UID) } if kw := strings.TrimSpace(p.Keyword); kw != "" { cond := orm.NewCondition() cond = cond.Or("title__icontains", kw). Or("file_name__icontains", kw). Or("tags__icontains", kw). Or("summary__icontains", kw). Or("owner_name__icontains", kw) qs = qs.SetCond(qs.GetCond().AndCond(cond)) } if p.Uncategorized { qs = oaDocApplyUncategorized(qs, a, scope) } else if p.CategoryID > 0 { ids, err := oaDocCategoryIDsWithChildren(a, scope, p.CategoryID) if err != nil { return nil, 0, err } if len(ids) > 0 { qs = qs.Filter("category_id__in", ids) } else { // 所选分类不在当前空间,直接返回空结果 qs = qs.Filter("category_id__in", []uint64{}) } } if p.Status >= 0 { qs = qs.Filter("status", int8(p.Status)) } if p.DocType >= 0 { qs = qs.Filter("doc_type", int8(p.DocType)) } if tag := strings.TrimSpace(p.Tag); tag != "" { qs = qs.Filter("tags__icontains", tag) } if p.Star == 1 { qs = qs.Filter("is_star", 1) } total, err := qs.Count() if err != nil { return nil, 0, err } page, pageSize := p.Page, p.PageSize if page < 1 { page = 1 } if pageSize < 1 { pageSize = 20 } var list []models.OaDoc _, err = qs.OrderBy("-is_star", "-update_time", "-id"). Limit(pageSize).Offset((page - 1) * pageSize). All(&list) if err != nil && err != orm.ErrNoRows { return nil, 0, err } if list == nil { list = []models.OaDoc{} } // 列表不返回体积较大的编辑源,仅标记是否在线制作 for i := range list { if strings.TrimSpace(list[i].Content) != "" { list[i].IsOnline = 1 } list[i].Content = "" } return list, total, nil } // OaDocGet 读取文档详情(不累加查看次数)。私密文档对无权限者返回未找到。 func OaDocGet(a OaDocActor, id uint64) (*models.OaDoc, error) { var item models.OaDoc qs := oaDocApplyVisible(oaDocBase(a.Tid).Filter("id", id), a) if err := qs.One(&item); err != nil { return nil, ErrDocNotFound } return &item, nil } // OaDocView 读取文档详情并累加查看次数。 func OaDocView(a OaDocActor, id uint64) (*models.OaDoc, error) { item, err := OaDocGet(a, id) if err != nil { return nil, err } _, _ = models.Orm.Raw( "UPDATE yz_backend_oa_doc SET view_count = view_count + 1 WHERE id = ?", id).Exec() item.ViewCount++ return item, nil } // OaDocDownload 记录一次下载并回传文档(供前端拿到下载地址)。 func OaDocDownload(a OaDocActor, id uint64) (*models.OaDoc, error) { item, err := OaDocGet(a, id) if err != nil { return nil, err } _, _ = models.Orm.Raw( "UPDATE yz_backend_oa_doc SET download_count = download_count + 1 WHERE id = ?", id).Exec() item.DownCount++ return item, nil } // OaDocSaveParams 文档新增/编辑入参。 // SetCategory 为 true 时才以 CategoryID 覆盖原值(允许显式改为 0 即"未分类")。 type OaDocSaveParams struct { SetCategory bool CategoryID uint64 // SetVisibility 为 true 时才以 Visibility 覆盖原值, // 避免"未传该字段"被当成"改为租户公开"(int8 零值无法区分未传与显式 0) SetVisibility bool Title string FileID uint64 FileURL string FileName string Ext string Size uint64 Tags string Summary string // Content 在线制作文档的编辑源(富文本 HTML),SetContent 为 true 时才覆盖 SetContent bool Content string Status int8 Version int OwnerID uint64 OwnerName string IsStar int8 Visibility int8 // 0-租户公开 1-私密 } // OaDocCreate 新增文档。 // 若未传标题则回退为原始文件名(去扩展名);doc_type 由扩展名推断。 func OaDocCreate(a OaDocActor, operatorName string, p OaDocSaveParams) (*models.OaDoc, error) { tid := a.Tid title := strings.TrimSpace(p.Title) if title == "" { title = strings.TrimSpace(strings.TrimSuffix(p.FileName, "."+p.Ext)) } if title == "" { return nil, ErrDocTitleEmpty } // 分类须与文档可见性同空间:共享(visibility=0)→共享空间;私密(visibility=1)→私密空间(绑定用户级别)。 if p.CategoryID > 0 { scope := oaDocScopeOfVisibility(p.Visibility) if _, ok, err := oaDocCategoryUsable(a, scope, p.CategoryID); err != nil { return nil, err } else if !ok { return nil, ErrDocCategoryInvalid } } ext := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(p.Ext), ".")) version := p.Version if version < 1 { version = 1 } if p.Status < 0 || p.Status > 2 { p.Status = models.DocStatusDraft } if p.IsStar != 1 { p.IsStar = 0 } item := &models.OaDoc{ Tid: tid, CategoryID: p.CategoryID, Title: title, FileID: p.FileID, FileURL: strings.TrimSpace(p.FileURL), FileName: strings.TrimSpace(p.FileName), Ext: ext, Size: p.Size, DocType: models.DocTypeByExt(ext), Tags: strings.TrimSpace(p.Tags), Summary: strings.TrimSpace(p.Summary), Content: p.Content, Status: p.Status, Version: version, IsStar: p.IsStar, OwnerID: p.OwnerID, OwnerName: strings.TrimSpace(p.OwnerName), CreatorID: a.UID, CreatorName: operatorName, Visibility: visibilityOf(p.Visibility), IsDeleted: 0, } if _, err := models.Orm.Insert(item); err != nil { return nil, err } return item, nil } // visibilityOf 归一化可见性取值:仅 1 视为私密,其余一律公开。 func visibilityOf(v int8) int8 { if v == 1 { return 1 } return 0 } // OaDocUpdate 编辑文档(仅更新传入的字段)。 func OaDocUpdate(a OaDocActor, id uint64, p OaDocSaveParams) (*models.OaDoc, error) { var item models.OaDoc if err := oaDocApplyVisible(oaDocBase(a.Tid).Filter("id", id), a).One(&item); err != nil { return nil, ErrDocNotFound } if !oaDocCanManage(a, &item) { return nil, ErrDocNoPermission } fields := []string{"UpdateTime"} if p.SetCategory && p.CategoryID != item.CategoryID { // 分类须与文档可见性同空间:以"更新后"的可见性推导范围 if p.CategoryID > 0 { v := item.Visibility if p.SetVisibility { v = p.Visibility } scope := oaDocScopeOfVisibility(v) if _, ok, err := oaDocCategoryUsable(a, scope, p.CategoryID); err != nil { return nil, err } else if !ok { return nil, ErrDocCategoryInvalid } } item.CategoryID = p.CategoryID fields = append(fields, "CategoryID") } if p.SetVisibility { if v := visibilityOf(p.Visibility); v != item.Visibility { item.Visibility = v fields = append(fields, "Visibility") } } if title := strings.TrimSpace(p.Title); title != "" && title != item.Title { item.Title = title fields = append(fields, "Title") } if url := strings.TrimSpace(p.FileURL); url != "" { item.FileURL = url fields = append(fields, "FileURL") if p.FileID > 0 { item.FileID = p.FileID fields = append(fields, "FileID") } if name := strings.TrimSpace(p.FileName); name != "" { item.FileName = name fields = append(fields, "FileName") } if ext := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(p.Ext), ".")); ext != "" { item.Ext = ext item.DocType = models.DocTypeByExt(ext) fields = append(fields, "Ext", "DocType") } if p.Size > 0 { item.Size = p.Size fields = append(fields, "Size") } } if tags := strings.TrimSpace(p.Tags); tags != item.Tags { item.Tags = tags fields = append(fields, "Tags") } if summary := strings.TrimSpace(p.Summary); summary != item.Summary { item.Summary = summary fields = append(fields, "Summary") } if p.Status >= 0 && p.Status <= 2 && p.Status != item.Status { item.Status = p.Status fields = append(fields, "Status") } if p.Version > 0 && p.Version != item.Version { item.Version = p.Version fields = append(fields, "Version") } if p.IsStar == 0 || p.IsStar == 1 { if p.IsStar != item.IsStar { item.IsStar = p.IsStar fields = append(fields, "IsStar") } } if p.OwnerID > 0 { item.OwnerID = p.OwnerID fields = append(fields, "OwnerID") } if owner := strings.TrimSpace(p.OwnerName); owner != "" && owner != item.OwnerName { item.OwnerName = owner fields = append(fields, "OwnerName") } // 在线文档编辑源:仅在显式传入时覆盖 if p.SetContent && p.Content != item.Content { item.Content = p.Content fields = append(fields, "Content") } if len(fields) == 1 { return &item, nil } now := time.Now() item.UpdateTime = &now if _, err := models.Orm.Update(&item, fields...); err != nil { return nil, err } return &item, nil } // OaDocDelete 批量软删除文档,并清理与被删文档相关的关联关系。 // 仅对"可见且可管理"的文档生效;若给定 ID 全部无权操作则返回 ErrDocNoPermission。 func OaDocDelete(a OaDocActor, ids []uint64) (int64, error) { ids = normalizeIDs(ids) if len(ids) == 0 { return 0, ErrDocIDEmpty } var list []models.OaDoc if _, err := oaDocApplyVisible(oaDocBase(a.Tid).Filter("id__in", ids), a).All(&list); err != nil && err != orm.ErrNoRows { return 0, err } allowed := make([]uint64, 0, len(list)) for _, d := range list { if oaDocCanManage(a, &d) { allowed = append(allowed, d.ID) } } if len(allowed) == 0 { return 0, ErrDocNoPermission } now := time.Now() n, err := oaDocBase(a.Tid).Filter("id__in", allowed).Update(orm.Params{ "IsDeleted": 1, "DeleteTime": now, "UpdateTime": now, }) if err != nil { return 0, err } // 清理被删文档参与的全部关联关系,避免图谱出现悬挂连线 // 注意:SetCond 会整体替换查询条件,tid 必须写进 cond,否则会误删其它租户的数据 cond := orm.NewCondition(). And("tid", a.Tid). AndCond(orm.NewCondition(). Or("source_id__in", allowed). Or("target_id__in", allowed)) _, _ = models.Orm.QueryTable(new(models.OaDocLink)).SetCond(cond).Delete() return n, nil } // OaDocMove 批量移动文档到目标分类(0 表示未分类)。仅对可管理文档生效。 func OaDocMove(a OaDocActor, ids []uint64, categoryID uint64) (int64, error) { ids = normalizeIDs(ids) if len(ids) == 0 { return 0, ErrDocIDEmpty } // 移动仅改变文档归属分类;分类须与每篇文档的可见性同空间(共享/私密) if categoryID > 0 { var sample models.OaDoc if _, err := oaDocApplyVisible(oaDocBase(a.Tid).Filter("id__in", ids), a).OrderBy("id").Limit(1).All(&sample); err == nil { scope := oaDocScopeOfVisibility(sample.Visibility) if _, ok, err := oaDocCategoryUsable(a, scope, categoryID); err != nil { return 0, err } else if !ok { return 0, ErrDocCategoryInvalid } } } var list []models.OaDoc if _, err := oaDocApplyVisible(oaDocBase(a.Tid).Filter("id__in", ids), a).All(&list); err != nil && err != orm.ErrNoRows { return 0, err } allowed := make([]uint64, 0, len(list)) for _, d := range list { if oaDocCanManage(a, &d) { allowed = append(allowed, d.ID) } } if len(allowed) == 0 { return 0, ErrDocNoPermission } now := time.Now() params := orm.Params{ "CategoryID": categoryID, "UpdateTime": now, } return oaDocBase(a.Tid).Filter("id__in", allowed).Update(params) } // OaDocToggleStar 收藏 / 取消收藏切换,返回切换后的状态。 func OaDocToggleStar(a OaDocActor, id uint64) (int8, error) { var item models.OaDoc if err := oaDocApplyVisible(oaDocBase(a.Tid).Filter("id", id), a).One(&item); err != nil { return 0, ErrDocNotFound } if item.IsStar == 1 { item.IsStar = 0 } else { item.IsStar = 1 } if _, err := models.Orm.Update(&item, "IsStar"); err != nil { return 0, err } return item.IsStar, nil } // OaDocStatsResult 文档库概览统计 type OaDocStatsResult struct { Total int64 `json:"total"` Published int64 `json:"published"` Draft int64 `json:"draft"` Archived int64 `json:"archived"` Starred int64 `json:"starred"` Uncategorized int64 `json:"uncategorized"` LinkCount int64 `json:"link_count"` // 可见性维度概览(不受当前 visibility 筛选影响,始终反映全库口径): PublicCount int64 `json:"public_count"` // 团队公开文档数 PrivateCount int64 `json:"private_count"` // 私密文档数 MineCount int64 `json:"mine_count"` // 我创建的文档数 TypeCounts []OaDocTypeCount `json:"type_counts"` RecentDocs []models.OaDoc `json:"recent_docs"` HotDocs []models.OaDoc `json:"hot_docs"` } // OaDocTypeCount 按文档类型统计 type OaDocTypeCount struct { DocType int8 `json:"doc_type"` Count int64 `json:"count"` } // OaDocStats 文档库概览:总数、状态分布、类型分布、最近更新与热门文档。 // scope 限定统计口径("shared" 共享文档空间 / "personal" 私密文档空间 / "all" 跨空间);可见性约束同样生效。 func OaDocStats(a OaDocActor, scope string) (*OaDocStatsResult, error) { scope = oaDocScopeOf(scope) vis := int8(-1) if scope != models.DocScopeAll { vis = oaDocScopeVisibility(scope) } // 当前范围内的全部可见文档(私密仅创建者/被共享者可见) allVisible := oaDocApplyVisible(oaDocBase(a.Tid), a) if vis >= 0 { allVisible = allVisible.Filter("visibility", vis) } mineCount, _ := allVisible.Filter("creator_id", a.UID).Count() base := allVisible total, _ := base.Count() published, _ := base.Filter("status", models.DocStatusPublish).Count() draft, _ := base.Filter("status", models.DocStatusDraft).Count() archived, _ := base.Filter("status", models.DocStatusArchive).Count() starred, _ := base.Filter("is_star", 1).Count() uncategorized, _ := oaDocApplyUncategorized(base, a, scope).Count() // 关联数只统计"当前空间口径下文档发起"的连线 linkCount := int64(0) linkExtra, linkExtraArgs := oaDocVisibilityRestrictSQL(a, "d.") linkArgs := []interface{}{a.Tid} if vis >= 0 { linkArgs = append(linkArgs, vis) } linkArgs = append(linkArgs, linkExtraArgs...) var linkRows []struct { Cnt int64 `orm:"column(cnt)"` } visClause := "" if vis >= 0 { visClause = " AND d.visibility = ?" } _, _ = models.Orm.Raw( `SELECT COUNT(*) AS cnt FROM yz_backend_oa_doc_link l WHERE l.tid = ?`+visClause+` AND EXISTS ( SELECT 1 FROM yz_backend_oa_doc d WHERE d.id = l.source_id AND d.is_deleted = 0`+linkExtra+")", linkArgs...).QueryRows(&linkRows) if len(linkRows) > 0 { linkCount = linkRows[0].Cnt } var rows []struct { DocType int8 `orm:"column(doc_type)"` Cnt int64 `orm:"column(cnt)"` } typeExtra, typeExtraArgs := oaDocVisibilityRestrictSQL(a, "") typeArgs := []interface{}{a.Tid} if vis >= 0 { typeArgs = append(typeArgs, vis) } typeArgs = append(typeArgs, typeExtraArgs...) _, _ = models.Orm.Raw( "SELECT doc_type, COUNT(*) AS cnt FROM yz_backend_oa_doc WHERE tid = ? AND is_deleted = 0"+ visClause+typeExtra+" GROUP BY doc_type ORDER BY cnt DESC", typeArgs...).QueryRows(&rows) typeCounts := make([]OaDocTypeCount, 0, len(rows)) for _, r := range rows { typeCounts = append(typeCounts, OaDocTypeCount{DocType: r.DocType, Count: r.Cnt}) } var recent []models.OaDoc _, _ = base.OrderBy("-update_time", "-id").Limit(8).All(&recent) if recent == nil { recent = []models.OaDoc{} } var hot []models.OaDoc _, _ = base.OrderBy("-view_count", "-id").Limit(8).All(&hot) if hot == nil { hot = []models.OaDoc{} } publicCount, privateCount := int64(0), int64(0) switch scope { case models.DocScopeShared: publicCount = total case models.DocScopePersonal: privateCount = total case models.DocScopeAll: publicCount, _ = oaDocApplyVisible(oaDocBase(a.Tid), a).Filter("visibility", models.DocVisibilityPublic).Count() privateCount, _ = oaDocApplyVisible(oaDocBase(a.Tid), a).Filter("visibility", models.DocVisibilityPrivate).Count() } return &OaDocStatsResult{ Total: total, Published: published, Draft: draft, Archived: archived, Starred: starred, Uncategorized: uncategorized, LinkCount: linkCount, PublicCount: publicCount, PrivateCount: privateCount, MineCount: mineCount, TypeCounts: typeCounts, RecentDocs: recent, HotDocs: hot, }, nil } // ---------------- 文档关联 ---------------- // OaDocLinkItem 关联关系 + 两端文档标题,便于前端直接展示 type OaDocLinkItem struct { ID uint64 `json:"id"` Tid int `json:"tid"` SourceID uint64 `json:"source_id"` TargetID uint64 `json:"target_id"` Relation string `json:"relation"` RelationCN string `json:"relation_cn"` Remark string `json:"remark"` CreateTime time.Time `json:"create_time"` SourceTitle string `json:"source_title"` TargetTitle string `json:"target_title"` } // OaDocLinkCreate 建立两个文档之间的关联。 func OaDocLinkCreate(a OaDocActor, creatorID, sourceID, targetID uint64, relation, remark string) (*models.OaDocLink, error) { tid := a.Tid if sourceID == 0 || targetID == 0 { return nil, ErrDocNotFound } if sourceID == targetID { return nil, ErrDocLinkSelf } relation = strings.TrimSpace(relation) if relation == "" { relation = models.DocRelationRelated } for _, id := range []uint64{sourceID, targetID} { // 关联两端文档必须对当前访问者可见,避免私密文档被越权串接 if _, err := OaDocGet(a, id); err != nil { return nil, ErrDocLinkTargetNotFound } } qs := models.Orm.QueryTable(new(models.OaDocLink)). Filter("tid", tid). Filter("source_id", sourceID). Filter("target_id", targetID). Filter("relation", relation) if qs.Exist() { return nil, ErrDocLinkDuplicate } item := &models.OaDocLink{ Tid: tid, SourceID: sourceID, TargetID: targetID, Relation: relation, Remark: strings.TrimSpace(remark), CreatorID: creatorID, } if _, err := models.Orm.Insert(item); err != nil { return nil, err } // 反向补一条同类关系,使图谱连线无向展示 reverse := *item reverse.ID = 0 reverse.SourceID, reverse.TargetID = targetID, sourceID if _, err := models.Orm.Insert(&reverse); err != nil { // 唯一索引冲突说明反向关系已存在,忽略即可 _ = err } return item, nil } // OaDocLinkDelete 删除关联关系(同时清理反向关系,保证图谱双向一致)。 func OaDocLinkDelete(a OaDocActor, id uint64) error { tid := a.Tid var item models.OaDocLink if err := models.Orm.QueryTable(new(models.OaDocLink)). Filter("tid", tid).Filter("id", id).One(&item); err != nil { return ErrDocLinkNotFound } // 关联两端文档必须对当前访问者可见,避免私密文档被越权解除关联 if _, err := OaDocGet(a, item.SourceID); err != nil { return ErrDocNoPermission } if _, err := OaDocGet(a, item.TargetID); err != nil { return ErrDocNoPermission } // SetCond 会整体替换查询条件,tid 必须写进 cond forward := orm.NewCondition(). And("source_id", item.SourceID). And("target_id", item.TargetID). And("relation", item.Relation) backward := orm.NewCondition(). And("source_id", item.TargetID). And("target_id", item.SourceID). And("relation", item.Relation) cond := orm.NewCondition(). And("tid", tid). AndCond(orm.NewCondition().OrCond(forward).OrCond(backward)) _, err := models.Orm.QueryTable(new(models.OaDocLink)).SetCond(cond).Delete() return err } // OaDocLinkList 查询与某文档相关的全部关联(含反向),all 为 true 时返回租户全部关联。 // 仅返回两端文档对当前访问者均可见的关联,避免私密文档被越权探查。 func OaDocLinkList(a OaDocActor, docID uint64, all bool) ([]OaDocLinkItem, error) { sql := ` SELECT l.id, l.tid, l.source_id, l.target_id, l.relation, l.remark, l.create_time, IFNULL(s.title, '') AS source_title, IFNULL(t.title, '') AS target_title FROM yz_backend_oa_doc_link l LEFT JOIN yz_backend_oa_doc s ON s.id = l.source_id AND s.is_deleted = 0 LEFT JOIN yz_backend_oa_doc t ON t.id = l.target_id AND t.is_deleted = 0 ` args := []interface{}{} if all { sql += "WHERE l.tid = ? ORDER BY l.id DESC LIMIT 500" args = append(args, a.Tid) } else { sql += "WHERE l.tid = ? AND (l.source_id = ? OR l.target_id = ?) ORDER BY l.id DESC LIMIT 200" args = append(args, a.Tid, docID, docID) } var rows []OaDocLinkItem if _, err := models.Orm.Raw(sql, args...).QueryRows(&rows); err != nil { return nil, err } // 收集所有涉及的文档 ID,批量判定可见性后过滤 endpointIDs := make([]uint64, 0, len(rows)*2) seen := make(map[uint64]bool, len(rows)*2) for _, r := range rows { if !seen[r.SourceID] { seen[r.SourceID] = true endpointIDs = append(endpointIDs, r.SourceID) } if !seen[r.TargetID] { seen[r.TargetID] = true endpointIDs = append(endpointIDs, r.TargetID) } } visible := oaDocVisibleIDSet(a, endpointIDs) out := make([]OaDocLinkItem, 0, len(rows)) for _, r := range rows { if !visible[r.SourceID] || !visible[r.TargetID] { continue } r.RelationCN = DocRelationLabel(r.Relation) out = append(out, r) } if out == nil { out = []OaDocLinkItem{} } return out, nil } // ---------------- 文档共享(私密文档可见性授权) ---------------- // OaDocShareItem 共享目标项(创建/更新入参)。 type OaDocShareItem struct { ShareType int8 `json:"share_type"` // 0-用户 1-部门 TargetID uint64 `json:"target_id"` } // OaDocShareView 共享项视图(含目标名称)。 type OaDocShareView struct { ID uint64 `json:"id"` DocID uint64 `json:"doc_id"` ShareType int8 `json:"share_type"` TargetID uint64 `json:"target_id"` TargetName string `json:"target_name"` } // OaDocShareList 读取文档的共享清单(仅可见文档)。 func OaDocShareList(a OaDocActor, docID uint64) ([]OaDocShareView, error) { if _, err := OaDocGet(a, docID); err != nil { return nil, err } var rows []models.OaDocShare if _, err := models.Orm.QueryTable(new(models.OaDocShare)). Filter("tid", a.Tid).Filter("doc_id", docID).OrderBy("id").All(&rows); err != nil && err != orm.ErrNoRows { return nil, err } views := make([]OaDocShareView, 0, len(rows)) for _, r := range rows { views = append(views, OaDocShareView{ ID: r.ID, DocID: r.DocID, ShareType: r.ShareType, TargetID: r.TargetID, TargetName: oaDocShareTargetName(a.Tid, r.ShareType, r.TargetID), }) } if views == nil { views = []OaDocShareView{} } return views, nil } // oaDocShareTargetName 解析共享目标名称(用户姓名 / 部门名称)。 func oaDocShareTargetName(tid int, shareType int8, targetID uint64) string { if targetID == 0 { return "" } if shareType == models.DocShareTypeOrg { var org models.BackendOrganization if err := models.Orm.QueryTable(new(models.BackendOrganization)). Filter("tid", tid).Filter("id", targetID).One(&org); err == nil { return org.OrgName } return "" } var u models.SystemTenantUser if err := models.Orm.QueryTable(new(models.SystemTenantUser)). Filter("tid", tid).Filter("uid", targetID).Filter("delete_time__isnull", true).One(&u); err == nil { if u.Name != nil { return *u.Name } } return "" } // OaDocShareSet 全量替换文档的共享授权:仅创建者或全权限用户可设置。 func OaDocShareSet(a OaDocActor, docID uint64, items []OaDocShareItem) ([]OaDocShareView, error) { doc, err := OaDocGet(a, docID) if err != nil { return nil, err } if !oaDocCanManage(a, doc) { return nil, ErrDocNoPermission } seen := make(map[string]bool, len(items)) clean := make([]OaDocShareItem, 0, len(items)) for _, it := range items { if it.TargetID == 0 { continue } st := it.ShareType if st != models.DocShareTypeUser && st != models.DocShareTypeOrg { st = models.DocShareTypeUser } key := fmt.Sprintf("%d_%d", st, it.TargetID) if seen[key] { continue } seen[key] = true clean = append(clean, OaDocShareItem{ShareType: st, TargetID: it.TargetID}) } // 全量替换:先清空再写入 if _, err := models.Orm.QueryTable(new(models.OaDocShare)). Filter("tid", a.Tid).Filter("doc_id", docID).Delete(); err != nil { return nil, err } for _, it := range clean { rec := models.OaDocShare{ Tid: a.Tid, DocID: docID, ShareType: it.ShareType, TargetID: it.TargetID, CreatorID: a.UID, } if _, err := models.Orm.Insert(&rec); err != nil { return nil, err } } return OaDocShareList(a, docID) } // OaDocShareRemove 删除单条共享授权(需对文档可管理)。 func OaDocShareRemove(a OaDocActor, id uint64) error { var item models.OaDocShare if err := models.Orm.QueryTable(new(models.OaDocShare)). Filter("tid", a.Tid).Filter("id", id).One(&item); err != nil { return ErrDocLinkNotFound } doc, err := OaDocGet(a, item.DocID) if err != nil { return err } if !oaDocCanManage(a, doc) { return ErrDocNoPermission } _, err = models.Orm.QueryTable(new(models.OaDocShare)). Filter("tid", a.Tid).Filter("id", id).Delete() return err } // OaDocMemberItem 租户用户(供文档共享选人)。 type OaDocMemberItem struct { UID uint64 `json:"uid"` Name string `json:"name"` Account string `json:"account"` OrgID uint64 `json:"org_id"` OrgName string `json:"org_name"` } // OaDocMembers 查询租户内用户(用于私密文档共享选人)。 func OaDocMembers(tid int, keyword string, limit int) ([]OaDocMemberItem, error) { if limit < 1 || limit > 200 { limit = 100 } qs := models.Orm.QueryTable(new(models.SystemTenantUser)). Filter("tid", tid). Filter("status", 1). Filter("delete_time__isnull", true) if kw := strings.TrimSpace(keyword); kw != "" { cond := orm.NewCondition(). Or("name__icontains", kw). Or("account__icontains", kw) qs = qs.SetCond(qs.GetCond().AndCond(cond)) } var users []models.SystemTenantUser if _, err := qs.OrderBy("-id").Limit(limit).All(&users); err != nil && err != orm.ErrNoRows { return nil, err } orgIDs := make([]uint64, 0, len(users)) for _, u := range users { if u.OrgID > 0 { orgIDs = append(orgIDs, u.OrgID) } } orgName := make(map[uint64]string, len(orgIDs)) if len(orgIDs) > 0 { var orgs []models.BackendOrganization if _, err := models.Orm.QueryTable(new(models.BackendOrganization)). Filter("tid", tid).Filter("id__in", orgIDs).All(&orgs); err == nil { for _, o := range orgs { orgName[o.ID] = o.OrgName } } } out := make([]OaDocMemberItem, 0, len(users)) for _, u := range users { name := "" if u.Name != nil { name = *u.Name } account := "" if u.Account != nil { account = *u.Account } out = append(out, OaDocMemberItem{ UID: u.Uid, Name: name, Account: account, OrgID: u.OrgID, OrgName: orgName[u.OrgID], }) } if out == nil { out = []OaDocMemberItem{} } return out, nil } // ---------------- 文档图谱 ---------------- // OaDocGraphParams 图谱查询条件 type OaDocGraphParams struct { Keyword string CategoryID uint64 Relation string IncludeCategory bool OnlyLinked bool Limit int } // OaDocGraphNode 图谱节点;Category=0 文档,Category=1 分类 type OaDocGraphNode struct { ID string `json:"id"` Name string `json:"name"` Category int `json:"category"` DocID uint64 `json:"doc_id"` CategoryID uint64 `json:"category_id"` Ext string `json:"ext"` DocType int8 `json:"doc_type"` Status int8 `json:"status"` Tags string `json:"tags"` OwnerName string `json:"owner_name"` ViewCount int `json:"view_count"` Degree int `json:"degree"` Value int `json:"value"` } // OaDocGraphLink 图谱连线;Kind=doc 为文档关联,Kind=belong 为文档归属分类 type OaDocGraphLink struct { Source string `json:"source"` Target string `json:"target"` Relation string `json:"relation"` Remark string `json:"remark"` Label string `json:"label"` Kind string `json:"kind"` } // OaDocRelationCount 关系类型分布 type OaDocRelationCount struct { Relation string `json:"relation"` Label string `json:"label"` Count int64 `json:"count"` } // OaDocGraphStats 图谱概览统计 type OaDocGraphStats struct { DocCount int64 `json:"doc_count"` LinkCount int64 `json:"link_count"` CategoryCount int64 `json:"category_count"` IsolatedCount int64 `json:"isolated_count"` AvgDegree float64 `json:"avg_degree"` RelationCounts []OaDocRelationCount `json:"relation_counts"` TopNodes []OaDocGraphNode `json:"top_nodes"` } // OaDocGraphResult 图谱数据 type OaDocGraphResult struct { Nodes []OaDocGraphNode `json:"nodes"` Links []OaDocGraphLink `json:"links"` Categories []string `json:"categories"` Stats OaDocGraphStats `json:"stats"` } // OaDocGraph 构建文档知识图谱。 // // 节点包含文档与(可选)分类中心;连线包含: // - kind=doc :文档之间的关联关系(yz_backend_oa_doc_link) // - kind=belong :文档到所属分类的归属关系,使孤立文档也能在图谱中定位 func OaDocGraph(a OaDocActor, p OaDocGraphParams) (*OaDocGraphResult, error) { limit := p.Limit if limit < 1 || limit > 1000 { limit = 300 } docs, _, err := OaDocList(a, OaDocListParams{ Actor: a, Scope: models.DocScopeAll, Keyword: p.Keyword, CategoryID: p.CategoryID, Status: -1, DocType: -1, Star: -1, Page: 1, PageSize: limit, }) if err != nil { return nil, err } docIDs := make([]uint64, 0, len(docs)) for _, d := range docs { docIDs = append(docIDs, d.ID) } nodes := make([]OaDocGraphNode, 0, len(docs)+8) links := make([]OaDocGraphLink, 0, len(docs)*2) degree := make(map[string]int) for _, d := range docs { nodes = append(nodes, OaDocGraphNode{ ID: fmt.Sprintf("d%d", d.ID), Name: d.Title, Category: 0, DocID: d.ID, CategoryID: d.CategoryID, Ext: d.Ext, DocType: d.DocType, Status: d.Status, Tags: d.Tags, OwnerName: d.OwnerName, ViewCount: d.ViewCount, }) } // 文档之间的关联连线 if len(docIDs) > 0 { // SetCond 会整体替换查询条件,tid 必须写进 cond cond := orm.NewCondition(). And("tid", a.Tid). And("source_id__in", docIDs). And("target_id__in", docIDs) qs := models.Orm.QueryTable(new(models.OaDocLink)).SetCond(cond) if rel := strings.TrimSpace(p.Relation); rel != "" { qs = qs.Filter("relation", rel) } var linkList []models.OaDocLink if _, err := qs.OrderBy("id").All(&linkList); err != nil && err != orm.ErrNoRows { return nil, err } for _, l := range linkList { // 双向各存一条,按 id 去重避免出现两条重复连线 if l.SourceID > l.TargetID { continue } links = append(links, OaDocGraphLink{ Source: fmt.Sprintf("d%d", l.SourceID), Target: fmt.Sprintf("d%d", l.TargetID), Relation: l.Relation, Remark: l.Remark, Label: DocRelationLabel(l.Relation), Kind: "doc", }) degree[fmt.Sprintf("d%d", l.SourceID)]++ degree[fmt.Sprintf("d%d", l.TargetID)]++ } } // 归属分类连线 + 分类中心节点 categoryCount := int64(0) if p.IncludeCategory { // 图谱包含两类分类节点:共享分类(user_id=0,Category=1)与当前用户自己的私密分类(Category=2)。 // 只取用户可见范围(共享 + 本人私密),避免把其他用户的私密分类暴露出来。 catCond := orm.NewCondition(). And("tid", a.Tid). And("is_deleted", 0). AndCond(orm.NewCondition().Or("user_id", 0).Or("user_id", a.UID)) var cats []models.OaDocCategory if _, err := models.Orm.QueryTable(new(models.OaDocCategory)).SetCond(catCond).OrderBy("sort", "id").All(&cats); err != nil && err != orm.ErrNoRows { return nil, err } categoryCount = int64(len(cats)) catByID := make(map[uint64]models.OaDocCategory, len(cats)) for _, c := range cats { catByID[c.ID] = c } // 文档 → 分类 归属连线(仅当分类在当前可见范围内,避免出现指向不存在节点的悬空连线) for _, d := range docs { if d.CategoryID == 0 { continue } if _, ok := catByID[d.CategoryID]; !ok { continue } links = append(links, OaDocGraphLink{ Source: fmt.Sprintf("d%d", d.ID), Target: fmt.Sprintf("c%d", d.CategoryID), Relation: models.DocRelationBelong, Label: DocRelationLabel(models.DocRelationBelong), Kind: "belong", }) degree[fmt.Sprintf("d%d", d.ID)]++ degree[fmt.Sprintf("c%d", d.CategoryID)]++ } // 分类 → 父分类 层级连线(补全分类树的上下级结构,避免顶级/上级分类缺失) for _, c := range cats { if c.ParentID == 0 { continue } if _, ok := catByID[c.ParentID]; !ok { continue } links = append(links, OaDocGraphLink{ Source: fmt.Sprintf("c%d", c.ID), Target: fmt.Sprintf("c%d", c.ParentID), Relation: models.DocRelationBelong, Label: "子分类", Kind: "cate", }) degree[fmt.Sprintf("c%d", c.ID)]++ degree[fmt.Sprintf("c%d", c.ParentID)]++ } // 当前可见范围内的全部分类都作为中心节点,保证分类树完整 for _, c := range cats { catKind := 1 if c.UserID != 0 { catKind = 2 // 私密分类 } nodes = append(nodes, OaDocGraphNode{ ID: fmt.Sprintf("c%d", c.ID), Name: c.Name, Category: catKind, CategoryID: c.ID, }) } } // 仅看有关系的文档:剔除孤立节点 if p.OnlyLinked { kept := make([]OaDocGraphNode, 0, len(nodes)) for _, n := range nodes { if degree[n.ID] > 0 { kept = append(kept, n) } } nodes = kept } for i := range nodes { nodes[i].Degree = degree[nodes[i].ID] nodes[i].Value = degree[nodes[i].ID] + 1 } // 统计 stats := OaDocGraphStats{ DocCount: int64(len(docs)), CategoryCount: categoryCount, } isolated := int64(0) for _, n := range nodes { if n.Category == 0 && n.Degree == 0 { isolated++ } } stats.IsolatedCount = isolated docLinkCount := int64(0) for _, l := range links { if l.Kind == "doc" { docLinkCount++ } } stats.LinkCount = docLinkCount if len(docs) > 0 { stats.AvgDegree = float64(docLinkCount*2) / float64(len(docs)) } // 关系分布直接由已可见的关联连线统计(每条去重连线计 1,与图谱展示一致) relCountMap := make(map[string]int64, 4) for _, l := range links { if l.Kind != "doc" { continue } relCountMap[l.Relation]++ } stats.RelationCounts = make([]OaDocRelationCount, 0, len(relCountMap)) for rel, cnt := range relCountMap { stats.RelationCounts = append(stats.RelationCounts, OaDocRelationCount{ Relation: rel, Label: DocRelationLabel(rel), Count: cnt, }) } sort.Slice(stats.RelationCounts, func(i, j int) bool { return stats.RelationCounts[i].Count > stats.RelationCounts[j].Count }) sorted := make([]OaDocGraphNode, 0, len(nodes)) for _, n := range nodes { if n.Category == 0 { sorted = append(sorted, n) } } for i := 0; i < len(sorted); i++ { for j := i + 1; j < len(sorted); j++ { if sorted[j].Degree > sorted[i].Degree { sorted[i], sorted[j] = sorted[j], sorted[i] } } } if len(sorted) > 8 { sorted = sorted[:8] } stats.TopNodes = sorted return &OaDocGraphResult{ Nodes: nodes, Links: links, Categories: []string{"文档", "分类"}, Stats: stats, }, nil } // normalizeIDs 过滤无效 ID 并去重 func normalizeIDs(ids []uint64) []uint64 { seen := make(map[uint64]bool, len(ids)) result := make([]uint64, 0, len(ids)) for _, id := range ids { if id > 0 && !seen[id] { seen[id] = true result = append(result, id) } } return result }