package models import ( "strings" "sync" "time" "github.com/beego/beego/v2/client/orm" ) // OA 文档管理(文档库 / 文档图谱)数据模型。 // // 三张表按租户 tid 隔离: // - yz_backend_oa_doc_category 文档分类(树形,parent_id=0 为一级) // - yz_backend_oa_doc 文档主体(附件信息冗余存储,避免每次联查文件表) // - 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"` // 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"` } func (m *OaDocCategory) TableName() string { return "yz_backend_oa_doc_category" } // OaDoc OA文档: yz_backend_oa_doc // 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"` // 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"` } func (m *OaDoc) TableName() string { return "yz_backend_oa_doc" } // OaDocLink OA文档关联: yz_backend_oa_doc_link // relation: reference-引用 related-相关 version-版本迭代 belong-从属 type OaDocLink struct { ID uint64 `orm:"column(id);pk;auto" json:"id"` Tid int `orm:"column(tid)" json:"tid"` SourceID uint64 `orm:"column(source_id);default(0)" json:"source_id"` TargetID uint64 `orm:"column(target_id);default(0)" json:"target_id"` Relation string `orm:"column(relation);size(32);default(related)" json:"relation"` Remark string `orm:"column(remark);size(255);default()" json:"remark"` CreatorID uint64 `orm:"column(creator_id);default(0)" json:"creator_id"` CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` } func (m *OaDocLink) TableName() string { return "yz_backend_oa_doc_link" } // 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,含其全部子部门) type OaDocShare struct { ID uint64 `orm:"column(id);pk;auto" json:"id"` Tid int `orm:"column(tid)" json:"tid"` DocID uint64 `orm:"column(doc_id);default(0)" json:"doc_id"` ShareType int8 `orm:"column(share_type);default(0)" json:"share_type"` TargetID uint64 `orm:"column(target_id);default(0)" json:"target_id"` CreatorID uint64 `orm:"column(creator_id);default(0)" json:"creator_id"` CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` } 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 ) // 文档状态常量(status) const ( DocStatusDraft int8 = 0 DocStatusPublish int8 = 1 DocStatusArchive int8 = 2 ) // 文档关联关系常量(relation) const ( DocRelationReference = "reference" DocRelationRelated = "related" DocRelationVersion = "version" DocRelationBelong = "belong" ) // 文档可见性常量(visibility) const ( DocVisibilityPublic int8 = 0 // 租户公开 DocVisibilityPrivate int8 = 1 // 私密 ) // 文档共享目标类型常量(share_type) const ( DocShareTypeUser int8 = 0 // 共享给指定用户(target_id = yz_system_tenant_user.uid) DocShareTypeOrg int8 = 1 // 共享给部门(target_id = yz_backend_organization.id,含子部门) ) // extDocTypeMap 扩展名 -> 文档类型 var extDocTypeMap = map[string]int8{ "doc": DocTypeDoc, "docx": DocTypeDoc, "txt": DocTypeDoc, "md": DocTypeDoc, "rtf": DocTypeDoc, "wps": DocTypeDoc, "odt": DocTypeDoc, "xls": DocTypeSheet, "xlsx": DocTypeSheet, "csv": DocTypeSheet, "ods": DocTypeSheet, "ppt": DocTypeSlide, "pptx": DocTypeSlide, "odp": DocTypeSlide, "pdf": DocTypePDF, "jpg": DocTypeImage, "jpeg": DocTypeImage, "png": DocTypeImage, "gif": DocTypeImage, "bmp": DocTypeImage, "webp": DocTypeImage, "svg": DocTypeImage, "zip": DocTypeZip, "rar": DocTypeZip, "7z": DocTypeZip, "tar": DocTypeZip, "gz": DocTypeZip, "mp4": DocTypeVideo, "webm": DocTypeVideo, "mov": DocTypeVideo, "avi": DocTypeVideo, "mp3": DocTypeAudio, "wav": DocTypeAudio, "ogg": DocTypeAudio, } // DocTypeByExt 根据扩展名推断文档类型,未知返回"其他"。 func DocTypeByExt(ext string) int8 { if t, ok := extDocTypeMap[strings.ToLower(strings.TrimPrefix(strings.TrimSpace(ext), "."))]; ok { return t } return DocTypeOther } var oaDocumentTableOnce sync.Once // EnsureOaDocumentTables 首次访问文档接口时自动建表(若不存在)。 // 与通知公告/日程保持一致的运行期自愈策略,避免新功能上线还需手工执行建表脚本。 func EnsureOaDocumentTables() error { if Orm == nil { return nil } var err error oaDocumentTableOnce.Do(func() { _, err = Orm.Raw(` 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_scope_user (tid, scope, user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档分类表'`).Exec() if err != nil { return } _, err = Orm.Raw(` CREATE TABLE IF NOT EXISTS yz_backend_oa_doc ( id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', tid int NOT NULL DEFAULT 0 COMMENT '租户ID', category_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '所属分类ID,0为未分类', title varchar(255) NOT NULL DEFAULT '' COMMENT '文档标题', file_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '关联附件ID(yz_system_files)', file_url varchar(512) NOT NULL DEFAULT '' COMMENT '附件访问地址', file_name varchar(255) NOT NULL DEFAULT '' COMMENT '原始文件名', ext varchar(16) NOT NULL DEFAULT '' COMMENT '扩展名', size bigint unsigned NOT NULL DEFAULT 0 COMMENT '文件大小(字节)', 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-是', owner_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '负责人ID', owner_name varchar(100) NOT NULL DEFAULT '' COMMENT '负责人姓名', view_count int NOT NULL DEFAULT 0 COMMENT '查看次数', download_count int NOT NULL DEFAULT 0 COMMENT '下载次数', creator_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '创建人ID', creator_name varchar(100) NOT NULL DEFAULT '' COMMENT '创建人姓名', visibility tinyint NOT NULL DEFAULT 0 COMMENT '可见性 0-租户公开 1-私密(仅创建者与被共享者)', 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_cate (tid, category_id, is_deleted), KEY idx_tid_status (tid, status, is_deleted), KEY idx_tid_title (tid, title) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档表'`).Exec() if err != nil { return } _, err = Orm.Raw(` CREATE TABLE IF NOT EXISTS yz_backend_oa_doc_link ( id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', tid int NOT NULL DEFAULT 0 COMMENT '租户ID', source_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '源文档ID', target_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '目标文档ID', relation varchar(32) NOT NULL DEFAULT 'related' COMMENT '关系 reference-引用 related-相关 version-版本 belong-从属', remark varchar(255) NOT NULL DEFAULT '' COMMENT '关系说明', creator_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '创建人ID', create_time datetime DEFAULT NULL COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_doc_pair (tid, source_id, target_id, relation), KEY idx_tid_source (tid, source_id), KEY idx_tid_target (tid, target_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA文档关联表'`).Exec() if err != nil { return } // 历史库补齐 visibility 列(已存在时 MySQL 报 duplicate column,忽略) _, _ = Orm.Raw(` ALTER TABLE yz_backend_oa_doc 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', tid int NOT NULL DEFAULT 0 COMMENT '租户ID', doc_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '文档ID', share_type tinyint NOT NULL DEFAULT 0 COMMENT '共享类型 0-用户(target_id=uid) 1-部门(target_id=org_id,含子部门)', target_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '共享目标ID', creator_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '授权人ID', create_time datetime DEFAULT NULL COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_doc_target (tid, doc_id, share_type, target_id), 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 }