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("不能将分类移动到自己或其子分类下") 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 { models.EnsureOaDocumentTables() models.EnsureTenantUserOrgColumn() 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)) } // 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"` } // oaDocCategoryBase 租户内未删除分类的基础查询集 func oaDocCategoryBase(tid int) orm.QuerySeter { return models.Orm.QueryTable(new(models.OaDocCategory)). Filter("tid", tid). Filter("is_deleted", 0) } // OaDocCategoryTree 返回分类树,每个节点附带该分类下的文档数量(仅统计直属文档)。 // 数量统计同样受可见性约束:无权限的私密文档不计入。 func OaDocCategoryTree(a OaDocActor) ([]*OaDocCategoryItem, error) { models.EnsureOaDocumentTables() var list []models.OaDocCategory if _, err := oaDocCategoryBase(a.Tid).OrderBy("sort", "id").All(&list); err != nil && err != orm.ErrNoRows { return nil, err } counts, err := OaDocCountByCategory(a) 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) } } return roots, nil } // OaDocCountByCategory 统计各分类下未删除文档的数量(仅统计当前访问者可见的文档)。 func OaDocCountByCategory(a OaDocActor) (map[uint64]int64, error) { models.EnsureOaDocumentTables() sql := "SELECT category_id, COUNT(*) AS cnt FROM yz_backend_oa_doc WHERE tid = ? AND is_deleted = 0" args := []interface{}{a.Tid} if !a.IsAdmin { shared := oaDocSharedDocIDs(a) if len(shared) == 0 { shared = []uint64{0} } placeholders := make([]string, len(shared)) for i := range shared { placeholders[i] = "?" } // 占位符顺序需与 args 一致:先 creator_id,再 IN (...) args = append(args, a.UID) for _, id := range shared { args = append(args, id) } sql += fmt.Sprintf( " AND (visibility = 0 OR creator_id = ? OR id IN (%s))", strings.Join(placeholders, ",")) } 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 } // OaDocCategoryCreate 新建分类。 func OaDocCategoryCreate(tid int, parentID uint64, name, remark string, sort int) (*models.OaDocCategory, error) { models.EnsureOaDocumentTables() name = strings.TrimSpace(name) if name == "" { return nil, ErrDocCategoryNameEmpty } if parentID != 0 { ok, err := oaDocCategoryExists(tid, parentID) if err != nil { return nil, err } if !ok { return nil, ErrDocCategoryNotFound } } item := &models.OaDocCategory{ Tid: tid, ParentID: parentID, Name: name, Remark: strings.TrimSpace(remark), Sort: sort, IsDeleted: 0, } if _, err := models.Orm.Insert(item); err != nil { return nil, err } return item, nil } // OaDocCategoryUpdate 修改分类名称/备注/排序,parentID < 0 表示不调整父级。 func OaDocCategoryUpdate(tid int, id uint64, name, remark string, sort int, parentID int64) (*models.OaDocCategory, error) { models.EnsureOaDocumentTables() var item models.OaDocCategory if err := oaDocCategoryBase(tid).Filter("id", id).One(&item); err != nil { return nil, ErrDocCategoryNotFound } fields := []string{"UpdateTime"} if name = strings.TrimSpace(name); name != "" && name != item.Name { 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 { newParent := uint64(parentID) if newParent == id { return nil, ErrDocCategoryCircular } if newParent != 0 { ok, err := oaDocCategoryExists(tid, newParent) if err != nil { return nil, err } if !ok { return nil, ErrDocCategoryNotFound } // 禁止挂到自己的子孙节点下,避免形成环 childIDs, err := OaDocCategoryDescendants(tid, id) 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(tid int, rootID uint64) ([]uint64, error) { var list []models.OaDocCategory if _, err := oaDocCategoryBase(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(tid int, rootID uint64) ([]uint64, error) { if rootID == 0 { return nil, nil } children, err := OaDocCategoryDescendants(tid, rootID) if err != nil { return nil, err } return append([]uint64{rootID}, children...), nil } // OaDocCategoryDelete 删除分类:存在子分类或文档时拒绝删除。 func OaDocCategoryDelete(tid int, id uint64) error { models.EnsureOaDocumentTables() var item models.OaDocCategory if err := oaDocCategoryBase(tid).Filter("id", id).One(&item); err != nil { return ErrDocCategoryNotFound } childCount, err := oaDocCategoryBase(tid).Filter("parent_id", id).Count() if err != nil { return err } if childCount > 0 { return ErrDocCategoryHasChild } docCount, err := oaDocBase(tid).Filter("category_id", id).Count() if err != nil { return err } if docCount > 0 { return ErrDocCategoryHasDoc } now := time.Now() _, err = oaDocCategoryBase(tid).Filter("id", id).Update(orm.Params{ "IsDeleted": 1, "DeleteTime": now, "UpdateTime": now, }) return err } func oaDocCategoryExists(tid int, id uint64) (bool, error) { n, err := oaDocCategoryBase(tid).Filter("id", id).Count() if err != nil { return false, err } return n > 0, nil } // ---------------- 文档 ---------------- // oaDocBase 租户内未删除文档的基础查询集 func oaDocBase(tid int) orm.QuerySeter { return models.Orm.QueryTable(new(models.OaDoc)). Filter("tid", tid). Filter("is_deleted", 0) } // OaDocListParams 文档列表查询条件。 // Status/DocType/Star 使用 -1 表示"全部";CategoryID > 0 时包含其全部子孙分类。 // Actor 为访问者上下文,用于租户内用户级可见性过滤(私密文档只对创建者/被共享者可见)。 type OaDocListParams struct { Actor OaDocActor Keyword string CategoryID uint64 Uncategorized bool // true 时只看未分类(category_id = 0)的文档 Status int DocType int Tag string Star int Page int PageSize int } // OaDocList 分页查询文档(自动应用可见性过滤)。 func OaDocList(a OaDocActor, p OaDocListParams) ([]models.OaDoc, int64, error) { models.EnsureOaDocumentTables() qs := oaDocApplyVisible(oaDocBase(a.Tid), a) 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 = qs.Filter("category_id", 0) } else if p.CategoryID > 0 { ids, err := oaDocCategoryIDsWithChildren(a.Tid, p.CategoryID) if err != nil { return nil, 0, err } if len(ids) > 0 { qs = qs.Filter("category_id__in", ids) } } 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{} } return list, total, nil } // OaDocGet 读取文档详情(不累加查看次数)。私密文档对无权限者返回未找到。 func OaDocGet(a OaDocActor, id uint64) (*models.OaDoc, error) { models.EnsureOaDocumentTables() 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 Title string FileID uint64 FileURL string FileName string Ext string Size uint64 Tags string Summary 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) { models.EnsureOaDocumentTables() tid := a.Tid title := strings.TrimSpace(p.Title) if title == "" { title = strings.TrimSpace(strings.TrimSuffix(p.FileName, "."+p.Ext)) } if title == "" { return nil, ErrDocTitleEmpty } if p.CategoryID > 0 { ok, err := oaDocCategoryExists(tid, p.CategoryID) if err != nil { return nil, err } 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), 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) { models.EnsureOaDocumentTables() 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 v := visibilityOf(p.Visibility); v != item.Visibility { item.Visibility = v fields = append(fields, "Visibility") } if p.SetCategory && p.CategoryID != item.CategoryID { if p.CategoryID > 0 { ok, err := oaDocCategoryExists(a.Tid, p.CategoryID) if err != nil { return nil, err } if !ok { return nil, ErrDocCategoryInvalid } } item.CategoryID = p.CategoryID fields = append(fields, "CategoryID") } 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 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) { models.EnsureOaDocumentTables() 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) { models.EnsureOaDocumentTables() ids = normalizeIDs(ids) if len(ids) == 0 { return 0, ErrDocIDEmpty } if categoryID > 0 { ok, err := oaDocCategoryExists(a.Tid, categoryID) if err != nil { return 0, err } 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() return oaDocBase(a.Tid).Filter("id__in", allowed).Update(orm.Params{ "CategoryID": categoryID, "UpdateTime": now, }) } // OaDocToggleStar 收藏 / 取消收藏切换,返回切换后的状态。 func OaDocToggleStar(a OaDocActor, id uint64) (int8, error) { models.EnsureOaDocumentTables() 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"` 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 文档库概览:总数、状态分布、类型分布、最近更新与热门文档(均受可见性约束)。 func OaDocStats(a OaDocActor) (*OaDocStatsResult, error) { models.EnsureOaDocumentTables() base := oaDocApplyVisible(oaDocBase(a.Tid), a) 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, _ := base.Filter("category_id", 0).Count() linkCount, _ := models.Orm.QueryTable(new(models.OaDocLink)).Filter("tid", a.Tid).Count() var rows []struct { DocType int8 `orm:"column(doc_type)"` Cnt int64 `orm:"column(cnt)"` } _, _ = models.Orm.Raw( "SELECT doc_type, COUNT(*) AS cnt FROM yz_backend_oa_doc WHERE tid = ? AND is_deleted = 0 GROUP BY doc_type ORDER BY cnt DESC", a.Tid).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{} } return &OaDocStatsResult{ Total: total, Published: published, Draft: draft, Archived: archived, Starred: starred, Uncategorized: uncategorized, LinkCount: linkCount, 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) { models.EnsureOaDocumentTables() 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 { models.EnsureOaDocumentTables() 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) { models.EnsureOaDocumentTables() 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) { models.EnsureOaDocumentTables() 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) { models.EnsureOaDocumentTables() limit := p.Limit if limit < 1 || limit > 1000 { limit = 300 } docs, _, err := OaDocList(a, OaDocListParams{ Actor: a, 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 { var cats []models.OaDocCategory if _, err := oaDocCategoryBase(a.Tid).OrderBy("sort", "id").All(&cats); err != nil && err != orm.ErrNoRows { return nil, err } categoryCount = int64(len(cats)) used := make(map[uint64]bool, len(cats)) for _, d := range docs { if d.CategoryID == 0 { continue } used[d.CategoryID] = true 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 !used[c.ID] { continue } nodes = append(nodes, OaDocGraphNode{ ID: fmt.Sprintf("c%d", c.ID), Name: c.Name, Category: 1, 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 }