doclib_service.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. """文档库业务权限:租户隔离 + 上传人/明确分享人/超级管理员。
  2. 该模块采用用户确认的逐文件 ACL,部门数据范围不扩大私人资料访问权限。
  3. 目录只暴露可见文件的祖先路径,不将目录可见性当作文件授权。
  4. """
  5. from datetime import datetime
  6. from typing import Optional
  7. from uuid import uuid4
  8. from fastapi import HTTPException
  9. from sqlalchemy import and_, exists, or_
  10. from sqlalchemy.orm import Session
  11. from app.core.config import settings
  12. from app.core.tenant_context import get_tenant_no
  13. from app.models.doclib import DoclibFile, DoclibFolder, DoclibShare
  14. from app.models.models import SysDept, SysUser
  15. class DoclibService:
  16. def __init__(self, db: Session, user: dict):
  17. self.db = db
  18. self.user = user
  19. self.uid = user.get('user_id')
  20. if not self.uid:
  21. raise HTTPException(401, '请先登录')
  22. self.tenant = get_tenant_no(db, user)
  23. self.is_admin = user.get('role') == 'super_admin'
  24. def _base(self, model):
  25. return self.db.query(model).filter(model.tenant_no == self.tenant, model.del_flag == 0)
  26. def _public(self):
  27. return dict(id=str(uuid4()), tenant_no=self.tenant, create_by=self.uid,
  28. update_by=self.uid, create_dept_id=self.user.get('region_id') or '')
  29. def _shared(self):
  30. return exists().where(and_(DoclibShare.tenant_no == self.tenant,
  31. DoclibShare.file_id == DoclibFile.id,
  32. DoclibShare.user_id == self.uid, DoclibShare.del_flag == 0))
  33. def _files(self, scope: Optional[str] = None):
  34. query = self._base(DoclibFile)
  35. if scope == 'mine':
  36. return query.filter(DoclibFile.create_by == self.uid)
  37. if scope == 'shared':
  38. return query.filter(DoclibFile.create_by != self.uid, self._shared())
  39. if scope == 'all':
  40. if not self.is_admin:
  41. raise HTTPException(403, '仅超级管理员可查看全部文档')
  42. return query
  43. if scope is not None:
  44. raise HTTPException(400, '无效文档范围')
  45. return query if self.is_admin else query.filter(or_(DoclibFile.create_by == self.uid, self._shared()))
  46. def _file(self, file_id, *, manage=False, lock=False):
  47. query = self._files().filter(DoclibFile.id == file_id)
  48. row = (query.populate_existing().with_for_update() if lock else query).first()
  49. if row is None:
  50. raise HTTPException(404, '文档不存在或无权访问')
  51. if manage and not (self.is_admin or row.create_by == self.uid):
  52. raise HTTPException(403, '只有上传人和超级管理员可以管理文档')
  53. return row
  54. def _folder(self, folder_id, *, own=False, manage=False, lock=False):
  55. query = self._base(DoclibFolder).filter(DoclibFolder.id == folder_id)
  56. row = (query.populate_existing().with_for_update() if lock else query).first()
  57. if row is None:
  58. raise HTTPException(404, '文件夹不存在')
  59. if (own and row.create_by != self.uid) or (manage and row.create_by != self.uid and not self.is_admin):
  60. raise HTTPException(403, '无权管理此文件夹')
  61. return row
  62. def _lock_owner(self, owner_id):
  63. # 所有目录写操作锁同一所有者,避免检查空目录与并发上传/建子目录之间的竞争。
  64. row = self._base(SysUser).filter(SysUser.id == owner_id).with_for_update().first()
  65. if row is None:
  66. raise HTTPException(404, '文件夹所属用户不存在')
  67. def _folder_map(self):
  68. return {r.id: r for r in self._base(DoclibFolder).all()}
  69. @staticmethod
  70. def _path(folder_id, folder_map):
  71. names, seen = [], set()
  72. while folder_id and folder_id not in seen and folder_id in folder_map:
  73. seen.add(folder_id)
  74. row = folder_map[folder_id]
  75. names.append(row.name)
  76. folder_id = row.parent_id
  77. return ' / '.join(reversed(names))
  78. def _person_map(self, ids):
  79. if not ids:
  80. return {}
  81. rows = (self.db.query(SysUser, SysDept.dept_name)
  82. .outerjoin(SysDept, and_(SysDept.id == SysUser.dept_id, SysDept.tenant_no == self.tenant,
  83. SysDept.del_flag == 0))
  84. .filter(SysUser.tenant_no == self.tenant, SysUser.id.in_(ids)).all())
  85. return {u.id: {'id': u.id, 'name': u.nick_name or u.user_no, 'deptName': dept or ''} for u, dept in rows}
  86. def _serialize_files(self, rows):
  87. if not rows:
  88. return []
  89. shares = self._base(DoclibShare).filter(DoclibShare.file_id.in_([r.id for r in rows])).all()
  90. people = self._person_map({r.create_by for r in rows} | {s.user_id for s in shares})
  91. folder_map = self._folder_map()
  92. by_file = {}
  93. for share in shares:
  94. by_file.setdefault(share.file_id, []).append(people.get(share.user_id, {
  95. 'id': share.user_id, 'name': '已删除用户', 'deptName': ''}))
  96. return [self._row_to_dict(row, folder_map, people, by_file.get(row.id, [])) for row in rows]
  97. def _row_to_dict(self, row, folder_map, people, shares):
  98. """文件可读字段的唯一转换入口;所有身份和对象属性均为只读。"""
  99. return {
  100. 'id': row.id, 'name': row.name, 'folderId': row.folder_id,
  101. 'folderPath': self._path(row.folder_id, folder_map) or '根目录',
  102. 'size': row.size, 'contentType': row.content_type, 'ownerId': row.create_by,
  103. 'ownerName': people.get(row.create_by, {}).get('name', '已删除用户'),
  104. 'createTime': row.create_time.strftime('%Y-%m-%d %H:%M:%S'),
  105. 'updateTime': row.update_time.strftime('%Y-%m-%d %H:%M:%S'),
  106. 'canManage': self.is_admin or row.create_by == self.uid,
  107. 'shares': sorted(shares, key=lambda x: x['id']),
  108. }
  109. def get_file(self, file_id):
  110. return self._serialize_files([self._file(file_id)])[0]
  111. def download_record(self, file_id):
  112. # 仅供服务端下载使用;不得直接序列化 object_key 返回给前端。
  113. return self._file(file_id)
  114. def list_files(self, scope='mine', folder_id='', keyword='', page=1, page_size=20):
  115. query = self._files(scope)
  116. if folder_id:
  117. query = query.filter(DoclibFile.folder_id == folder_id)
  118. if keyword.strip():
  119. query = query.filter(DoclibFile.name.contains(keyword.strip(), autoescape=True))
  120. total = query.count()
  121. rows = (query.order_by(DoclibFile.create_time.desc(), DoclibFile.id.desc())
  122. .offset((page - 1) * page_size).limit(page_size).all())
  123. return {'list': self._serialize_files(rows), 'total': total}
  124. def _folder_node(self, row, scope, folder_map, count=0):
  125. return {'id': f'{scope}:{row.id}', 'name': row.name, 'scope': scope,
  126. 'folderId': row.id, 'fullPath': self._path(row.id, folder_map),
  127. 'ownerId': row.create_by, 'canManage': scope != 'shared' and (self.is_admin or row.create_by == self.uid),
  128. 'count': count, 'children': []}
  129. def tree(self):
  130. from sqlalchemy import func
  131. folder_map = self._folder_map()
  132. roots = []
  133. scopes = [('mine', '我的文档'), ('shared', '分享给我')]
  134. if self.is_admin:
  135. scopes.append(('all', '全部文档'))
  136. for scope, title in scopes:
  137. counts = dict(self._files(scope).with_entities(DoclibFile.folder_id, func.count(DoclibFile.id))
  138. .group_by(DoclibFile.folder_id).all())
  139. if scope == 'mine':
  140. allowed = {fid for fid, f in folder_map.items() if f.create_by == self.uid}
  141. elif scope == 'all':
  142. allowed = set(folder_map)
  143. else:
  144. allowed = set(counts) & set(folder_map)
  145. for fid in list(allowed):
  146. seen = set()
  147. while fid in folder_map and fid not in seen:
  148. seen.add(fid)
  149. allowed.add(fid)
  150. fid = folder_map[fid].parent_id
  151. nodes = {fid: self._folder_node(folder_map[fid], scope, folder_map, counts.get(fid, 0))
  152. for fid in sorted(allowed, key=lambda x: (folder_map[x].create_time, x), reverse=True)}
  153. root = {'id': scope, 'name': title, 'scope': scope, 'folderId': '', 'fullPath': title,
  154. 'ownerId': self.uid if scope == 'mine' else '', 'canManage': False,
  155. 'count': sum(counts.values()), 'children': []}
  156. for fid, node in nodes.items():
  157. parent = nodes.get(folder_map[fid].parent_id, root)
  158. parent['children'].append(node)
  159. roots.append(root)
  160. return {'nodes': roots, 'isSuperAdmin': self.is_admin, 'maxUploadBytes': settings.DOCLIB_MAX_UPLOAD_BYTES}
  161. def _unique_name(self, name, parent_id, owner_id, exclude=''):
  162. found = self._base(DoclibFolder).filter(DoclibFolder.create_by == owner_id,
  163. DoclibFolder.parent_id == parent_id, DoclibFolder.name == name, DoclibFolder.id != exclude).with_for_update().first()
  164. if found:
  165. raise HTTPException(409, '同一目录下已存在此名称')
  166. def create_folder(self, name, parent_id=''):
  167. self._lock_owner(self.uid)
  168. if parent_id:
  169. self._folder(parent_id, own=True, lock=True)
  170. self._unique_name(name, parent_id, self.uid)
  171. row = DoclibFolder(name=name, parent_id=parent_id, **self._public())
  172. self.db.add(row)
  173. self.db.commit()
  174. return self._folder_node(row, 'mine', self._folder_map())
  175. def rename_folder(self, folder_id, name):
  176. row = self._folder(folder_id, manage=True)
  177. self._lock_owner(row.create_by)
  178. row = self._folder(folder_id, manage=True, lock=True)
  179. self._unique_name(name, row.parent_id, row.create_by, row.id)
  180. row.name, row.update_by = name, self.uid
  181. self.db.commit()
  182. return self._folder_node(row, 'mine' if row.create_by == self.uid else 'all', self._folder_map())
  183. def delete_folder(self, folder_id):
  184. row = self._folder(folder_id, manage=True)
  185. self._lock_owner(row.create_by)
  186. row = self._folder(folder_id, manage=True, lock=True)
  187. if (self._base(DoclibFolder).filter(DoclibFolder.parent_id == row.id).with_for_update().first() or
  188. self._base(DoclibFile).filter(DoclibFile.folder_id == row.id).with_for_update().first()):
  189. raise HTTPException(409, '文件夹非空,请先删除文件和子文件夹')
  190. row.del_flag, row.update_by = 1, self.uid
  191. self.db.commit()
  192. def create_file(self, *, name, folder_id, size, content_type, object_key):
  193. self._lock_owner(self.uid)
  194. if folder_id:
  195. self._folder(folder_id, own=True, lock=True)
  196. row = DoclibFile(name=name, folder_id=folder_id, size=size, content_type=content_type,
  197. object_key=object_key, **self._public())
  198. self.db.add(row)
  199. self.db.flush()
  200. result = self._serialize_files([row])[0]
  201. self.db.commit()
  202. return result
  203. def set_shares(self, file_id, user_ids):
  204. row = self._file(file_id, manage=True, lock=True)
  205. requested = set(user_ids) - {row.create_by}
  206. valid = {u.id for u in self._base(SysUser).filter(SysUser.id.in_(requested), SysUser.status == '1').all()}
  207. if requested != valid:
  208. raise HTTPException(400, '分享人员不存在、已停用或不属于当前租户')
  209. history = {s.user_id: s for s in self.db.query(DoclibShare).filter(
  210. DoclibShare.tenant_no == self.tenant, DoclibShare.file_id == file_id).populate_existing().with_for_update().all()}
  211. for uid, share in history.items():
  212. share.del_flag = 0 if uid in requested else 1
  213. share.update_by = self.uid
  214. for uid in requested - set(history):
  215. self.db.add(DoclibShare(file_id=file_id, user_id=uid, **self._public()))
  216. row.update_by, row.update_time = self.uid, datetime.now()
  217. self.db.commit()
  218. return self._serialize_files([row])[0]
  219. def delete_files(self, ids):
  220. rows = [self._file(fid, manage=True, lock=True) for fid in sorted(set(ids))]
  221. for row in rows:
  222. row.del_flag, row.update_by = 1, self.uid
  223. self._base(DoclibShare).filter(DoclibShare.file_id.in_(ids)).update(
  224. {DoclibShare.del_flag: 1, DoclibShare.update_by: self.uid}, synchronize_session=False)
  225. self.db.commit()
  226. def users(self, keyword=''):
  227. query = self._base(SysUser).filter(SysUser.status == '1', SysUser.id != self.uid)
  228. if keyword.strip():
  229. query = query.filter(or_(SysUser.nick_name.contains(keyword.strip(), autoescape=True),
  230. SysUser.user_no.contains(keyword.strip(), autoescape=True)))
  231. rows = query.order_by(SysUser.nick_name, SysUser.id).limit(100).all()
  232. people = self._person_map([r.id for r in rows])
  233. return [people[r.id] for r in rows]