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