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