| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263 |
- """文档库业务权限:租户隔离 + 上传人/明确分享人/超级管理员。
- 该模块采用用户确认的逐文件 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]
|