优化文档相关

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
+73 -35
View File
@@ -69,6 +69,9 @@ func (c *BackendOaDocumentController) oaDocFail(err error) {
status, code, msg = 404, 404, err.Error()
case services.ErrDocLinkDuplicate:
status, code, msg = 409, 409, err.Error()
case services.ErrDocCategorySystem, services.ErrDocCategorySystemLocked,
services.ErrDocCategoryNameKept:
status, code, msg = 403, 403, err.Error()
default:
msg = err.Error()
}
@@ -95,14 +98,15 @@ func (c *BackendOaDocumentController) oaDocActor(claims *jwtutil.Claims) service
// ---------------- 分类 ----------------
// Categories GET /backend/oa/document/categories 分类树(含文档数量)
// Categories GET /backend/oa/document/categories?scope=shared|personal 文档分类树(含各分类文档数量)
func (c *BackendOaDocumentController) Categories() {
claims, err := c.oaDocClaims()
if err != nil {
c.oaDocFail(err)
return
}
tree, err := services.OaDocCategoryTree(c.oaDocActor(claims))
scope := strings.TrimSpace(c.GetString("scope"))
tree, err := services.OaDocCategoryTree(c.oaDocActor(claims), scope)
if err != nil {
c.oaDocFail(err)
return
@@ -118,6 +122,7 @@ func (c *BackendOaDocumentController) CreateCategory() {
return
}
var body struct {
Scope string `json:"scope"`
ParentID uint64 `json:"parent_id"`
Name string `json:"name"`
Remark string `json:"remark"`
@@ -126,7 +131,8 @@ func (c *BackendOaDocumentController) CreateCategory() {
if !c.oaDocParseBody(&body) {
return
}
item, err := services.OaDocCategoryCreate(claims.TenantId, body.ParentID, body.Name, body.Remark, body.Sort)
item, err := services.OaDocCategoryCreate(
c.oaDocActor(claims), body.Scope, body.ParentID, body.Name, body.Remark, body.Sort)
if err != nil {
c.oaDocFail(err)
return
@@ -169,7 +175,7 @@ func (c *BackendOaDocumentController) UpdateCategory() {
if body.Sort != nil {
sort = *body.Sort
}
item, err := services.OaDocCategoryUpdate(claims.TenantId, id, name, remark, sort, parentID)
item, err := services.OaDocCategoryUpdate(c.oaDocActor(claims), id, name, remark, sort, parentID)
if err != nil {
c.oaDocFail(err)
return
@@ -188,7 +194,7 @@ func (c *BackendOaDocumentController) DeleteCategory() {
if !ok {
return
}
if err := services.OaDocCategoryDelete(claims.TenantId, id); err != nil {
if err := services.OaDocCategoryDelete(c.oaDocActor(claims), id); err != nil {
c.oaDocFail(err)
return
}
@@ -211,13 +217,19 @@ func (c *BackendOaDocumentController) List() {
docType, _ := c.GetInt("doc_type", -1)
star, _ := c.GetInt("star", -1)
categoryID, _ := c.GetUint64("category_id")
visibility, _ := c.GetInt("visibility", -1)
mine := strings.TrimSpace(c.GetString("mine")) == "1"
scope := strings.TrimSpace(c.GetString("scope"))
actor := c.oaDocActor(claims)
list, total, err := services.OaDocList(actor, services.OaDocListParams{
Actor: actor,
Scope: scope,
Keyword: strings.TrimSpace(c.GetString("keyword")),
CategoryID: categoryID,
Uncategorized: strings.TrimSpace(c.GetString("uncategorized")) == "1",
Visibility: visibility,
Mine: mine,
Status: status,
DocType: docType,
Tag: strings.TrimSpace(c.GetString("tag")),
@@ -232,14 +244,15 @@ func (c *BackendOaDocumentController) List() {
c.oaDocOk(map[string]interface{}{"list": list, "total": total})
}
// Stats GET /backend/oa/document/stats 文档库概览
// Stats GET /backend/oa/document/stats?scope=shared|personal|all 文档库概览
func (c *BackendOaDocumentController) Stats() {
claims, err := c.oaDocClaims()
if err != nil {
c.oaDocFail(err)
return
}
stats, err := services.OaDocStats(c.oaDocActor(claims))
scope := strings.TrimSpace(c.GetString("scope"))
stats, err := services.OaDocStats(c.oaDocActor(claims), scope)
if err != nil {
c.oaDocFail(err)
return
@@ -266,6 +279,25 @@ func (c *BackendOaDocumentController) Detail() {
c.oaDocOk(item)
}
// Content GET /backend/oa/document/content/:id 读取在线文档的编辑源(不累加查看次数)
func (c *BackendOaDocumentController) Content() {
claims, err := c.oaDocClaims()
if err != nil {
c.oaDocFail(err)
return
}
id, ok := c.oaDocID()
if !ok {
return
}
item, err := services.OaDocGet(c.oaDocActor(claims), id)
if err != nil {
c.oaDocFail(err)
return
}
c.oaDocOk(map[string]interface{}{"content": item.Content})
}
// Create POST /backend/oa/document/create
func (c *BackendOaDocumentController) Create() {
claims, err := c.oaDocClaims()
@@ -406,10 +438,10 @@ func (c *BackendOaDocumentController) Download() {
return
}
c.oaDocOk(map[string]interface{}{
"id": item.ID,
"title": item.Title,
"file_url": item.FileURL,
"file_name": item.FileName,
"id": item.ID,
"title": item.Title,
"file_url": item.FileURL,
"file_name": item.FileName,
"download_count": item.DownCount,
})
}
@@ -605,38 +637,44 @@ func (c *BackendOaDocumentController) oaDocParseSaveBody() (services.OaDocSavePa
var body struct {
CategoryID *uint64 `json:"category_id"`
Title string `json:"title"`
FileID uint64 `json:"file_id"`
FileURL string `json:"file_url"`
FileName string `json:"file_name"`
Ext string `json:"ext"`
Size uint64 `json:"size"`
Tags string `json:"tags"`
Summary string `json:"summary"`
Status *int8 `json:"status"`
Version int `json:"version"`
OwnerID uint64 `json:"owner_id"`
OwnerName string `json:"owner_name"`
IsStar *int8 `json:"is_star"`
Visibility *int8 `json:"visibility"`
FileID uint64 `json:"file_id"`
FileURL string `json:"file_url"`
FileName string `json:"file_name"`
Ext string `json:"ext"`
Size uint64 `json:"size"`
Tags string `json:"tags"`
Summary string `json:"summary"`
Content *string `json:"content"`
Status *int8 `json:"status"`
Version int `json:"version"`
OwnerID uint64 `json:"owner_id"`
OwnerName string `json:"owner_name"`
IsStar *int8 `json:"is_star"`
Visibility *int8 `json:"visibility"`
}
if !c.oaDocParseBody(&body) {
return services.OaDocSaveParams{}, false
}
params := services.OaDocSaveParams{
Title: body.Title,
FileID: body.FileID,
FileURL: body.FileURL,
FileName: body.FileName,
Ext: body.Ext,
Size: body.Size,
Tags: body.Tags,
Summary: body.Summary,
Version: body.Version,
OwnerID: body.OwnerID,
OwnerName: body.OwnerName,
Title: body.Title,
FileID: body.FileID,
FileURL: body.FileURL,
FileName: body.FileName,
Ext: body.Ext,
Size: body.Size,
Tags: body.Tags,
Summary: body.Summary,
Version: body.Version,
OwnerID: body.OwnerID,
OwnerName: body.OwnerName,
}
if body.Content != nil {
params.SetContent = true
params.Content = *body.Content
}
if body.Visibility != nil {
params.Visibility = *body.Visibility
params.SetVisibility = true
}
if body.CategoryID != nil {
params.SetCategory = true
+23 -1
View File
@@ -18,18 +18,40 @@
CREATE TABLE IF NOT EXISTS yz_backend_oa_doc_category (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tid int NOT NULL DEFAULT 0 COMMENT '租户ID',
user_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '分类归属用户:共享空间共享=0(租户级);私密空间=创建者的 yz_system_tenant_user.uid',
parent_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '父级ID,0为一级分类',
name varchar(128) NOT NULL DEFAULT '' COMMENT '分类名称',
sort int NOT NULL DEFAULT 0 COMMENT '排序,越小越靠前',
remark varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
is_system tinyint NOT NULL DEFAULT 0 COMMENT '是否系统内置分类 0-否(可删除) 1-是(不可删除)',
scope varchar(16) NOT NULL DEFAULT 'shared' COMMENT '所属空间 shared-共享文档空间 personal-私密文档空间(绑定用户级别)',
is_deleted tinyint NOT NULL DEFAULT 0 COMMENT '是否删除 0-否 1-是',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '更新时间',
delete_time datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (id),
KEY idx_tid_parent (tid, parent_id)
KEY idx_tid_parent (tid, parent_id),
KEY idx_tid_scope_user (tid, scope, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档分类表';
-- 1.1 历史库补齐列(特性上线前已建表时执行,已存在则报错可忽略)
-- ALTER TABLE yz_backend_oa_doc_category
-- ADD COLUMN is_system tinyint NOT NULL DEFAULT 0 COMMENT '是否系统内置分类 0-否(可删除) 1-是(不可删除)';
-- ALTER TABLE yz_backend_oa_doc_category
-- ADD COLUMN scope varchar(16) NOT NULL DEFAULT 'shared' COMMENT '所属空间 shared-共享 personal-私密';
-- ALTER TABLE yz_backend_oa_doc_category
-- ADD COLUMN user_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '分类归属用户(私密空间=租户用户 uid)';
-- 1.2 分类归属规则(双空间模型,2026-09 起):
-- 文档库按「共享文档 / 私密文档」两个空间组织,每个空间各自一棵分类树:
-- - 共享空间:scope=shared、user_id=0(租户级,全租户共用一套,任何人可建/删,内置分类除外);
-- - 私密空间:scope=personal、user_id=当前用户的 yz_system_tenant_user.uid(绑定到用户级别,各用户独立一套)。
-- 文档可见性(OaDoc.visibility)与所在空间一一对应:共享空间→0(租户公开),私密空间→1(私密,仅创建者与被共享者可见)。
-- 系统内置分类「项目文档」(scope=shared, user_id=0, is_system=1) 属于共享空间,按租户自动补建
-- (models.EnsureOaDocDefaultCategories),一般无需手工插入。
-- 历史脏数据(scope 不在 shared/personal)在 EnsureOaDocumentTables 中自动归为共享空间(user_id=0, scope=shared)。
-- 说明:「未分类」是虚拟分类,文档 category_id = 0 即表示未分类,两个空间各自独立计算,不落库、不可删除。
-- 2. 文档表
-- doc_type: 0-其他 1-文档 2-表格 3-演示 4-PDF 5-图片 6-压缩包 7-视频 8-音频
-- status : 0-草稿 1-已发布 2-已归档
+204 -44
View File
@@ -4,6 +4,8 @@ import (
"strings"
"sync"
"time"
"github.com/beego/beego/v2/client/orm"
)
// OA 文档管理(文档库 / 文档图谱)数据模型。
@@ -14,14 +16,28 @@ import (
// - yz_backend_oa_doc_link 文档关联关系(引用/相关/版本/从属),文档图谱的连线来源
// OaDocCategory OA文档分类: yz_backend_oa_doc_category
//
// 双空间模型:文档库按「共享文档 / 私密文档」两个空间组织,每个空间各自有一棵分类树。
// - 共享文档空间(Scope="shared"、UserID=0):租户级,租户内所有成员共用同一套文件夹结构;
// - 私密文档空间(Scope="personal"、UserID=登录用户的 yz_system_tenant_user.uid):
// 绑定到租户的用户级别,每位用户拥有自己独立的一套文件夹,互不干扰;
// - 文档的可见性(OaDoc.Visibility)与所在空间一一对应:共享空间→租户公开(0),私密空间→私密(1);
// - 系统内置的「项目文档」属于共享空间(Scope="shared"、UserID=0);
// - 「未分类」是虚拟分类(文档 category_id=0),不落库、不可删除,两个空间各自独立计算。
type OaDocCategory struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
ParentID uint64 `orm:"column(parent_id);default(0)" json:"parent_id"`
Name string `orm:"column(name);size(128)" json:"name"`
Sort int `orm:"column(sort);default(0)" json:"sort"`
Remark string `orm:"column(remark);size(255);default()" json:"remark"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
// UserID 分类归属用户:共享空间恒为 0(租户级);私密空间为创建者的 yz_system_tenant_user.uid。
UserID uint64 `orm:"column(user_id);default(0)" json:"user_id"`
ParentID uint64 `orm:"column(parent_id);default(0)" json:"parent_id"`
Name string `orm:"column(name);size(128)" json:"name"`
Sort int `orm:"column(sort);default(0)" json:"sort"`
Remark string `orm:"column(remark);size(255);default()" json:"remark"`
IsSystem int8 `orm:"column(is_system);default(0)" json:"is_system"`
// Scope 所属空间:shared=共享文档空间,personal=私密文档空间(绑定到用户级别)。
Scope string `orm:"column(scope);size(16);default(shared)" json:"scope"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
@@ -35,33 +51,38 @@ func (m *OaDocCategory) TableName() string {
// status: 0-草稿 1-已发布 2-已归档
// doc_type: 0-其他 1-文档 2-表格 3-演示 4-PDF 5-图片 6-压缩包 7-视频 8-音频
type OaDoc struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
CategoryID uint64 `orm:"column(category_id);default(0)" json:"category_id"`
Title string `orm:"column(title);size(255)" json:"title"`
FileID uint64 `orm:"column(file_id);default(0)" json:"file_id"`
FileURL string `orm:"column(file_url);size(512);default()" json:"file_url"`
FileName string `orm:"column(file_name);size(255);default()" json:"file_name"`
Ext string `orm:"column(ext);size(16);default()" json:"ext"`
Size uint64 `orm:"column(size);default(0)" json:"size"`
DocType int8 `orm:"column(doc_type);default(0)" json:"doc_type"`
Tags string `orm:"column(tags);size(255);default()" json:"tags"`
Summary string `orm:"column(summary);size(1000);default()" json:"summary"`
Status int8 `orm:"column(status);default(0)" json:"status"`
Version int `orm:"column(version);default(1)" json:"version"`
IsStar int8 `orm:"column(is_star);default(0)" json:"is_star"`
OwnerID uint64 `orm:"column(owner_id);default(0)" json:"owner_id"`
OwnerName string `orm:"column(owner_name);size(100);default()" json:"owner_name"`
ViewCount int `orm:"column(view_count);default(0)" json:"view_count"`
DownCount int `orm:"column(download_count);default(0)" json:"download_count"`
CreatorID uint64 `orm:"column(creator_id);default(0)" json:"creator_id"`
CreatorName string `orm:"column(creator_name);size(100);default()" json:"creator_name"`
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
CategoryID uint64 `orm:"column(category_id);default(0)" json:"category_id"`
Title string `orm:"column(title);size(255)" json:"title"`
FileID uint64 `orm:"column(file_id);default(0)" json:"file_id"`
FileURL string `orm:"column(file_url);size(512);default()" json:"file_url"`
FileName string `orm:"column(file_name);size(255);default()" json:"file_name"`
Ext string `orm:"column(ext);size(16);default()" json:"ext"`
Size uint64 `orm:"column(size);default(0)" json:"size"`
DocType int8 `orm:"column(doc_type);default(0)" json:"doc_type"`
Tags string `orm:"column(tags);size(255);default()" json:"tags"`
Summary string `orm:"column(summary);size(1000);default()" json:"summary"`
// Content 在线制作文档的编辑源(富文本 HTML),仅用于再次编辑;上传文件或普通文档为空。
// 注意:文档对外的写入/下载格式为 docx(由该源导出),此字段只是编辑器的中间态。
Content string `orm:"column(content);type(longtext);null" json:"content,omitempty"`
// IsOnline 是否在线制作(非持久化字段,列表接口按 Content 是否为空计算)
IsOnline int8 `orm:"-" json:"is_online"`
Status int8 `orm:"column(status);default(0)" json:"status"`
Version int `orm:"column(version);default(1)" json:"version"`
IsStar int8 `orm:"column(is_star);default(0)" json:"is_star"`
OwnerID uint64 `orm:"column(owner_id);default(0)" json:"owner_id"`
OwnerName string `orm:"column(owner_name);size(100);default()" json:"owner_name"`
ViewCount int `orm:"column(view_count);default(0)" json:"view_count"`
DownCount int `orm:"column(download_count);default(0)" json:"download_count"`
CreatorID uint64 `orm:"column(creator_id);default(0)" json:"creator_id"`
CreatorName string `orm:"column(creator_name);size(100);default()" json:"creator_name"`
// Visibility 可见性:0-租户公开(默认,租户内所有后台用户可见)1-私密(仅创建者与被共享者可见)
Visibility int8 `orm:"column(visibility);default(0)" json:"visibility"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
Visibility int8 `orm:"column(visibility);default(0)" json:"visibility"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *OaDoc) TableName() string {
@@ -88,7 +109,8 @@ func (m *OaDocLink) TableName() string {
// OaDocShare OA文档共享: yz_backend_oa_doc_share
// 私密文档(visibility=1)在"创建者可见"之外,额外授权给指定用户或部门。
// share_type: 0-用户(target_id 为 yz_system_tenant_user.uid)
// 1-部门(target_id 为 yz_backend_organization.id,含其全部子部门)
//
// 1-部门(target_id 为 yz_backend_organization.id,含其全部子部门)
type OaDocShare struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
@@ -103,17 +125,39 @@ func (m *OaDocShare) TableName() string {
return "yz_backend_oa_doc_share"
}
// 文档分类内置标记常量(is_system)
const (
DocCategoryNormal int8 = 0 // 普通分类,租户可自行删除
DocCategorySystem int8 = 1 // 系统内置分类,任何租户都不可删除
)
// 文档范围(scope):文档库按「共享文档 / 私密文档」两个空间组织,分类同样按此隔离。
// 共享空间(shared):租户级,所有人共用;私密空间(personal):绑定到用户级别,每人独立一套。
// 文档侧复用 visibility 字段:personal 对应私密(1),shared 对应租户公开(0)。
const (
DocScopeAll = "all"
DocScopePersonal = "personal"
DocScopeShared = "shared"
)
// DocCategoryNameProject 系统内置分类名称:项目文档,只属于共享文档范围。
// 每个租户首次访问文档接口时自动补建自己那一份(按 tid 隔离),租户之间互不可见。
const DocCategoryNameProject = "项目文档"
// docCategorySystemRemark 内置分类的默认备注
const docCategorySystemRemark = "系统内置分类,不可删除"
// 文档类型常量(doc_type)
const (
DocTypeOther int8 = 0
DocTypeDoc int8 = 1 // doc / docx / txt / md / rtf
DocTypeSheet int8 = 2 // xls / xlsx / csv
DocTypeSlide int8 = 3 // ppt / pptx
DocTypePDF int8 = 4 // pdf
DocTypeImage int8 = 5 // jpg / png / gif / bmp / webp
DocTypeZip int8 = 6 // zip / rar / 7z / tar / gz
DocTypeVideo int8 = 7
DocTypeAudio int8 = 8
DocTypeOther int8 = 0
DocTypeDoc int8 = 1 // doc / docx / txt / md / rtf
DocTypeSheet int8 = 2 // xls / xlsx / csv
DocTypeSlide int8 = 3 // ppt / pptx
DocTypePDF int8 = 4 // pdf
DocTypeImage int8 = 5 // jpg / png / gif / bmp / webp
DocTypeZip int8 = 6 // zip / rar / 7z / tar / gz
DocTypeVideo int8 = 7
DocTypeAudio int8 = 8
)
// 文档状态常量(status)
@@ -179,16 +223,20 @@ func EnsureOaDocumentTables() error {
CREATE TABLE IF NOT EXISTS yz_backend_oa_doc_category (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tid int NOT NULL DEFAULT 0 COMMENT '租户ID',
user_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '归属用户ID 0-租户级(共享分类) >0-该用户的个人分类',
parent_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '父级ID,0为一级分类',
name varchar(128) NOT NULL DEFAULT '' COMMENT '分类名称',
sort int NOT NULL DEFAULT 0 COMMENT '排序,越小越靠前',
remark varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
is_system tinyint NOT NULL DEFAULT 0 COMMENT '是否系统内置分类 0-否(可删除) 1-是(不可删除)',
scope varchar(16) NOT NULL DEFAULT 'shared' COMMENT '所属范围 personal-个人文档 shared-共享文档',
is_deleted tinyint NOT NULL DEFAULT 0 COMMENT '是否删除 0-否 1-是',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '更新时间',
delete_time datetime DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (id),
KEY idx_tid_parent (tid, parent_id)
KEY idx_tid_parent (tid, parent_id),
KEY idx_tid_scope_user (tid, scope, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档分类表'`).Exec()
if err != nil {
return
@@ -208,6 +256,7 @@ CREATE TABLE IF NOT EXISTS yz_backend_oa_doc (
doc_type tinyint NOT NULL DEFAULT 0 COMMENT '类型 0-其他 1-文档 2-表格 3-演示 4-PDF 5-图片 6-压缩包 7-视频 8-音频',
tags varchar(255) NOT NULL DEFAULT '' COMMENT '标签,逗号分隔',
summary varchar(1000) NOT NULL DEFAULT '' COMMENT '摘要/描述',
content longtext NULL COMMENT '在线制作文档的编辑源(富文本HTML),仅用于再次编辑',
status tinyint NOT NULL DEFAULT 0 COMMENT '状态 0-草稿 1-已发布 2-已归档',
version int NOT NULL DEFAULT 1 COMMENT '版本号',
is_star tinyint NOT NULL DEFAULT 0 COMMENT '是否收藏 0-否 1-是',
@@ -256,6 +305,30 @@ CREATE TABLE IF NOT EXISTS yz_backend_oa_doc_link (
ADD COLUMN visibility tinyint NOT NULL DEFAULT 0 COMMENT '可见性 0-租户公开 1-私密(仅创建者与被共享者)'
`).Exec()
// 历史库补齐在线文档编辑源列 content(在线制作功能上线前已建表)
_, _ = Orm.Raw(`
ALTER TABLE yz_backend_oa_doc
ADD COLUMN content longtext NULL COMMENT '在线制作文档的编辑源(富文本HTML),仅用于再次编辑'
`).Exec()
// 历史库补齐分类表的 is_system 列(内置分类特性上线前已建表)
_, _ = Orm.Raw(`
ALTER TABLE yz_backend_oa_doc_category
ADD COLUMN is_system tinyint NOT NULL DEFAULT 0 COMMENT '是否系统内置分类 0-否(可删除) 1-是(不可删除)'
`).Exec()
// 历史库补齐分类表的 scope 列:既有分类默认归入共享文档,保证升级后仍可见
_, _ = Orm.Raw(`
ALTER TABLE yz_backend_oa_doc_category
ADD COLUMN scope varchar(16) NOT NULL DEFAULT 'shared' COMMENT '所属范围 personal-个人文档 shared-共享文档'
`).Exec()
// 历史库补齐分类表的 user_id 列:既有分类默认归租户级(共享分类)
_, _ = Orm.Raw(`
ALTER TABLE yz_backend_oa_doc_category
ADD COLUMN user_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '归属用户ID 0-租户级(共享分类) >0-该用户的个人分类'
`).Exec()
_, err = Orm.Raw(`
CREATE TABLE IF NOT EXISTS yz_backend_oa_doc_share (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
@@ -270,6 +343,93 @@ CREATE TABLE IF NOT EXISTS yz_backend_oa_doc_share (
KEY idx_tid_doc (tid, doc_id),
KEY idx_target (tid, share_type, target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档共享表'`).Exec()
if err != nil {
return
}
// 双空间模型:兼容存量数据,将不在 (shared, personal) 范围内的分类统一归为共享空间
// (user_id 置 0、scope 置 shared),避免历史脏数据导致分类不可见。
// 注意:不再把 personal 并入 shared,私密空间需保留各自的 user_id 隔离。
_, _ = Orm.Raw(`
UPDATE yz_backend_oa_doc_category
SET user_id = 0, scope = 'shared'
WHERE scope NOT IN ('shared', 'personal') AND is_deleted = 0
`).Exec()
})
return err
}
// oaDocDefaultCategoryOnce 记录已完成内置分类补建的租户,避免每次请求重复查库。
// key 为 tid(int),value 为 struct{}{};补建失败时不写缓存,下次请求继续重试。
var oaDocDefaultCategoryOnce sync.Map
// EnsureOaDocDefaultCategories 保证指定租户的系统内置分类齐全(当前为「项目文档」)。
//
// 租户隔离:内置分类是真实数据行,每个租户各自持有一份(tid 不同),
// 首次访问文档接口时自动补建,租户之间互不可见、互不影响。
//
// 范围隔离:「项目文档」属于共享文档空间(scope=shared、user_id=0),租户内所有人共用。
// 私密文档空间不预置内置分类,由用户自行建立自己的文件夹。
//
// 「未分类」不落库:它是虚拟分类,文档 category_id = 0 即表示未分类,因此天然不可删除;
// 同时在 services 层把「未分类」「项目文档」列为保留名,禁止新建同名分类与之混淆。
//
// 任何失败均静默忽略,不阻断主流程(与 EnsureDefaultTenantRoles 保持一致)。
func EnsureOaDocDefaultCategories(tid int) {
if Orm == nil || tid <= 0 {
return
}
if _, done := oaDocDefaultCategoryOnce.Load(tid); done {
return
}
if err := ensureOaDocProjectCategory(tid); err == nil {
oaDocDefaultCategoryOnce.Store(tid, struct{}{})
}
}
// ensureOaDocProjectCategory 补建租户的「项目文档」内置分类:
// 1. 已有任意内置分类(is_system=1):说明租户已初始化过(可能被改名),直接返回;
// 2. 已有同名普通分类:升级为内置分类,避免出现两个「项目文档」;
// 3. 都没有:插入一条一级内置分类。
func ensureOaDocProjectCategory(tid int) error {
// 「项目文档」为统一租户分类树下的内置分类(scope=shared)
qs := Orm.QueryTable(new(OaDocCategory)).
Filter("tid", tid).
Filter("scope", DocScopeShared)
sysCount, err := qs.Filter("is_system", DocCategorySystem).Count()
if err != nil {
return err
}
if sysCount > 0 {
return nil
}
var existed []OaDocCategory
if _, err := qs.Filter("name", DocCategoryNameProject).
Filter("is_deleted", 0).
OrderBy("id").Limit(1).All(&existed); err != nil && err != orm.ErrNoRows {
return err
}
if len(existed) > 0 {
item := existed[0]
item.IsSystem = DocCategorySystem
if item.Remark == "" {
item.Remark = docCategorySystemRemark
}
_, err := Orm.Update(&item, "IsSystem", "Remark")
return err
}
_, err = Orm.Insert(&OaDocCategory{
Tid: tid,
ParentID: 0,
Name: DocCategoryNameProject,
Sort: 0,
Remark: docCategorySystemRemark,
IsSystem: DocCategorySystem,
Scope: DocScopeShared,
IsDeleted: 0,
})
return err
}
+1
View File
@@ -306,6 +306,7 @@ func RegisterAuthRoutes() {
beego.Router("/backend/oa/document/links", &controllers.BackendOaDocumentController{}, "get:LinkList;post:LinkCreate")
beego.Router("/backend/oa/document/links/:id", &controllers.BackendOaDocumentController{}, "delete:LinkDelete")
beego.Router("/backend/oa/document/detail/:id", &controllers.BackendOaDocumentController{}, "get:Detail")
beego.Router("/backend/oa/document/content/:id", &controllers.BackendOaDocumentController{}, "get:Content")
beego.Router("/backend/oa/document/create", &controllers.BackendOaDocumentController{}, "post:Create")
beego.Router("/backend/oa/document/update/:id", &controllers.BackendOaDocumentController{}, "post:Update")
beego.Router("/backend/oa/document/delete/:id", &controllers.BackendOaDocumentController{}, "delete:Delete")
+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,
})
}