264 lines
13 KiB
Python
264 lines
13 KiB
Python
"""文档库业务权限:租户隔离 + 上传人/明确分享人/超级管理员。
|
|
|
|
该模块采用用户确认的逐文件 ACL,部门数据范围不扩大私人资料访问权限。
|
|
目录只暴露可见文件的祖先路径,不将目录可见性当作文件授权。
|
|
"""
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
from uuid import uuid4
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import and_, exists, or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.config import settings
|
|
from app.core.tenant_context import get_tenant_no
|
|
from app.models.doclib import DoclibFile, DoclibFolder, DoclibShare
|
|
from app.models.models import SysDept, SysUser
|
|
|
|
|
|
class DoclibService:
|
|
def __init__(self, db: Session, user: dict):
|
|
self.db = db
|
|
self.user = user
|
|
self.uid = user.get('user_id')
|
|
if not self.uid:
|
|
raise HTTPException(401, '请先登录')
|
|
self.tenant = get_tenant_no(db, user)
|
|
self.is_admin = user.get('role') == 'super_admin'
|
|
|
|
def _base(self, model):
|
|
return self.db.query(model).filter(model.tenant_no == self.tenant, model.del_flag == 0)
|
|
|
|
def _public(self):
|
|
return dict(id=str(uuid4()), tenant_no=self.tenant, create_by=self.uid,
|
|
update_by=self.uid, create_dept_id=self.user.get('region_id') or '')
|
|
|
|
def _shared(self):
|
|
return exists().where(and_(DoclibShare.tenant_no == self.tenant,
|
|
DoclibShare.file_id == DoclibFile.id,
|
|
DoclibShare.user_id == self.uid, DoclibShare.del_flag == 0))
|
|
|
|
def _files(self, scope: Optional[str] = None):
|
|
query = self._base(DoclibFile)
|
|
if scope == 'mine':
|
|
return query.filter(DoclibFile.create_by == self.uid)
|
|
if scope == 'shared':
|
|
return query.filter(DoclibFile.create_by != self.uid, self._shared())
|
|
if scope == 'all':
|
|
if not self.is_admin:
|
|
raise HTTPException(403, '仅超级管理员可查看全部文档')
|
|
return query
|
|
if scope is not None:
|
|
raise HTTPException(400, '无效文档范围')
|
|
return query if self.is_admin else query.filter(or_(DoclibFile.create_by == self.uid, self._shared()))
|
|
|
|
def _file(self, file_id, *, manage=False, lock=False):
|
|
query = self._files().filter(DoclibFile.id == file_id)
|
|
row = (query.populate_existing().with_for_update() if lock else query).first()
|
|
if row is None:
|
|
raise HTTPException(404, '文档不存在或无权访问')
|
|
if manage and not (self.is_admin or row.create_by == self.uid):
|
|
raise HTTPException(403, '只有上传人和超级管理员可以管理文档')
|
|
return row
|
|
|
|
def _folder(self, folder_id, *, own=False, manage=False, lock=False):
|
|
query = self._base(DoclibFolder).filter(DoclibFolder.id == folder_id)
|
|
row = (query.populate_existing().with_for_update() if lock else query).first()
|
|
if row is None:
|
|
raise HTTPException(404, '文件夹不存在')
|
|
if (own and row.create_by != self.uid) or (manage and row.create_by != self.uid and not self.is_admin):
|
|
raise HTTPException(403, '无权管理此文件夹')
|
|
return row
|
|
|
|
def _lock_owner(self, owner_id):
|
|
# 所有目录写操作锁同一所有者,避免检查空目录与并发上传/建子目录之间的竞争。
|
|
row = self._base(SysUser).filter(SysUser.id == owner_id).with_for_update().first()
|
|
if row is None:
|
|
raise HTTPException(404, '文件夹所属用户不存在')
|
|
|
|
def _folder_map(self):
|
|
return {r.id: r for r in self._base(DoclibFolder).all()}
|
|
|
|
@staticmethod
|
|
def _path(folder_id, folder_map):
|
|
names, seen = [], set()
|
|
while folder_id and folder_id not in seen and folder_id in folder_map:
|
|
seen.add(folder_id)
|
|
row = folder_map[folder_id]
|
|
names.append(row.name)
|
|
folder_id = row.parent_id
|
|
return ' / '.join(reversed(names))
|
|
|
|
def _person_map(self, ids):
|
|
if not ids:
|
|
return {}
|
|
rows = (self.db.query(SysUser, SysDept.dept_name)
|
|
.outerjoin(SysDept, and_(SysDept.id == SysUser.dept_id, SysDept.tenant_no == self.tenant,
|
|
SysDept.del_flag == 0))
|
|
.filter(SysUser.tenant_no == self.tenant, SysUser.id.in_(ids)).all())
|
|
return {u.id: {'id': u.id, 'name': u.nick_name or u.user_no, 'deptName': dept or ''} for u, dept in rows}
|
|
|
|
def _serialize_files(self, rows):
|
|
if not rows:
|
|
return []
|
|
shares = self._base(DoclibShare).filter(DoclibShare.file_id.in_([r.id for r in rows])).all()
|
|
people = self._person_map({r.create_by for r in rows} | {s.user_id for s in shares})
|
|
folder_map = self._folder_map()
|
|
by_file = {}
|
|
for share in shares:
|
|
by_file.setdefault(share.file_id, []).append(people.get(share.user_id, {
|
|
'id': share.user_id, 'name': '已删除用户', 'deptName': ''}))
|
|
return [self._row_to_dict(row, folder_map, people, by_file.get(row.id, [])) for row in rows]
|
|
|
|
def _row_to_dict(self, row, folder_map, people, shares):
|
|
"""文件可读字段的唯一转换入口;所有身份和对象属性均为只读。"""
|
|
return {
|
|
'id': row.id, 'name': row.name, 'folderId': row.folder_id,
|
|
'folderPath': self._path(row.folder_id, folder_map) or '根目录',
|
|
'size': row.size, 'contentType': row.content_type, 'ownerId': row.create_by,
|
|
'ownerName': people.get(row.create_by, {}).get('name', '已删除用户'),
|
|
'createTime': row.create_time.strftime('%Y-%m-%d %H:%M:%S'),
|
|
'updateTime': row.update_time.strftime('%Y-%m-%d %H:%M:%S'),
|
|
'canManage': self.is_admin or row.create_by == self.uid,
|
|
'shares': sorted(shares, key=lambda x: x['id']),
|
|
}
|
|
|
|
def get_file(self, file_id):
|
|
return self._serialize_files([self._file(file_id)])[0]
|
|
|
|
def download_record(self, file_id):
|
|
# 仅供服务端下载使用;不得直接序列化 object_key 返回给前端。
|
|
return self._file(file_id)
|
|
|
|
def list_files(self, scope='mine', folder_id='', keyword='', page=1, page_size=20):
|
|
query = self._files(scope)
|
|
if folder_id:
|
|
query = query.filter(DoclibFile.folder_id == folder_id)
|
|
if keyword.strip():
|
|
query = query.filter(DoclibFile.name.contains(keyword.strip(), autoescape=True))
|
|
total = query.count()
|
|
rows = (query.order_by(DoclibFile.create_time.desc(), DoclibFile.id.desc())
|
|
.offset((page - 1) * page_size).limit(page_size).all())
|
|
return {'list': self._serialize_files(rows), 'total': total}
|
|
|
|
def _folder_node(self, row, scope, folder_map, count=0):
|
|
return {'id': f'{scope}:{row.id}', 'name': row.name, 'scope': scope,
|
|
'folderId': row.id, 'fullPath': self._path(row.id, folder_map),
|
|
'ownerId': row.create_by, 'canManage': scope != 'shared' and (self.is_admin or row.create_by == self.uid),
|
|
'count': count, 'children': []}
|
|
|
|
def tree(self):
|
|
from sqlalchemy import func
|
|
folder_map = self._folder_map()
|
|
roots = []
|
|
scopes = [('mine', '我的文档'), ('shared', '分享给我')]
|
|
if self.is_admin:
|
|
scopes.append(('all', '全部文档'))
|
|
for scope, title in scopes:
|
|
counts = dict(self._files(scope).with_entities(DoclibFile.folder_id, func.count(DoclibFile.id))
|
|
.group_by(DoclibFile.folder_id).all())
|
|
if scope == 'mine':
|
|
allowed = {fid for fid, f in folder_map.items() if f.create_by == self.uid}
|
|
elif scope == 'all':
|
|
allowed = set(folder_map)
|
|
else:
|
|
allowed = set(counts) & set(folder_map)
|
|
for fid in list(allowed):
|
|
seen = set()
|
|
while fid in folder_map and fid not in seen:
|
|
seen.add(fid)
|
|
allowed.add(fid)
|
|
fid = folder_map[fid].parent_id
|
|
nodes = {fid: self._folder_node(folder_map[fid], scope, folder_map, counts.get(fid, 0))
|
|
for fid in sorted(allowed, key=lambda x: (folder_map[x].create_time, x), reverse=True)}
|
|
root = {'id': scope, 'name': title, 'scope': scope, 'folderId': '', 'fullPath': title,
|
|
'ownerId': self.uid if scope == 'mine' else '', 'canManage': False,
|
|
'count': sum(counts.values()), 'children': []}
|
|
for fid, node in nodes.items():
|
|
parent = nodes.get(folder_map[fid].parent_id, root)
|
|
parent['children'].append(node)
|
|
roots.append(root)
|
|
return {'nodes': roots, 'isSuperAdmin': self.is_admin, 'maxUploadBytes': settings.DOCLIB_MAX_UPLOAD_BYTES}
|
|
|
|
def _unique_name(self, name, parent_id, owner_id, exclude=''):
|
|
found = self._base(DoclibFolder).filter(DoclibFolder.create_by == owner_id,
|
|
DoclibFolder.parent_id == parent_id, DoclibFolder.name == name, DoclibFolder.id != exclude).with_for_update().first()
|
|
if found:
|
|
raise HTTPException(409, '同一目录下已存在此名称')
|
|
|
|
def create_folder(self, name, parent_id=''):
|
|
self._lock_owner(self.uid)
|
|
if parent_id:
|
|
self._folder(parent_id, own=True, lock=True)
|
|
self._unique_name(name, parent_id, self.uid)
|
|
row = DoclibFolder(name=name, parent_id=parent_id, **self._public())
|
|
self.db.add(row)
|
|
self.db.commit()
|
|
return self._folder_node(row, 'mine', self._folder_map())
|
|
|
|
def rename_folder(self, folder_id, name):
|
|
row = self._folder(folder_id, manage=True)
|
|
self._lock_owner(row.create_by)
|
|
row = self._folder(folder_id, manage=True, lock=True)
|
|
self._unique_name(name, row.parent_id, row.create_by, row.id)
|
|
row.name, row.update_by = name, self.uid
|
|
self.db.commit()
|
|
return self._folder_node(row, 'mine' if row.create_by == self.uid else 'all', self._folder_map())
|
|
|
|
def delete_folder(self, folder_id):
|
|
row = self._folder(folder_id, manage=True)
|
|
self._lock_owner(row.create_by)
|
|
row = self._folder(folder_id, manage=True, lock=True)
|
|
if (self._base(DoclibFolder).filter(DoclibFolder.parent_id == row.id).with_for_update().first() or
|
|
self._base(DoclibFile).filter(DoclibFile.folder_id == row.id).with_for_update().first()):
|
|
raise HTTPException(409, '文件夹非空,请先删除文件和子文件夹')
|
|
row.del_flag, row.update_by = 1, self.uid
|
|
self.db.commit()
|
|
|
|
def create_file(self, *, name, folder_id, size, content_type, object_key):
|
|
self._lock_owner(self.uid)
|
|
if folder_id:
|
|
self._folder(folder_id, own=True, lock=True)
|
|
row = DoclibFile(name=name, folder_id=folder_id, size=size, content_type=content_type,
|
|
object_key=object_key, **self._public())
|
|
self.db.add(row)
|
|
self.db.flush()
|
|
result = self._serialize_files([row])[0]
|
|
self.db.commit()
|
|
return result
|
|
|
|
def set_shares(self, file_id, user_ids):
|
|
row = self._file(file_id, manage=True, lock=True)
|
|
requested = set(user_ids) - {row.create_by}
|
|
valid = {u.id for u in self._base(SysUser).filter(SysUser.id.in_(requested), SysUser.status == '1').all()}
|
|
if requested != valid:
|
|
raise HTTPException(400, '分享人员不存在、已停用或不属于当前租户')
|
|
history = {s.user_id: s for s in self.db.query(DoclibShare).filter(
|
|
DoclibShare.tenant_no == self.tenant, DoclibShare.file_id == file_id).populate_existing().with_for_update().all()}
|
|
for uid, share in history.items():
|
|
share.del_flag = 0 if uid in requested else 1
|
|
share.update_by = self.uid
|
|
for uid in requested - set(history):
|
|
self.db.add(DoclibShare(file_id=file_id, user_id=uid, **self._public()))
|
|
row.update_by, row.update_time = self.uid, datetime.now()
|
|
self.db.commit()
|
|
return self._serialize_files([row])[0]
|
|
|
|
def delete_files(self, ids):
|
|
rows = [self._file(fid, manage=True, lock=True) for fid in sorted(set(ids))]
|
|
for row in rows:
|
|
row.del_flag, row.update_by = 1, self.uid
|
|
self._base(DoclibShare).filter(DoclibShare.file_id.in_(ids)).update(
|
|
{DoclibShare.del_flag: 1, DoclibShare.update_by: self.uid}, synchronize_session=False)
|
|
self.db.commit()
|
|
|
|
def users(self, keyword=''):
|
|
query = self._base(SysUser).filter(SysUser.status == '1', SysUser.id != self.uid)
|
|
if keyword.strip():
|
|
query = query.filter(or_(SysUser.nick_name.contains(keyword.strip(), autoescape=True),
|
|
SysUser.user_no.contains(keyword.strip(), autoescape=True)))
|
|
rows = query.order_by(SysUser.nick_name, SysUser.id).limit(100).all()
|
|
people = self._person_map([r.id for r in rows])
|
|
return [people[r.id] for r in rows]
|