base.py 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492
  1. # -*- coding: utf-8 -*-
  2. # @Author : YY
  3. from io import BufferedReader, BytesIO
  4. import io
  5. import json
  6. import os,socket,threading,re,base64,ipaddress,math,psutil
  7. from zipfile import is_zipfile
  8. import time
  9. from typing import Any, Callable, Dict, List, Literal, Optional, Type, \
  10. get_args, get_origin
  11. from datetime import datetime
  12. from openpyxl import Workbook, load_workbook
  13. from openpyxl.worksheet.worksheet import Worksheet
  14. from openpyxl.styles import PatternFill
  15. from pydantic import BaseModel
  16. from werkzeug.exceptions import NotFound
  17. from werkzeug.datastructures import FileStorage
  18. from werkzeug.utils import secure_filename
  19. from flask import Response, request
  20. from jwt import api_jwt
  21. from logging import Logger
  22. from ruoyi_common.base.snippet import classproperty
  23. from pydantic.alias_generators import to_camel
  24. from ..constant import Constants
  25. class UtilException(Exception):
  26. def __init__(self, message, status):
  27. super().__init__(message)
  28. self.status = status
  29. class StringUtil:
  30. @classmethod
  31. def to_bool(cls, value) -> bool:
  32. """
  33. 将值转换为布尔值
  34. Args:
  35. value (str or bytes): 输入
  36. Returns:
  37. bool: 转化后的布尔值
  38. """
  39. if isinstance(value, str):
  40. value = value.lower()
  41. if value in ['true', '1', 'yes', 'y']:
  42. return True
  43. elif value in ['false', '0', 'no', 'n']:
  44. return False
  45. elif isinstance(value, bytes):
  46. value = value.decode('utf-8')
  47. return cls.to_bool(value)
  48. elif value is None:
  49. return False
  50. else:
  51. raise TypeError('value must be str or bytes')
  52. return bool(value)
  53. @classmethod
  54. def to_int(cls, value) -> int:
  55. """
  56. 将值转换为整数
  57. Args:
  58. value (str): 输入
  59. Returns:
  60. int: 转化后的整数
  61. """
  62. if isinstance(value, str):
  63. value = value.strip()
  64. if value.isdigit():
  65. return int(value)
  66. return int(value)
  67. @classmethod
  68. def to_float(cls, value) -> float:
  69. """
  70. 将值转换为浮点数
  71. Args:
  72. value (str): 输入
  73. Returns:
  74. float: 转化后的浮点数
  75. """
  76. if isinstance(value, str):
  77. value = value.strip()
  78. if value.replace('.', '', 1).isdigit():
  79. return float(value)
  80. return float(value)
  81. @classmethod
  82. def to_str(cls, value) -> str:
  83. """
  84. 将值转换为字符串
  85. Args:
  86. value (_type_): 输入
  87. Returns:
  88. str: 转化后的字符串
  89. """
  90. if isinstance(value, bool):
  91. return str(value).lower()
  92. elif isinstance(value, bytes):
  93. value = value.decode('utf-8')
  94. return value
  95. return str(value)
  96. @classmethod
  97. def to_datetime(cls, value) -> datetime:
  98. """
  99. 将字符串转换为datetime类型
  100. Args:
  101. value (str): 字符类型日期字符串
  102. Returns:
  103. datetime: datetime类型日期
  104. """
  105. if isinstance(value, str):
  106. value = value.strip()
  107. try:
  108. return datetime.strptime(value, cls.datatime_format)
  109. except ValueError:
  110. pass
  111. return value
  112. @classmethod
  113. def ishttp(cls, val:str) -> bool:
  114. """
  115. 判断是否为http或https开头的url
  116. Args:
  117. val (str): 输入字符串
  118. Returns:
  119. bool: 是否为http或https开头的url
  120. """
  121. return val.startswith('http://') or val.startswith('https://')
  122. @classmethod
  123. def pad_left(cls, value, length:int) -> str:
  124. """
  125. 左填充字符串,使其长度达到指定长度
  126. Args:
  127. value (_type_): 输入字符串
  128. length (int): 目标长度
  129. Returns:
  130. str: 填充后的字符串
  131. """
  132. return str(value).zfill(length)
  133. @classmethod
  134. def left_pad(cls, value, length:int) -> str:
  135. """
  136. 兼容方法:left_pad 等同于 pad_left
  137. Args:
  138. value (_type_): 输入字符串
  139. length (int): 目标长度
  140. Returns:
  141. str: 填充后的字符串
  142. """
  143. return cls.pad_left(value, length)
  144. @classmethod
  145. def substring_after(cls, string:str, separator:str) -> str:
  146. """
  147. 获取字符串string中第一个分隔符separator之后的字符串
  148. Args:
  149. string (str): 输入字符串
  150. separator (str): 分隔符
  151. Returns:
  152. str: 转换后的字符串
  153. """
  154. if separator in string:
  155. return string.split(separator, 1)[1]
  156. return ""
  157. class DictUtil:
  158. @classmethod
  159. def upper_key(cls,dict_obj:dict) -> dict:
  160. '''
  161. 将配置对象中的所有键转换为大写
  162. Args:
  163. dict_obj(dict): 字典
  164. Returns:
  165. dict: 转换后的字典
  166. '''
  167. new_configobj = {}
  168. for k in dict_obj.keys():
  169. v = dict_obj.get(k)
  170. if k.islower():
  171. if isinstance(v, dict):
  172. new_configobj[k.upper()] = cls.upper_key(v)
  173. else:
  174. new_configobj[k.upper()] = v
  175. else:
  176. new_configobj[k] = v
  177. return new_configobj
  178. @classmethod
  179. def lower_key(cls,dict_obj:dict) -> dict:
  180. '''
  181. 将配置对象中的所有键转换为小写
  182. Args:
  183. dict_obj(dict): 字典
  184. Returns:
  185. dict: 转换后的字典
  186. '''
  187. new_configobj = {}
  188. for k in dict_obj.keys():
  189. v = dict_obj.get(k)
  190. if k.islower():
  191. if isinstance(v, dict):
  192. new_configobj[k.upper()] = cls.upper_key(v)
  193. else:
  194. new_configobj[k.upper()] = v
  195. return new_configobj
  196. @classmethod
  197. def flatten(cls,dict_obj) -> dict:
  198. '''
  199. 将字典展平为一级字典
  200. Args:
  201. dict_obj(dict): 字典
  202. Returns:
  203. dict: 展平后的字典
  204. '''
  205. new_dict = {}
  206. inner_dict = {}
  207. for k,v in dict_obj.items():
  208. if isinstance(v,dict):
  209. inner_dict.update(cls.flatten(v))
  210. else:
  211. new_dict[k] = v
  212. new_dict.update(inner_dict)
  213. return new_dict
  214. @classmethod
  215. def camelize_keys(cls, dict_obj: dict, deep: bool = True) -> dict:
  216. """
  217. 将字典的所有 key 从下划线命名转换为驼峰命名(front-end 专用)
  218. Args:
  219. dict_obj (dict): 原始字典(通常是实体 / 查询结果的 to_dict)
  220. deep (bool): 是否递归处理嵌套字典和列表
  221. Returns:
  222. dict: key 已经转换为驼峰后的新字典
  223. """
  224. if not isinstance(dict_obj, dict):
  225. return dict_obj
  226. new_dict = {}
  227. for k, v in dict_obj.items():
  228. # 先转换当前 key
  229. new_key = to_camel(k) if isinstance(k, str) else k
  230. # 再递归处理 value
  231. if deep:
  232. if isinstance(v, dict):
  233. new_dict[new_key] = cls.camelize_keys(v, deep=True)
  234. elif isinstance(v, list):
  235. new_list = []
  236. for item in v:
  237. if isinstance(item, dict):
  238. new_list.append(cls.camelize_keys(item, deep=True))
  239. else:
  240. new_list.append(item)
  241. new_dict[new_key] = new_list
  242. else:
  243. new_dict[new_key] = v
  244. else:
  245. new_dict[new_key] = v
  246. return new_dict
  247. @classmethod
  248. def format_value(cls,dict_obj) -> dict:
  249. '''
  250. 格式化字典的值
  251. Args:
  252. dict_obj(dict): 字典
  253. Returns:
  254. dict: 格式化后的字典
  255. '''
  256. pattern = re.compile("\{(.*?)\}")
  257. new_dict = {}
  258. for k,v in dict_obj.items():
  259. if isinstance(v,str) and re.match(pattern,v):
  260. v_v = v.format(**dict_obj)
  261. new_dict[k] = v_v
  262. else:
  263. new_dict[k] = v
  264. return new_dict
  265. @classmethod
  266. def recurive_key(cls,dict_obj,pre_key="") -> dict:
  267. '''
  268. 递归处理字典中的键
  269. Args:
  270. dict_obj(dict): 字典
  271. pre_key(str): 前缀
  272. Returns:
  273. dict: 处理后的字典
  274. '''
  275. new_dict = {}
  276. new_key = ""
  277. for k1,v1 in dict_obj.items():
  278. if pre_key=="":
  279. new_key = k1
  280. else:
  281. new_key = "{}.{}".format(pre_key,k1)
  282. if isinstance(v1,dict):
  283. next_dict = cls.recurive_key(v1,new_key)
  284. new_dict[new_key] = next_dict
  285. else:
  286. new_dict[new_key] = v1
  287. return new_dict
  288. class Base64Util:
  289. @classmethod
  290. def decode(cls,data:str, is_padding=True) -> str:
  291. '''
  292. base64解码
  293. Args:
  294. data(str): base64编码数据
  295. is_padding(bool): 是否需要补位
  296. Returns:
  297. str: 解码后数据
  298. '''
  299. suplus = len(data) % 4
  300. if is_padding:
  301. missing_padding = 4 - suplus
  302. data += '='* missing_padding
  303. else:
  304. data = data[:-suplus] if suplus else data
  305. return str(base64.b64decode(data))
  306. class TokenUtil:
  307. default_algorithm = "HS512"
  308. default_headers = {
  309. "typ": None,
  310. "alg": default_algorithm
  311. }
  312. @classmethod
  313. def encode(cls, payload, secret, headers=None) -> str:
  314. '''
  315. 编码生成jwt token
  316. Args:
  317. payload(dict): 载荷
  318. secret(str): 密钥
  319. headers(dict): 头部
  320. Returns:
  321. str: jwt token
  322. '''
  323. if headers is None:
  324. headers = cls.default_headers
  325. if "alg" not in headers:
  326. headers["alg"] = cls.default_algorithm
  327. else:
  328. if not headers["alg"]:
  329. headers["alg"] = cls.default_algorithm
  330. algorithm = headers["alg"]
  331. secret_decoded = Base64Util.decode(secret)
  332. jwt = api_jwt.encode(
  333. payload,
  334. secret_decoded,
  335. algorithm,
  336. headers=headers
  337. )
  338. return jwt
  339. @classmethod
  340. def decode(cls, jwt, secret, algorithms=None, verify=True) -> dict:
  341. '''
  342. 解码jwt token
  343. Args:
  344. jwt(str): jwt token
  345. secret(str): 密钥
  346. algorithms(str): 算法
  347. verify(bool): 是否验证
  348. Returns:
  349. dict: 解码后的payload
  350. '''
  351. if algorithms is None:
  352. algorithms = cls.default_algorithm
  353. secret_decoded = Base64Util.decode(secret)
  354. payload = api_jwt.decode(jwt, secret_decoded, algorithms=algorithms, verify=verify)
  355. return payload
  356. @classmethod
  357. def get_from_request(cls) -> str:
  358. '''
  359. 从请求头中获取token
  360. Returns:
  361. str: token
  362. '''
  363. authorization = request.headers.get('Authorization', None)
  364. if authorization is None:
  365. raise Exception('Authorization header not found')
  366. authorization_split = authorization.split()
  367. if len(authorization_split)!= 2 or authorization_split[0].lower()!= 'bearer':
  368. raise Exception('Invalid authorization header')
  369. return authorization_split[1]
  370. @classmethod
  371. def verify_from_request(cls, key, algorithms=None):
  372. '''
  373. 从请求头中获取token,并验证token
  374. Args:
  375. key(str): 密钥
  376. algorithms(str): 算法
  377. Raises:
  378. UtilException: 验证失败
  379. '''
  380. encoded_token = cls.get_token_from_request()
  381. cls.decode(encoded_token, key, algorithms=algorithms, verify=True)
  382. class IpUtil:
  383. @classmethod
  384. def get_ip(cls):
  385. '''
  386. 获取请求ip
  387. Returns:
  388. str: ip
  389. '''
  390. forwarded_for = request.headers.get('X-Forwarded-For')
  391. ip = None
  392. if forwarded_for:
  393. ip = forwarded_for.split(',')[0].strip()
  394. if not ip:
  395. ip = request.headers.get('X-Real-IP')
  396. if not ip:
  397. ip = request.remote_addr
  398. if not ip or ip == '::1':
  399. ip = '127.0.0.1'
  400. if ip == "localhost":
  401. ip = "127.0.0.1"
  402. return ip
  403. @classmethod
  404. def get_local_ips(cls) -> List[str]:
  405. '''
  406. 获取本地ip
  407. Returns:
  408. List[str]: 本地ip列表
  409. '''
  410. ips = []
  411. addrs = psutil.net_if_addrs() # 获取所有网络接口信息
  412. for interface, addr_list in addrs.items():
  413. for addr in addr_list:
  414. if addr.family == socket.AF_INET and not addr.address.startswith("127."):
  415. ips.append(addr.address)
  416. return ips
  417. def is_valid_ip(ip: str) -> bool:
  418. try:
  419. ipaddress.ip_address(ip)
  420. return True
  421. except ValueError:
  422. return False
  423. class AddressUtil:
  424. @classmethod
  425. def get_address(cls, ip) -> str:
  426. '''
  427. 根据ip获取地址
  428. Args:
  429. ip(str): ip
  430. Returns:
  431. str: 地址
  432. '''
  433. if not ip:
  434. return "未知"
  435. try:
  436. parsed_ip = ipaddress.ip_address(ip)
  437. if parsed_ip.is_loopback or parsed_ip.is_private:
  438. return "内网IP"
  439. except ValueError:
  440. return "未知"
  441. # TODO: 可在此集成第三方IP定位服务
  442. return "未知"
  443. class UserAgentUtil:
  444. @classmethod
  445. def get_user_agent(cls) -> str:
  446. '''
  447. 获取请求头中的user-agent
  448. Returns:
  449. str: user-agent
  450. '''
  451. user_agent = request.headers.get('User-Agent', None)
  452. return user_agent
  453. @classmethod
  454. def is_mobile(cls) -> bool:
  455. '''
  456. 判断是否为移动端
  457. Returns:
  458. bool: 是否为移动端
  459. '''
  460. user_agent = cls.get_user_agent()
  461. if user_agent is None:
  462. return False
  463. mobile_agents = [
  464. 'Android', 'iPhone', 'SymbianOS', 'Windows Phone', 'iPad', 'iPod'
  465. ]
  466. for agent in mobile_agents:
  467. if agent in user_agent:
  468. return True
  469. return False
  470. @classmethod
  471. def browser(cls) -> Literal['Chrome', 'Firefox', 'Safari', 'IE', None]:
  472. '''
  473. 获取浏览器类型
  474. Returns:
  475. Literal['Chrome', 'Firefox', 'Safari', 'IE', None]: 浏览器类型
  476. '''
  477. user_agent = cls.get_user_agent()
  478. if user_agent is None:
  479. return None
  480. if 'Chrome' in user_agent:
  481. return 'Chrome'
  482. elif 'Firefox' in user_agent:
  483. return 'Firefox'
  484. elif 'Safari' in user_agent:
  485. return 'Safari'
  486. elif 'MSIE' in user_agent or 'Trident' in user_agent:
  487. return 'IE'
  488. else:
  489. return None
  490. @classmethod
  491. def os(cls) -> Literal['Windows', 'Mac', 'Linux', 'Unix', None]:
  492. '''
  493. 获取操作系统类型
  494. Returns:
  495. Literal['Windows', 'Mac', 'Linux', 'Unix', None]: 操作系统类型
  496. '''
  497. user_agent = cls.get_user_agent()
  498. if user_agent is None:
  499. return None
  500. if 'Windows NT' in user_agent:
  501. return 'Windows'
  502. elif 'Macintosh' in user_agent:
  503. return 'Mac'
  504. elif 'Linux' in user_agent:
  505. return 'Linux'
  506. elif 'Unix' in user_agent:
  507. return 'Unix'
  508. else:
  509. return None
  510. @classmethod
  511. def is_pc(cls) -> bool:
  512. '''
  513. 判断是否为PC端
  514. Returns:
  515. bool: 是否为PC端
  516. '''
  517. user_agent = cls.get_user_agent()
  518. if user_agent is None:
  519. return False
  520. pc_agents = [
  521. 'Windows NT', 'Macintosh', 'Linux', 'Unix', 'FreeBSD', 'OpenBSD',
  522. 'NetBSD', 'SunOS', 'AIX', 'HP-UX', 'IRIX', 'OSF1', ' SCO', 'IRIX64'
  523. ]
  524. for agent in pc_agents:
  525. if agent in user_agent:
  526. return True
  527. return False
  528. @classmethod
  529. def is_weixin(cls) -> bool:
  530. '''
  531. 判断是否为微信端
  532. Returns:
  533. bool: 是否为微信端
  534. '''
  535. user_agent = cls.get_user_agent()
  536. if user_agent is None:
  537. return False
  538. weixin_agents = [
  539. 'MicroMessenger', 'WeChat', 'QQ', 'Weibo', 'TencentTraveler',
  540. 'QQBrowser', 'QQMobile', 'QQScan', 'Tenvideo', 'SogouExplorer',
  541. ]
  542. pass
  543. class MimeTypeUtil:
  544. IMAGE_PNG = "image/png"
  545. IMAGE_JPG = "image/jpg"
  546. IMAGE_JPEG = "image/jpeg"
  547. IMAGE_BMP = "image/bmp"
  548. IMAGE_GIF = "image/gif"
  549. VIDEO_EXTENSION = [ "mp4", "avi", "rmvb" ]
  550. MEDIA_EXTENSION = [ "swf", "flv", "mp3", "wav", "wma", "wmv", "mid", "avi", "mpg",
  551. "asf", "rm", "rmvb" ]
  552. FLASH_EXTENSION = [ "swf", "flv" ]
  553. IMAGE_EXTENSION = [ "bmp", "gif", "jpg", "jpeg", "png" ]
  554. DEFAULT_ALLOWED_EXTENSION = [
  555. # 图片
  556. "bmp", "gif", "jpg", "jpeg", "png",
  557. # word excel powerpoint
  558. "doc", "docx", "xls", "xlsx", "ppt", "pptx", "html", "htm", "txt",
  559. # 压缩文件
  560. "rar", "zip", "gz", "bz2",
  561. # 视频格式
  562. "mp4", "avi", "rmvb",
  563. # pdf
  564. "pdf" ]
  565. @classmethod
  566. def get_extension(cls, mime_type: str) -> str:
  567. '''
  568. 根据mime_type获取文件扩展名
  569. Args:
  570. mime_type(str): mime_type
  571. Returns:
  572. str: 文件扩展名
  573. '''
  574. match mime_type:
  575. case cls.IMAGE_PNG:
  576. return "png"
  577. case cls.IMAGE_JPG:
  578. return "jpg"
  579. case cls.IMAGE_JPEG:
  580. return "jpeg"
  581. case cls.IMAGE_BMP:
  582. return "bmp"
  583. case cls.IMAGE_GIF:
  584. return "gif"
  585. case _:
  586. return ""
  587. class DateUtil:
  588. YYYY = "%Y"
  589. YYYY_MM = "%Y-%m"
  590. YYYY_MM_DD = "%Y-%m-%d"
  591. YYYYMMDDHHMMSS = "%Y%m%d%H%M%S"
  592. YYYY_MM_DD_HH_MM_SS = "%Y-%m-%d %H:%M:%S"
  593. @classmethod
  594. def get_date_now(cls) -> str:
  595. """
  596. 获取当前日期 %Y-%m-%d
  597. Returns:
  598. str: 当前日期
  599. """
  600. return datetime.now().strftime(cls.YYYY_MM_DD)
  601. @classmethod
  602. def get_datetime_now(cls,fmt=None) -> str:
  603. """
  604. 获取当前日期 %Y%m%d%H%M%S
  605. Returns:
  606. str: 当前日期
  607. """
  608. fmt = fmt or cls.YYYYMMDDHHMMSS
  609. return datetime.now().strftime(fmt)
  610. @classmethod
  611. def get_time_now(cls) -> str:
  612. """
  613. 获取当前日期 '%Y-%m-%d %H:%M:%S'
  614. Returns:
  615. str: 当前日期
  616. """
  617. return datetime.now().strftime(cls.YYYY_MM_DD_HH_MM_SS)
  618. @classmethod
  619. def get_date_path(cls) -> str:
  620. """
  621. 获取当前日期 %Y/%m/%d
  622. Returns:
  623. str: 当前日期
  624. """
  625. return datetime.now().strftime("%Y/%m/%d")
  626. @classmethod
  627. def get_datepath(cls) -> str:
  628. """
  629. 获取当前日期 %Y%m%d
  630. Returns:
  631. str: 当前日期
  632. """
  633. return datetime.now().strftime("%Y%m%d")
  634. class FileUploadUtil:
  635. DEFAULT_MAX_SIZE = 50 * 1024 * 1024
  636. DEFAULT_FILE_NAME_LENGTH = 100
  637. @classmethod
  638. def upload(cls, file:FileStorage, base_path:str) -> str:
  639. '''
  640. 上传文件
  641. Args:
  642. file(FileStorage): 文件对象
  643. base_path(str): 上传路径
  644. Returns:
  645. str: 资源路径
  646. '''
  647. fn_len = len(file.filename)
  648. if fn_len > cls.DEFAULT_FILE_NAME_LENGTH:
  649. raise Exception("文件名长度超过限制")
  650. cls.check_allowed(file, MimeTypeUtil.DEFAULT_ALLOWED_EXTENSION)
  651. filename = cls.extract_file_name(file)
  652. # 物理磁盘完整路径,例如:G:/ruoyi/uploadPath/upload/2025/11/18/xxx.jpg
  653. filepath = os.path.join(base_path, filename)
  654. file_parpath = os.path.dirname(filepath)
  655. if not os.path.exists(file_parpath):
  656. os.makedirs(file_parpath)
  657. file.save(filepath)
  658. # 将物理路径转换为相对于 profile 的路径,生成浏览器可访问的 URL:
  659. # /profile/upload/2025/11/18/xxx.jpg
  660. # 为避免 utils.base 与 config 之间的循环依赖,这里在函数内部延迟导入 RuoYiConfig
  661. from ruoyi_common.config import RuoYiConfig
  662. relpath = os.path.relpath(filepath, RuoYiConfig.profile)
  663. relpath = relpath.replace(os.sep, "/")
  664. resource_path = Constants.RESOURCE_PREFIX + "/" + relpath
  665. return resource_path
  666. @classmethod
  667. def check_allowed(cls, file:FileStorage, allowed_extensions:List[str]):
  668. '''
  669. 文件大小、类型校验(对标若依的 FileUploadUtils.assertAllowed)
  670. Args:
  671. file(FileStorage): 文件对象
  672. allowed_extensions(List[str]): 允许的扩展名列表
  673. '''
  674. # 1. 文件大小校验
  675. # FileStorage 默认游标在起始位置,这里需要先 seek 到末尾再计算大小,
  676. # 然后把游标复位,避免影响后续 file.save 调用。
  677. current_pos = file.stream.tell()
  678. file.stream.seek(0, os.SEEK_END)
  679. file_size = file.stream.tell()
  680. file.stream.seek(current_pos, os.SEEK_SET)
  681. if file_size > cls.DEFAULT_MAX_SIZE:
  682. raise Exception("文件大小超过限制")
  683. # 2. 扩展名校验
  684. # 优先根据原始文件名获取扩展名,与若依 Java 版保持一致,
  685. # 只有当文件名没有扩展名时,才回退到根据 content_type 推断。
  686. extension = os.path.splitext(file.filename)[1]
  687. if extension.startswith("."):
  688. extension = extension[1:]
  689. extension = extension.lower()
  690. if not extension:
  691. extension = MimeTypeUtil.get_extension(file.content_type.lower())
  692. if extension not in allowed_extensions:
  693. if allowed_extensions == MimeTypeUtil.IMAGE_EXTENSION:
  694. raise Exception("图片格式不支持")
  695. elif allowed_extensions == MimeTypeUtil.VIDEO_EXTENSION:
  696. raise Exception("视频格式不支持")
  697. elif allowed_extensions == MimeTypeUtil.MEDIA_EXTENSION:
  698. raise Exception("媒体格式不支持")
  699. elif allowed_extensions == MimeTypeUtil.FLASH_EXTENSION:
  700. raise Exception("FLASH格式不支持")
  701. else:
  702. raise Exception("文件格式不支持")
  703. @classmethod
  704. def extract_file_name(cls, file:FileStorage) -> str:
  705. '''
  706. 提取文件名,仿照若依:
  707. 日期路径/原文件名_序列号.扩展名
  708. Args:
  709. file(FileStorage): 文件对象
  710. Returns:
  711. str: 文件名
  712. '''
  713. return "{}/{}_{}.{}".format(
  714. DateUtil.get_date_path(),
  715. os.path.basename(file.filename),
  716. Seq.get_seq_id(Seq.upload_seq_type),
  717. cls.get_extension(file)
  718. )
  719. @classmethod
  720. def get_extension(cls, file:FileStorage) -> str:
  721. '''
  722. 获取文件扩展名
  723. Args:
  724. file(FileStorage): 文件对象
  725. Returns:
  726. str: 文件扩展名
  727. '''
  728. extension = os.path.splitext(file.filename)[1]
  729. if extension is None or len(extension) == 0:
  730. extension = MimeTypeUtil.get_extension(file.content_type.lower())
  731. return extension
  732. @classmethod
  733. def get_filename(cls, filename:str) -> str:
  734. '''
  735. 获取文件名
  736. Args:
  737. filename(str): 带路径和后缀的文件名
  738. Returns:
  739. str: 文件名
  740. '''
  741. return os.path.basename(filename)
  742. class AtomicInteger:
  743. def __init__(self, initial=0):
  744. self._value = initial
  745. self._lock = threading.Lock()
  746. def get(self):
  747. '''
  748. 获取当前值
  749. Returns:
  750. int: 当前值
  751. '''
  752. with self._lock:
  753. return self._value
  754. def set(self, value):
  755. '''
  756. 设置新的值
  757. Args:
  758. value(int): 新的值
  759. '''
  760. with self._lock:
  761. self._value = value
  762. def increment(self):
  763. '''
  764. 原子增加操作
  765. Returns:
  766. int: 新值
  767. '''
  768. with self._lock:
  769. self._value += 1
  770. return self._value
  771. def decrement(self):
  772. '''
  773. 原子减少操作
  774. Returns:
  775. int: 新值
  776. '''
  777. with self._lock:
  778. self._value -= 1
  779. return self._value
  780. class Seq:
  781. common_seq_type = "common"
  782. upload_seq_type = "upload"
  783. common_seq = AtomicInteger(1)
  784. upload_seq = AtomicInteger(1)
  785. matchine_code = "A"
  786. @classmethod
  787. def get_seq_id(cls, seq_name:str = "common") -> int:
  788. '''
  789. 获取序列号
  790. Args:
  791. seq_name(str): 序列名称,common或upload
  792. Returns:
  793. int: 序列号
  794. '''
  795. ato = cls.upload_seq if seq_name == cls.upload_seq_type else cls.common_seq
  796. out = DateUtil.get_datetime_now() + cls.matchine_code + cls.get_seq(ato, 3)
  797. return out
  798. @classmethod
  799. def get_seq(cls, ato:AtomicInteger, length:int) -> str:
  800. '''
  801. 获取指定长度的序列号
  802. Args:
  803. ato(AtomicInteger): 原子整数
  804. length(int): 序列号长度
  805. Returns:
  806. str: 序列号
  807. '''
  808. seq = str(ato.increment())
  809. if ato.get() > math.pow(10, length):
  810. ato.set(1)
  811. return StringUtil.left_pad(seq, length)
  812. class MessageUtil:
  813. @staticmethod
  814. def message(code:str) -> str:
  815. """
  816. 根据code获取消息
  817. Args:
  818. code (str): 消息代码
  819. Returns:
  820. str: 消息内容
  821. """
  822. # todo
  823. return code
  824. class FileUtil:
  825. def delete_file(file_path:str) -> bool:
  826. '''
  827. 删除文件
  828. Args:
  829. file_path(str): 文件路径
  830. Returns:
  831. bool: 是否成功
  832. '''
  833. flag = False
  834. if os.path.isfile(file_path) and os.path.exists(file_path):
  835. os.remove(file_path)
  836. flag = True
  837. return flag
  838. class DescriptUtil:
  839. @classmethod
  840. def get_raw(cls, func:Callable) -> Callable:
  841. """
  842. 获取原始函数
  843. Args:
  844. func(Callable): 被装饰函数
  845. Returns:
  846. Callable: 原始函数
  847. """
  848. if hasattr(func, "__wrapped__"):
  849. func = func.__wrapped__
  850. return cls.get_raw(func)
  851. else:
  852. return func
  853. class ExcelUtil:
  854. default_header_fill = {
  855. "start_color": "FFFFFFFF",
  856. "end_color": "FFFFFFFF",
  857. "fill_type": None, # "solid" or None
  858. }
  859. default_row_fill = {
  860. "start_color": "FFFFFFFF",
  861. "end_color": "FFFFFFFF",
  862. "fill_type": None, # "solid" or None
  863. }
  864. allowed_extensions = ["xlsx","xls"]
  865. allowed_content_types = [
  866. "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
  867. ]
  868. max_content_length = 6 * 1024 * 1024 # 6M
  869. def __init__(self, model:Type[BaseModel]):
  870. self.model = model
  871. def write(self, data:List[BaseModel], sheetname:str) -> BytesIO:
  872. """
  873. 写入Excel文件
  874. Args:
  875. data(List[BaseModel]): 数据
  876. sheetname(str): 工作表名
  877. Returns:
  878. BytesIO: 文件字节流
  879. """
  880. if len(data) == 0:
  881. raise NotFound(description="无法导出excel,数据为空")
  882. workbook = Workbook()
  883. worksheet = workbook.create_sheet(title=sheetname)
  884. workbook.active = worksheet
  885. self.render_data(worksheet,data)
  886. output = BytesIO()
  887. workbook.save(output)
  888. output.seek(0)
  889. return output
  890. def render_header(self, sheet:Worksheet,fill:PatternFill=None):
  891. """
  892. 渲染Excel表头
  893. Args:
  894. sheet (Worksheet): 工作表
  895. fill(PatternFill): 表头填充
  896. """
  897. for col_index,access in enumerate(
  898. self.model.generate_excel_schema(),
  899. start=1
  900. ):
  901. _, access = access
  902. cell = sheet.cell(row=1,column=col_index,value=access.name)
  903. cell.fill = fill
  904. cell.font = access.header_font
  905. def render_row(self, sheet:Worksheet,row:BaseModel,row_index:int):
  906. """
  907. 渲染Excel行数据
  908. Args:
  909. sheet (Worksheet): 工作表
  910. row (BaseModel): 行数据模型
  911. row_index(int): 行索引
  912. """
  913. default_row_fill = PatternFill(
  914. **self.default_row_fill
  915. )
  916. for col_index,access in enumerate(row.generate_excel_data(),start=1):
  917. _,access = access
  918. cell = sheet.cell(row=row_index,column=col_index,value=access.val)
  919. cell.alignment = access.alignment
  920. cell.fill = access.fill if access.fill else default_row_fill
  921. cell.font = access.row_font
  922. def render_footer(self, sheet:Worksheet):
  923. """
  924. 渲染Excel表尾
  925. Args:
  926. sheet (Worksheet): 工作表
  927. """
  928. pass
  929. def render_data(self, sheet:Worksheet, data:List[BaseModel],header_fill:PatternFill=None):
  930. """
  931. 渲染Excel数据
  932. Args:
  933. sheet (Worksheet): 工作表
  934. data(List[BaseModel]): 数据模型列表
  935. """
  936. if not header_fill:
  937. header_fill = PatternFill(
  938. **self.default_header_fill
  939. )
  940. self.render_header(sheet,header_fill)
  941. for row_index,row in enumerate(data,start=2):
  942. self.render_row(sheet,row,row_index)
  943. self.render_footer(sheet)
  944. def export_response(self, data:List[BaseModel], sheetname:str) -> Response:
  945. """
  946. 响应Excel文件
  947. Args:
  948. data(List[BaseModel]): 数据
  949. sheetname(str): 工作表名
  950. Returns:
  951. Response: 文件流响应
  952. """
  953. output:BytesIO = self.write(data,sheetname)
  954. response = Response(
  955. response=output.getvalue(),
  956. status=200,
  957. mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  958. headers={"Content-Disposition": f"attachment; filename={time.time()}.xlsx"}
  959. )
  960. return response
  961. def import_template_response(self, sheetname:str) -> Response:
  962. """
  963. 响应导入模板
  964. Args:
  965. sheetname(str): 工作表名
  966. Returns:
  967. Response: 文件流响应
  968. """
  969. output:BytesIO = self.write_template(sheetname)
  970. response = Response(
  971. response=output.getvalue(),
  972. status=200,
  973. mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  974. headers={"Content-Disposition": f"attachment; filename={self.model.__name__}_import_template.xlsx"}
  975. )
  976. return response
  977. def write_template(self,sheetname:str) -> BytesIO:
  978. """
  979. 写入导入模板
  980. Args:
  981. sheetname(str): 工作表名
  982. Returns:
  983. BytesIO: 文件字节流
  984. """
  985. workbook = Workbook()
  986. worksheet = workbook.create_sheet(title=sheetname)
  987. workbook.active = worksheet
  988. self.render_template(worksheet)
  989. output = BytesIO()
  990. workbook.save(output)
  991. output.seek(0)
  992. return output
  993. def render_template(self, sheet:Worksheet):
  994. """
  995. 渲染导入模板
  996. Args:
  997. sheet (Worksheet): 工作表
  998. """
  999. header_fill = PatternFill(
  1000. **self.default_header_fill
  1001. )
  1002. self.render_header(sheet,header_fill)
  1003. def import_file(self, file:FileStorage, sheetname:Optional[str]=None) -> List[BaseModel]:
  1004. """
  1005. 导入数据
  1006. Args:
  1007. file(FileStorage): 导入文件
  1008. sheetname(str): 工作表名
  1009. Returns:
  1010. List[BaseModel]: 导入数据模型列表
  1011. """
  1012. self.check_file(file)
  1013. # Read the file content into BytesIO buffer which is fully compatible with openpyxl
  1014. file.stream.seek(0)
  1015. buffer = BytesIO(file.stream.read())
  1016. data = self.read_buffer(buffer, sheetname)
  1017. return data
  1018. def check_file(self, file:FileStorage):
  1019. '''
  1020. 检查文件是否合法
  1021. '''
  1022. filename = secure_filename(file.filename)
  1023. if "." not in filename:
  1024. raise Exception("文件名称不正确")
  1025. ext = filename.rsplit(".", 1)[1]
  1026. if ext not in self.allowed_extensions:
  1027. raise Exception("文件名称扩展名不正确")
  1028. if file.content_type not in self.allowed_content_types:
  1029. raise Exception("文件类型不正确")
  1030. # 文件大小
  1031. file.seek(0,os.SEEK_END)
  1032. if file.tell() > self.max_content_length:
  1033. raise Exception("文件大小超过限制")
  1034. IMPORT_SAMPLE_ROW_LIMIT = 5
  1035. def read_buffer(self, buffer, sheetname:Optional[str]=None) -> List[BaseModel]:
  1036. """
  1037. 读取文件流
  1038. Args:
  1039. buffer: 导入文件流 (can be BufferedReader or FileStorage stream)
  1040. sheetname(str): 工作表名
  1041. Returns:
  1042. List[BaseModel]: 导入数据模型列表
  1043. """
  1044. logger = self._get_logger()
  1045. try:
  1046. workbook = load_workbook(buffer,read_only=True,data_only=True)
  1047. except Exception as e:
  1048. raise Exception("文件格式不正确")
  1049. worksheets = self._get_candidate_worksheets(workbook, sheetname, logger)
  1050. if not worksheets:
  1051. raise NotFound(description="工作表不存在")
  1052. failures = []
  1053. column_meta = self._build_excel_column_meta()
  1054. for worksheet in worksheets:
  1055. data, sample_rows = self._extract_rows_from_worksheet(
  1056. worksheet,
  1057. column_meta=column_meta
  1058. )
  1059. if data is not None:
  1060. if logger:
  1061. logger.info(
  1062. "Excel导入:选择工作表",
  1063. extra={"sheet": worksheet.title, "sheetnames": workbook.sheetnames}
  1064. )
  1065. return data
  1066. failures.append({
  1067. "sheet": worksheet.title,
  1068. "sample_rows": sample_rows
  1069. })
  1070. if logger:
  1071. logger.info(
  1072. "Excel导入:工作表无有效表头,继续尝试下一张",
  1073. extra={
  1074. "sheet": worksheet.title,
  1075. "sample_rows": json.dumps(sample_rows, ensure_ascii=False)
  1076. }
  1077. )
  1078. if logger:
  1079. logger.error(
  1080. "Excel导入失败:所有工作表均缺少表头",
  1081. extra={
  1082. "sheetnames": workbook.sheetnames,
  1083. "failures": json.dumps(failures, ensure_ascii=False)
  1084. }
  1085. )
  1086. raise Exception("工作表为空,缺少表头行")
  1087. def _get_candidate_worksheets(self, workbook, sheetname:Optional[str], logger:Optional[Logger]) -> List[Worksheet]:
  1088. worksheets = list(workbook.worksheets)
  1089. if not worksheets:
  1090. return []
  1091. ordered = []
  1092. matched = None
  1093. if sheetname:
  1094. normalized_target = self._normalize_sheet_name(sheetname)
  1095. for worksheet in worksheets:
  1096. if self._normalize_sheet_name(worksheet.title) == normalized_target:
  1097. matched = worksheet
  1098. ordered.append(worksheet)
  1099. break
  1100. if matched is None and logger:
  1101. logger.warning(
  1102. "Excel导入提示:未找到指定工作表,将尝试所有工作表",
  1103. extra={"expected": sheetname, "available": workbook.sheetnames}
  1104. )
  1105. for worksheet in worksheets:
  1106. if worksheet is matched:
  1107. continue
  1108. ordered.append(worksheet)
  1109. if logger:
  1110. logger.info(
  1111. "Excel导入:工作表尝试顺序",
  1112. extra={"order": [ws.title for ws in ordered]}
  1113. )
  1114. return ordered
  1115. def _extract_rows_from_worksheet(self, worksheet:Worksheet, column_meta:Dict[str,Dict[str,Any]]):
  1116. header_values = None
  1117. data = []
  1118. sample_rows = []
  1119. for row_index, row in enumerate(worksheet.iter_rows(min_row=1, values_only=True), start=1):
  1120. row_values = list(row)
  1121. if len(sample_rows) < self.IMPORT_SAMPLE_ROW_LIMIT:
  1122. sample_rows.append({
  1123. "row": row_index,
  1124. "values": self._stringify_row(row_values)
  1125. })
  1126. if header_values is None:
  1127. if not any(cell not in (None, "") for cell in row_values):
  1128. continue
  1129. header_values = list(row_values)
  1130. continue
  1131. if not any(cell not in (None, "") for cell in row_values):
  1132. continue
  1133. row_data = {}
  1134. for header, cell_value in zip(header_values, row_values):
  1135. row_data[header] = self._coerce_cell_value(header, cell_value, column_meta)
  1136. new_row = self.model.rebuild_excel_schema(row_data)
  1137. data.append(self.model(**new_row))
  1138. if header_values is None:
  1139. return None, sample_rows
  1140. return data, sample_rows
  1141. def _build_excel_column_meta(self) -> Dict[str,Dict[str,Any]]:
  1142. if hasattr(self, "_excel_column_meta_cache"):
  1143. return self._excel_column_meta_cache
  1144. column_meta = {}
  1145. for field_path, access in self.model.generate_excel_schema():
  1146. column_meta[access.name] = {
  1147. "path": field_path.split("."),
  1148. "access": access,
  1149. "target_type": None
  1150. }
  1151. self._excel_column_meta_cache = column_meta
  1152. return column_meta
  1153. def _coerce_cell_value(self, header:str, value:Any, column_meta:Dict[str,Dict[str,Any]]):
  1154. meta = column_meta.get(header)
  1155. if not meta:
  1156. return value
  1157. access = meta.get("access")
  1158. # 如果设置了字典类型,将标签转换为字典值(导入时将标签转换为值)
  1159. if access and access.dict_type and value not in (None, ""):
  1160. try:
  1161. from ruoyi_system.service.sys_dict_type import DictCacheUtil
  1162. dict_value = DictCacheUtil.get_dict_value(access.dict_type, str(value))
  1163. if dict_value:
  1164. value = dict_value
  1165. except Exception:
  1166. # 如果字典转换失败,使用原值
  1167. pass
  1168. target_type = meta.get("target_type")
  1169. if target_type is None:
  1170. target_type = self._resolve_field_type(meta["path"])
  1171. meta["target_type"] = target_type
  1172. if target_type is str and value not in (None, ""):
  1173. return str(value)
  1174. return value
  1175. def _resolve_field_type(self, path:List[str]):
  1176. current_model = self.model
  1177. last_index = len(path) - 1
  1178. for idx, attr in enumerate(path):
  1179. if not hasattr(current_model, "model_fields"):
  1180. return None
  1181. field_info = current_model.model_fields.get(attr)
  1182. if field_info is None:
  1183. return None
  1184. if idx == last_index:
  1185. return get_final_type(field_info.annotation)
  1186. next_model = get_final_model(field_info.annotation)
  1187. if not next_model:
  1188. return None
  1189. current_model = next_model
  1190. @staticmethod
  1191. def _normalize_sheet_name(name:str) -> str:
  1192. return (name or "").strip().lower()
  1193. @classmethod
  1194. def _stringify_row(cls, row:List) -> List[str]:
  1195. normalized = []
  1196. for value in row:
  1197. if value is None:
  1198. normalized.append("")
  1199. elif isinstance(value, (int, float, bool, str)):
  1200. normalized.append(str(value))
  1201. else:
  1202. normalized.append(str(value))
  1203. return normalized
  1204. @staticmethod
  1205. def _get_logger() -> Optional[Logger]:
  1206. try:
  1207. return LogUtil.logger
  1208. except Exception:
  1209. return None
  1210. class LogUtil:
  1211. @classproperty
  1212. def logger(cls) -> Logger:
  1213. """
  1214. 获取日志对象
  1215. Returns:
  1216. logging.Logger: 日志对象
  1217. """
  1218. from flask import current_app
  1219. app = current_app
  1220. return app.logger
  1221. def get_final_type(annotation) -> Type:
  1222. args = get_args(annotation)
  1223. if isinstance(args, tuple) and len(args) > 1:
  1224. arg0 = args[0]
  1225. if isinstance(arg0, Type):
  1226. return arg0
  1227. else:
  1228. return get_final_type(arg0)
  1229. def get_final_model(annotation) -> Optional[type]:
  1230. origin = get_origin(annotation)
  1231. if origin is None:
  1232. return annotation
  1233. else:
  1234. args = get_args(annotation)
  1235. for arg in args:
  1236. return get_final_model(arg)
  1237. else:
  1238. return None