config_api.py 7.8 KB

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