files.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. """文件资料库:仅按归属和服务器生成的ID定位文件,不接受客户端磁盘路径。"""
  2. import hashlib
  3. import json
  4. import mimetypes
  5. from pathlib import Path
  6. import re
  7. import config
  8. from db import db_conn, test_lab_store
  9. ALLOWED = {'.pdf','.png','.jpg','.jpeg','.webp','.json','.xlsx','.csv'}
  10. DOCUMENTS = {'.pdf','.png','.jpg','.jpeg','.webp'}
  11. MAX_BYTES = 50*1024*1024
  12. def root():
  13. path = Path(config.get_str('TEST_LAB_DATA_DIR', 'data/test_lab'))
  14. return path if path.is_absolute() else config.ROOT/path
  15. def user_root(user):
  16. return root()/hashlib.sha256(user.encode()).hexdigest()[:24]
  17. def samples():
  18. configured = config.get_str('TEST_LAB_SAMPLE_DIR')
  19. directory = Path(configured) if configured else config.ROOT.parent/'陕西局代码/测试数据'
  20. if not directory.is_absolute():
  21. directory = config.ROOT/directory
  22. if not directory.is_dir():
  23. return {}
  24. directory = directory.resolve()
  25. result = {}
  26. for path in sorted(directory.rglob('*')):
  27. if len(result) >= 100:
  28. break
  29. if path.is_file() and not path.is_symlink() and path.suffix.lower() in ALLOWED and path.resolve().is_relative_to(directory):
  30. key = 'sample-'+hashlib.sha256(path.relative_to(directory).as_posix().encode()).hexdigest()[:24]
  31. result[key] = path
  32. return result
  33. def describe(identifier, filename, size, *, source, digest=''):
  34. suffix = Path(filename).suffix.lower()
  35. kind = 'pdf' if suffix=='.pdf' else 'image' if suffix in DOCUMENTS else 'json' if suffix=='.json' else 'excel'
  36. return {'id':identifier,'filename':filename,'size':size,'kind':kind,'source':source,'sha256':digest,
  37. 'can_analyze':suffix in DOCUMENTS or suffix=='.json',
  38. 'url':f'/api/test-lab/assets/{identifier}/content','preview_url':f'/api/test-lab/assets/{identifier}/preview'}
  39. def list_assets(user):
  40. builtin = [describe(key,path.name,path.stat().st_size,source='sample') for key,path in samples().items()]
  41. uploaded = [describe(row['id'],row['filename'],row['size'],source='upload',digest=row['sha256']) for row in test_lab_store.assets(user)]
  42. return builtin+uploaded
  43. def owned_file(identifier, user):
  44. if identifier.startswith('sample-'):
  45. path = samples().get(identifier)
  46. if path is None:
  47. raise ValueError('内置测试资料不存在')
  48. return path, describe(identifier,path.name,path.stat().st_size,source='sample')
  49. if not re.fullmatch(r'[0-9a-f]{32}', identifier):
  50. raise ValueError('测试资料不存在')
  51. row = test_lab_store.asset(identifier,user)
  52. path = user_root(user)/identifier/row['filename']
  53. if not path.is_file() or path.is_symlink() or not path.resolve().is_relative_to(user_root(user).resolve()):
  54. raise ValueError('测试资料文件不存在')
  55. return path, describe(identifier,row['filename'],row['size'],source='upload',digest=row['sha256'])
  56. async def upload(file, user):
  57. name = re.sub(r'[^\w\u4e00-\u9fff.\-]+','_',Path((file.filename or '').replace('\\','/')).name)[:180]
  58. if Path(name).suffix.lower() not in ALLOWED:
  59. raise ValueError('支持PDF、PNG、JPEG、WebP、JSON、XLSX和CSV文件')
  60. identifier = db_conn.new_id()
  61. directory = user_root(user)/identifier
  62. directory.mkdir(parents=True,exist_ok=False)
  63. path = directory/name
  64. size, digest = 0, hashlib.sha256()
  65. try:
  66. with path.open('xb') as stream:
  67. while chunk := await file.read(1024*1024):
  68. size += len(chunk)
  69. if size>MAX_BYTES:
  70. raise ValueError('单个文件不得超过50MB')
  71. stream.write(chunk)
  72. digest.update(chunk)
  73. if not size:
  74. raise ValueError('不能上传空文件')
  75. if path.suffix.lower()=='.json':
  76. if size>5*1024*1024:
  77. raise ValueError('JSON测试数据不得超过5MB')
  78. value = json.loads(path.read_text(encoding='utf-8-sig'))
  79. if not isinstance(value, dict):
  80. raise ValueError('JSON测试数据必须是一个对象')
  81. test_lab_store.save_asset(user,name,digest.hexdigest(),size,identifier)
  82. except BaseException:
  83. path.unlink(missing_ok=True)
  84. directory.rmdir()
  85. raise
  86. finally:
  87. await file.close()
  88. return describe(identifier,name,size,source='upload',digest=digest.hexdigest())
  89. def media_type(path):
  90. return {'.pdf':'application/pdf','.json':'application/json','.xlsx':'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  91. '.csv':'text/csv; charset=utf-8'}.get(path.suffix.lower(),mimetypes.guess_type(path.name)[0] or 'application/octet-stream')
  92. def json_value(path):
  93. if path.stat().st_size>5*1024*1024:
  94. raise ValueError('JSON文件过大')
  95. return json.loads(path.read_text(encoding='utf-8-sig'))
  96. def series_fragment(raw, source):
  97. """兼容项目已有的开停/风量mock JSON;设备归属和单位仍须显式给出。"""
  98. if raw.get('schema_version')!=1 or raw.get('data_kind')!='synthetic' or not isinstance(raw.get('series'),list):
  99. return None
  100. from algorithms.main_fan.models import Device, FanStateRecord, OperatingSeries
  101. common={'mine_code':raw['mine_code'],'mine_name':raw['mine_name'],
  102. 'evidence':{'source':source,'method':'synthetic','label':'上传的测试时序','excerpt':raw.get('description','上传的测试时序')}}
  103. fragment={'devices':[],'fan_state_records':[],'operating_series':[]}
  104. for series in raw['series']:
  105. if series.get('state_scope')=='whole_fan':
  106. fragment['devices'].append(Device(device_code=series['fan_id'],name=series['fan_name'],**common).model_dump(mode='json'))
  107. for sample in series['samples']:
  108. if sample['value'] not in (0,1):
  109. raise ValueError('整机开停曲线的值必须为0或1')
  110. fragment['fan_state_records'].append(FanStateRecord(fan_id=series['fan_id'],observed_at=sample['observed_at'],
  111. state='running' if sample['value']==1 else 'stopped',complete_fan_state=True,**common).model_dump(mode='json'))
  112. else:
  113. fragment['operating_series'].append(OperatingSeries(fan_id=series['fan_id'],metric='flow',unit=series['unit'],
  114. sensor_id=series['sensor_id'],mapping_confirmed=series['mapping_confirmed'],samples=series['samples'],**common).model_dump(mode='json'))
  115. return fragment