Files
2026-09-20 00:19:08 +08:00

1938 lines
58 KiB
Go
Raw Permalink Blame History

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