Files
shanxia2.0/backend/app/services/doclib_service.py
T

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]