1958 lines
59 KiB
Go
1958 lines
59 KiB
Go
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 {
|
||
models.EnsureOaDocumentTables()
|
||
models.EnsureTenantUserOrgColumn()
|
||
// 补建租户自己的系统内置分类(项目文档),按 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) {
|
||
models.EnsureOaDocumentTables()
|
||
|
||
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) {
|
||
models.EnsureOaDocumentTables()
|
||
|
||
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) {
|
||
models.EnsureOaDocumentTables()
|
||
|
||
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) {
|
||
models.EnsureOaDocumentTables()
|
||
|
||
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 {
|
||
models.EnsureOaDocumentTables()
|
||
|
||
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) {
|
||
models.EnsureOaDocumentTables()
|
||
|
||
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) {
|
||
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
|
||
// 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) {
|
||
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
|
||
}
|
||
// 分类须与文档可见性同空间:共享(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) {
|
||
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 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) {
|
||
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 {
|
||
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) {
|
||
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"`
|
||
// 可见性维度概览(不受当前 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) {
|
||
models.EnsureOaDocumentTables()
|
||
|
||
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) {
|
||
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,
|
||
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
|
||
}
|