config_api.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. import os.path
  2. from fastapi import APIRouter, File, Body, HTTPException, status
  3. from fastapi.responses import FileResponse, JSONResponse
  4. from ..core.config import settings
  5. import datetime
  6. import json
  7. from app.core.logger import get_logger
  8. logger = get_logger(__name__)
  9. router = APIRouter(tags=["Config"])
  10. def compare_json_structure(template: dict, data: dict, path: str = "") -> (bool, str):
  11. """
  12. 递归比较两个字典的结构(键),忽略值。
  13. 如果结构不匹配,返回 False 和错误原因。
  14. :param template: 模板字典(原始配置)
  15. :param data: 新的字典(待验证的配置)
  16. :param path: 用于错误报告的当前递归路径
  17. :return: 一个元组 (is_match: bool, reason: str)
  18. """
  19. template_keys = set(template.keys())
  20. data_keys = set(data.keys())
  21. # 检查当前层的键是否完全匹配
  22. if template_keys != data_keys:
  23. missing_keys = template_keys - data_keys
  24. extra_keys = data_keys - template_keys
  25. error_msg = f"结构不匹配。在路径 '{path or 'root'}' "
  26. if missing_keys:
  27. error_msg += f"缺少字段: {missing_keys}。"
  28. if extra_keys:
  29. error_msg += f"存在多余字段: {extra_keys}。"
  30. return False, error_msg
  31. # 递归检查所有子字典
  32. for key in template_keys:
  33. if isinstance(template[key], dict) and isinstance(data[key], dict):
  34. # 如果两个值都是字典,则递归深入
  35. is_match, reason = compare_json_structure(template[key], data[key], path=f"{path}.{key}" if path else key)
  36. if not is_match:
  37. return False, reason
  38. elif isinstance(template[key], dict) or isinstance(data[key], dict):
  39. # 如果只有一个是字典,说明结构已改变(例如,一个对象被替换成了字符串)
  40. current_path = f"{path}.{key}" if path else key
  41. return False, f"结构不匹配。字段 '{current_path}' 的类型已从字典变为非字典(或反之)。"
  42. return True, "结构匹配"
  43. def validate_rule_ranges(data: dict, path: str = "") -> (bool, str):
  44. """
  45. 递归检查配置中看起来像评分规则的列表。
  46. 规则:
  47. 1. 列表中的元素必须包含 min, max, deduction。
  48. 2. 第一个元素的 min 必须为 0。
  49. 3. 当前元素的 min 必须等于上一个元素的 max。
  50. """
  51. if isinstance(data, dict):
  52. for key, value in data.items():
  53. is_valid, error = validate_rule_ranges(value, path=f"{path}.{key}" if path else key)
  54. if not is_valid:
  55. return False, error
  56. elif isinstance(data, list):
  57. # 检查这是否是一个规则列表(通过检查第一个元素是否包含特定key)
  58. if len(data) > 0 and isinstance(data[0], dict) and "min" in data[0] and "max" in data[0] and "deduction" in \
  59. data[0]:
  60. current_path = path
  61. # 1. 检查首项 min 是否为 0
  62. first_min = data[0].get("min")
  63. if first_min != 0:
  64. return False, f"范围错误 [{current_path}]: 第一个区间的 min 必须为 0,当前为 {first_min}"
  65. # 2. 检查连续性
  66. prev_max = None
  67. for idx, rule in enumerate(data):
  68. current_min = rule.get("min")
  69. current_max = rule.get("max")
  70. # 处理 inf 字符串
  71. if current_max == "inf":
  72. current_max_val = float("inf")
  73. else:
  74. try:
  75. current_max_val = float(current_max)
  76. except (ValueError, TypeError):
  77. return False, f"数值错误 [{current_path}]: max 值 '{current_max}' 无效"
  78. try:
  79. current_min_val = float(current_min)
  80. except (ValueError, TypeError):
  81. return False, f"数值错误 [{current_path}]: min 值 '{current_min}' 无效"
  82. # 检查连续性 (除了第一个元素)
  83. if idx > 0:
  84. # 使用一个微小的容差处理浮点数比较,或者直接相等
  85. if prev_max == "inf":
  86. return False, f"逻辑错误 [{current_path}]: 'inf' 只能出现在最后一个区间的 max"
  87. prev_max_val = float(prev_max) if prev_max != "inf" else float("inf")
  88. if current_min_val != prev_max_val:
  89. return False, f"不连续错误 [{current_path}]: 第 {idx + 1} 项的 min ({current_min}) 不等于上一项的 max ({prev_max})"
  90. prev_max = current_max
  91. return True, "验证通过"
  92. @router.get("/scoring_config", summary="获取评分配置")
  93. async def get_scoring_config():
  94. """
  95. 读取并返回 scoring_config.json 文件的内容。
  96. """
  97. if not settings.SCORE_CONFIG_PATH.exists():
  98. logger.error(f"评分配置文件未找到: {settings.SCORE_CONFIG_PATH}")
  99. raise HTTPException(
  100. status_code=status.HTTP_404_NOT_FOUND,
  101. detail="评分配置文件未找到"
  102. )
  103. try:
  104. with open(settings.SCORE_CONFIG_PATH, 'r', encoding='utf-8') as f:
  105. config_data = json.load(f)
  106. return config_data
  107. except json.JSONDecodeError:
  108. logger.error(f"评分配置文件格式错误,无法解析: {settings.SCORE_CONFIG_PATH}")
  109. raise HTTPException(
  110. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  111. detail="评分配置文件格式错误"
  112. )
  113. except Exception as e:
  114. logger.error(f"读取配置文件时发生未知错误: {e}")
  115. raise HTTPException(
  116. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  117. detail=f"读取配置文件时发生未知错误: {e}"
  118. )
  119. @router.put("/scoring_config", summary="更新评分配置")
  120. async def update_scoring_config(new_config: dict = Body(...)):
  121. """
  122. 接收新的JSON配置,验证结构一致性以及分值范围的连续性(min=prev_max)
  123. """
  124. # 1. 检查并读取当前的配置文件作为模板
  125. if not settings.SCORE_CONFIG_PATH.exists():
  126. # 如果文件不存在,可能无法进行结构对比,视情况处理,这里假设必须存在
  127. raise HTTPException(status_code=404, detail="原始配置文件未找到")
  128. try:
  129. with open(settings.SCORE_CONFIG_PATH, 'r', encoding='utf-8') as f:
  130. current_config = json.load(f)
  131. except Exception as e:
  132. raise HTTPException(status_code=500, detail=f"读取原始配置文件失败: {e}")
  133. # 2. 比较新旧配置的结构 (Key的一致性)
  134. is_structure_valid, struct_reason = compare_json_structure(current_config, new_config)
  135. if not is_structure_valid:
  136. logger.warning(f"结构校验警告: {struct_reason}")
  137. # raise HTTPException(status_code=400, detail=struct_reason)
  138. # 3. **验证数值范围的连续性**
  139. is_range_valid, range_reason = validate_rule_ranges(new_config)
  140. if not is_range_valid:
  141. logger.warning(f"数值范围校验失败: {range_reason}")
  142. raise HTTPException(
  143. status_code=status.HTTP_400_BAD_REQUEST,
  144. detail=f"配置数值逻辑错误: {range_reason}"
  145. )
  146. # 4. 写入新文件
  147. try:
  148. with open(settings.SCORE_CONFIG_PATH, 'w', encoding='utf-8') as f:
  149. json.dump(new_config, f, indent=2, ensure_ascii=False)
  150. logger.info("评分配置文件已成功更新。")
  151. logger.info("保存备份")
  152. formatted_time = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
  153. backup_name = f"scors_backup_{formatted_time}.json"
  154. temp_path = os.path.join(settings.SCORE_BACKUP_PATH, backup_name)
  155. with open(temp_path, 'w', encoding='utf-8') as f:
  156. json.dump(new_config, f, indent=2, ensure_ascii=False)
  157. logger.info("配置备份结束")
  158. return JSONResponse(
  159. status_code=status.HTTP_200_OK,
  160. content={"message": "配置已成功更新"}
  161. )
  162. except Exception as e:
  163. logger.error(f"写入新配置文件时发生错误: {e}")
  164. raise HTTPException(
  165. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  166. detail=f"保存新配置文件时发生错误: {e}"
  167. )