优化文档相关

This commit is contained in:
2026-09-10 12:57:16 +08:00
parent 8a0334a020
commit e340d9a7d1
17 changed files with 3131 additions and 457 deletions
+476 -129
View File
@@ -18,20 +18,23 @@ import (
// 业务错误
var (
ErrDocCategoryNotFound = errors.New("分类不存在")
ErrDocCategoryHasChild = errors.New("存在子分类,请先删除子分类")
ErrDocCategoryHasDoc = errors.New("分类下仍有文档,请先移动或删除")
ErrDocCategoryNameEmpty = errors.New("请输入分类名称")
ErrDocCategoryCircular = errors.New("不能将分类移动到自己或其子分类下")
ErrDocNotFound = errors.New("文档不存在")
ErrDocTitleEmpty = errors.New("请输入文档标题")
ErrDocCategoryInvalid = errors.New("所属分类不存在")
ErrDocLinkSelf = errors.New("不能关联文档自身")
ErrDocLinkDuplicate = errors.New("该关联关系已存在")
ErrDocLinkTargetNotFound = errors.New("被关联的文档不存在")
ErrDocLinkNotFound = errors.New("关联关系不存在")
ErrDocIDEmpty = errors.New("请选择要操作的文档")
ErrDocNoPermission = errors.New("无权操作该私密文档")
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("无权操作该私密文档")
)
// ---------------- 访问者上下文与可见性 ----------------
@@ -54,6 +57,8 @@ type OaDocActor struct {
func NewOaDocActor(tid int, uid uint64) OaDocActor {
models.EnsureOaDocumentTables()
models.EnsureTenantUserOrgColumn()
// 补建租户自己的系统内置分类(项目文档),按 tid 隔离
models.EnsureOaDocDefaultCategories(tid)
actor := OaDocActor{Tid: tid, UID: uid, OrgIDs: []uint64{}}
@@ -164,6 +169,48 @@ func oaDocApplyVisible(qs orm.QuerySeter, a OaDocActor) orm.QuerySeter {
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))
@@ -217,24 +264,114 @@ type OaDocCategoryItem struct {
Children []*OaDocCategoryItem `json:"children"`
}
// oaDocCategoryBase 租户内未删除分类的基础查询集
func oaDocCategoryBase(tid int) orm.QuerySeter {
// ---------------- 范围(共享 / 私密) ----------------
// 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)
}
// OaDocCategoryTree 返回分类树,每个节点附带该分类下的文档数量(仅统计直属文档)。
// 数量统计同样受可见性约束:无权限的私密文档不计入。
func OaDocCategoryTree(a OaDocActor) ([]*OaDocCategoryItem, error) {
// 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.Tid).OrderBy("sort", "id").All(&list); err != nil && err != orm.ErrNoRows {
if _, err := oaDocCategoryBase(a, scope).OrderBy("-is_system", "sort", "id").All(&list); err != nil && err != orm.ErrNoRows {
return nil, err
}
counts, err := OaDocCountByCategory(a)
counts, err := OaDocCountByCategory(a, scope, -1)
if err != nil {
return nil, err
}
@@ -249,6 +386,7 @@ func OaDocCategoryTree(a OaDocActor) ([]*OaDocCategoryItem, error) {
}
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 {
@@ -257,33 +395,44 @@ func OaDocCategoryTree(a OaDocActor) ([]*OaDocCategoryItem, error) {
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 统计各分类下未删除文档的数量(仅统计当前访问者可见的文档)。
func OaDocCountByCategory(a OaDocActor) (map[uint64]int64, error) {
// 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 !a.IsAdmin {
shared := oaDocSharedDocIDs(a)
if len(shared) == 0 {
shared = []uint64{0}
if scope != models.DocScopeAll {
vis := int8(0)
if visibility >= 0 {
vis = int8(visibility)
} else {
vis = oaDocScopeVisibility(scope)
}
placeholders := make([]string, len(shared))
for i := range shared {
placeholders[i] = "?"
}
// 占位符顺序需与 args 一致:先 creator_id,再 IN (...)
args = append(args, a.UID)
for _, id := range shared {
args = append(args, id)
}
sql += fmt.Sprintf(
" AND (visibility = 0 OR creator_id = ? OR id IN (%s))",
strings.Join(placeholders, ","))
sql += fmt.Sprintf(" AND visibility = %d", vis)
}
extra, extraArgs := oaDocVisibilityRestrictSQL(a, "")
sql += extra
args = append(args, extraArgs...)
sql += " GROUP BY category_id"
var rows []struct {
@@ -300,16 +449,30 @@ func OaDocCountByCategory(a OaDocActor) (map[uint64]int64, error) {
return result, nil
}
// OaDocCategoryCreate 新建分类。
func OaDocCategoryCreate(tid int, parentID uint64, name, remark string, sort int) (*models.OaDocCategory, error) {
// 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(tid, parentID)
ok, err := oaDocCategoryExists(a, scope, parentID)
if err != nil {
return nil, err
}
@@ -319,11 +482,13 @@ func OaDocCategoryCreate(tid int, parentID uint64, name, remark string, sort int
}
item := &models.OaDocCategory{
Tid: tid,
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 {
@@ -333,16 +498,22 @@ func OaDocCategoryCreate(tid int, parentID uint64, name, remark string, sort int
}
// OaDocCategoryUpdate 修改分类名称/备注/排序,parentID < 0 表示不调整父级。
func OaDocCategoryUpdate(tid int, id uint64, name, remark string, sort int, parentID int64) (*models.OaDocCategory, error) {
func OaDocCategoryUpdate(a OaDocActor, id uint64, name, remark string, sort int, parentID int64) (*models.OaDocCategory, error) {
models.EnsureOaDocumentTables()
var item models.OaDocCategory
if err := oaDocCategoryBase(tid).Filter("id", id).One(&item); err != nil {
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")
}
@@ -355,20 +526,22 @@ func OaDocCategoryUpdate(tid int, id uint64, name, remark string, sort int, pare
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(tid, newParent)
ok, err := oaDocCategoryExists(a, item.Scope, newParent)
if err != nil {
return nil, err
}
if !ok {
return nil, ErrDocCategoryNotFound
}
// 禁止挂到自己的子孙节点下,避免形成环
childIDs, err := OaDocCategoryDescendants(tid, id)
childIDs, err := OaDocCategoryDescendants(a, newParent)
if err != nil {
return nil, err
}
@@ -394,9 +567,10 @@ func OaDocCategoryUpdate(tid int, id uint64, name, remark string, sort int, pare
}
// OaDocCategoryDescendants 收集某分类的全部子孙 ID(不含自身)。
func OaDocCategoryDescendants(tid int, rootID uint64) ([]uint64, error) {
// 遍历租户内全部未删除分类(跨空间),用于移动时的环路检测。
func OaDocCategoryDescendants(a OaDocActor, rootID uint64) ([]uint64, error) {
var list []models.OaDocCategory
if _, err := oaDocCategoryBase(tid).All(&list); err != nil && err != orm.ErrNoRows {
if _, err := oaDocCategoryBaseAny(a.Tid).All(&list); err != nil && err != orm.ErrNoRows {
return nil, err
}
children := make(map[uint64][]uint64)
@@ -420,27 +594,35 @@ func OaDocCategoryDescendants(tid int, rootID uint64) ([]uint64, error) {
}
// oaDocCategoryIDsWithChildren 返回指定分类及其所有子孙 ID;rootID=0 时返回 nil 表示不限制。
func oaDocCategoryIDsWithChildren(tid int, rootID uint64) ([]uint64, error) {
func oaDocCategoryIDsWithChildren(a OaDocActor, scope string, rootID uint64) ([]uint64, error) {
if rootID == 0 {
return nil, nil
}
children, err := OaDocCategoryDescendants(tid, rootID)
children, err := OaDocCategoryDescendants(a, rootID)
if err != nil {
return nil, err
}
return append([]uint64{rootID}, children...), nil
}
// OaDocCategoryDelete 删除分类:存在子分类或文档时拒绝删除。
func OaDocCategoryDelete(tid int, id uint64) error {
// OaDocCategoryDelete 删除分类:系统内置分类、存在子分类或文档时拒绝删除。
// 共享空间分类任何租户成员均可删除(内置分类除外);私密空间分类仅本人可删除。
func OaDocCategoryDelete(a OaDocActor, id uint64) error {
models.EnsureOaDocumentTables()
var item models.OaDocCategory
if err := oaDocCategoryBase(tid).Filter("id", id).One(&item); err != nil {
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(tid).Filter("parent_id", id).Count()
childCount, err := oaDocCategoryBase(a, item.Scope).Filter("parent_id", id).Count()
if err != nil {
return err
}
@@ -448,7 +630,7 @@ func OaDocCategoryDelete(tid int, id uint64) error {
return ErrDocCategoryHasChild
}
docCount, err := oaDocBase(tid).Filter("category_id", id).Count()
docCount, err := oaDocBase(a.Tid).Filter("category_id", id).Count()
if err != nil {
return err
}
@@ -457,7 +639,7 @@ func OaDocCategoryDelete(tid int, id uint64) error {
}
now := time.Now()
_, err = oaDocCategoryBase(tid).Filter("id", id).Update(orm.Params{
_, err = oaDocCategoryBaseAny(a.Tid).Filter("id", id).Update(orm.Params{
"IsDeleted": 1,
"DeleteTime": now,
"UpdateTime": now,
@@ -465,14 +647,37 @@ func OaDocCategoryDelete(tid int, id uint64) error {
return err
}
func oaDocCategoryExists(tid int, id uint64) (bool, error) {
n, err := oaDocCategoryBase(tid).Filter("id", id).Count()
// 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 租户内未删除文档的基础查询集
@@ -483,13 +688,17 @@ func oaDocBase(tid int) orm.QuerySeter {
}
// OaDocListParams 文档列表查询条件。
// Status/DocType/Star 使用 -1 表示"全部";CategoryID > 0 时包含其全部子孙分类。
// Actor 为访问者上下文,用于租户内用户级可见性过滤(私密文档只对创建者/被共享者可见)。
// Scope 限定空间:"shared"=共享文档(租户公开),"personal"=私密文档(绑定用户级别),"all"=跨两个空间。
// Status/DocType/Star 使用 -1 表示"全部";CategoryID > 0 时包含其全部子孙分类(须与 Scope 同空间)。
// Mine 为 true 时只看本人创建的文档。Actor 用于租户内用户级可见性过滤。
type OaDocListParams struct {
Actor OaDocActor
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
@@ -498,11 +707,18 @@ type OaDocListParams struct {
PageSize int
}
// OaDocList 分页查询文档(自动应用可见性过滤)。
// 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).
@@ -513,14 +729,17 @@ func OaDocList(a OaDocActor, p OaDocListParams) ([]models.OaDoc, int64, error) {
qs = qs.SetCond(qs.GetCond().AndCond(cond))
}
if p.Uncategorized {
qs = qs.Filter("category_id", 0)
qs = oaDocApplyUncategorized(qs, a, scope)
} else if p.CategoryID > 0 {
ids, err := oaDocCategoryIDsWithChildren(a.Tid, p.CategoryID)
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 {
@@ -559,6 +778,13 @@ func OaDocList(a OaDocActor, p OaDocListParams) ([]models.OaDoc, int64, error) {
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
}
@@ -603,20 +829,26 @@ func OaDocDownload(a OaDocActor, id uint64) (*models.OaDoc, error) {
type OaDocSaveParams struct {
SetCategory bool
CategoryID uint64
Title string
FileID uint64
FileURL string
FileName string
Ext string
Size uint64
Tags string
Summary string
Status int8
Version int
OwnerID uint64
OwnerName string
IsStar int8
Visibility int8 // 0-租户公开 1-私密
// 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 新增文档。
@@ -632,12 +864,12 @@ func OaDocCreate(a OaDocActor, operatorName string, p OaDocSaveParams) (*models.
if title == "" {
return nil, ErrDocTitleEmpty
}
// 分类须与文档可见性同空间:共享(visibility=0)→共享空间;私密(visibility=1)→私密空间(绑定用户级别)。
if p.CategoryID > 0 {
ok, err := oaDocCategoryExists(tid, p.CategoryID)
if err != nil {
scope := oaDocScopeOfVisibility(p.Visibility)
if _, ok, err := oaDocCategoryUsable(a, scope, p.CategoryID); err != nil {
return nil, err
}
if !ok {
} else if !ok {
return nil, ErrDocCategoryInvalid
}
}
@@ -666,6 +898,7 @@ func OaDocCreate(a OaDocActor, operatorName string, p OaDocSaveParams) (*models.
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,
@@ -703,23 +936,29 @@ func OaDocUpdate(a OaDocActor, id uint64, p OaDocSaveParams) (*models.OaDoc, err
}
fields := []string{"UpdateTime"}
if v := visibilityOf(p.Visibility); v != item.Visibility {
item.Visibility = v
fields = append(fields, "Visibility")
}
if p.SetCategory && p.CategoryID != item.CategoryID {
// 分类须与文档可见性同空间:以"更新后"的可见性推导范围
if p.CategoryID > 0 {
ok, err := oaDocCategoryExists(a.Tid, p.CategoryID)
if err != nil {
return nil, err
v := item.Visibility
if p.SetVisibility {
v = p.Visibility
}
if !ok {
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")
@@ -775,6 +1014,11 @@ func OaDocUpdate(a OaDocActor, id uint64, p OaDocSaveParams) (*models.OaDoc, err
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
@@ -840,13 +1084,16 @@ func OaDocMove(a OaDocActor, ids []uint64, categoryID uint64) (int64, error) {
if len(ids) == 0 {
return 0, ErrDocIDEmpty
}
// 移动仅改变文档归属分类;分类须与每篇文档的可见性同空间(共享/私密)
if categoryID > 0 {
ok, err := oaDocCategoryExists(a.Tid, categoryID)
if err != nil {
return 0, err
}
if !ok {
return 0, ErrDocCategoryInvalid
var 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
}
}
}
@@ -865,10 +1112,11 @@ func OaDocMove(a OaDocActor, ids []uint64, categoryID uint64) (int64, error) {
}
now := time.Now()
return oaDocBase(a.Tid).Filter("id__in", allowed).Update(orm.Params{
params := orm.Params{
"CategoryID": categoryID,
"UpdateTime": now,
})
}
return oaDocBase(a.Tid).Filter("id__in", allowed).Update(params)
}
// OaDocToggleStar 收藏 / 取消收藏切换,返回切换后的状态。
@@ -892,16 +1140,20 @@ func OaDocToggleStar(a OaDocActor, id uint64) (int8, error) {
// OaDocStatsResult 文档库概览统计
type OaDocStatsResult struct {
Total int64 `json:"total"`
Published int64 `json:"published"`
Draft int64 `json:"draft"`
Archived int64 `json:"archived"`
Starred int64 `json:"starred"`
Uncategorized int64 `json:"uncategorized"`
LinkCount int64 `json:"link_count"`
TypeCounts []OaDocTypeCount `json:"type_counts"`
RecentDocs []models.OaDoc `json:"recent_docs"`
HotDocs []models.OaDoc `json:"hot_docs"`
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 按文档类型统计
@@ -910,26 +1162,71 @@ type OaDocTypeCount struct {
Count int64 `json:"count"`
}
// OaDocStats 文档库概览:总数、状态分布、类型分布、最近更新与热门文档(均受可见性约束)。
func OaDocStats(a OaDocActor) (*OaDocStatsResult, error) {
// OaDocStats 文档库概览:总数、状态分布、类型分布、最近更新与热门文档。
// scope 限定统计口径("shared" 共享文档空间 / "personal" 私密文档空间 / "all" 跨空间);可见性约束同样生效。
func OaDocStats(a OaDocActor, scope string) (*OaDocStatsResult, error) {
models.EnsureOaDocumentTables()
base := oaDocApplyVisible(oaDocBase(a.Tid), a)
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, _ := base.Filter("category_id", 0).Count()
linkCount, _ := models.Orm.QueryTable(new(models.OaDocLink)).Filter("tid", a.Tid).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 GROUP BY doc_type ORDER BY cnt DESC",
a.Tid).QueryRows(&rows)
"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})
@@ -946,6 +1243,17 @@ func OaDocStats(a OaDocActor) (*OaDocStatsResult, error) {
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,
@@ -954,6 +1262,9 @@ func OaDocStats(a OaDocActor) (*OaDocStatsResult, error) {
Starred: starred,
Uncategorized: uncategorized,
LinkCount: linkCount,
PublicCount: publicCount,
PrivateCount: privateCount,
MineCount: mineCount,
TypeCounts: typeCounts,
RecentDocs: recent,
HotDocs: hot,
@@ -1371,13 +1682,13 @@ type OaDocRelationCount struct {
// 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"`
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"`
TopNodes []OaDocGraphNode `json:"top_nodes"`
}
// OaDocGraphResult 图谱数据
@@ -1402,7 +1713,8 @@ func OaDocGraph(a OaDocActor, p OaDocGraphParams) (*OaDocGraphResult, error) {
}
docs, _, err := OaDocList(a, OaDocListParams{
Actor: a,
Actor: a,
Scope: models.DocScopeAll,
Keyword: p.Keyword,
CategoryID: p.CategoryID,
Status: -1,
@@ -1476,18 +1788,31 @@ func OaDocGraph(a OaDocActor, p OaDocGraphParams) (*OaDocGraphResult, error) {
// 归属分类连线 + 分类中心节点
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 := oaDocCategoryBase(a.Tid).OrderBy("sort", "id").All(&cats); err != nil && err != orm.ErrNoRows {
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))
used := make(map[uint64]bool, 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
}
used[d.CategoryID] = true
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),
@@ -1498,14 +1823,36 @@ func OaDocGraph(a OaDocActor, p OaDocGraphParams) (*OaDocGraphResult, error) {
degree[fmt.Sprintf("d%d", d.ID)]++
degree[fmt.Sprintf("c%d", d.CategoryID)]++
}
// 分类 → 父分类 层级连线(补全分类树的上下级结构,避免顶级/上级分类缺失)
for _, c := range cats {
if !used[c.ID] {
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: 1,
Category: catKind,
CategoryID: c.ID,
})
}