lesscode-flask 0.2.62__tar.gz → 0.2.89__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/PKG-INFO +1 -1
  2. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/__init__.py +15 -1
  3. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/app.py +72 -3
  4. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/db/__init__.py +9 -4
  5. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/log/access_log_handler.py +8 -1
  6. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/model/access_log.py +3 -0
  7. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/setting/__init__.py +24 -0
  8. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/setup/__init__.py +298 -8
  9. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/limit/req_count/count_limiter_handler.py +16 -16
  10. lesscode_flask-0.2.89/lesscode_flask/utils/sign/__init__.py +3 -0
  11. lesscode_flask-0.2.89/lesscode_flask/utils/sign/signature.py +160 -0
  12. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/swagger/swagger_util.py +113 -5
  13. lesscode_flask-0.2.89/lesscode_flask/utils/task/__init__.py +0 -0
  14. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask.egg-info/PKG-INFO +1 -1
  15. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask.egg-info/SOURCES.txt +3 -0
  16. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/README.md +0 -0
  17. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/db/datasource.py +0 -0
  18. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/db/executor.py +0 -0
  19. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/export_data/__init__.py +0 -0
  20. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/export_data/data_download_handler.py +0 -0
  21. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/model/base_model.py +0 -0
  22. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/model/parameterized_query.py +0 -0
  23. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/model/resource_param_template.py +0 -0
  24. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/model/response_result.py +0 -0
  25. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/model/user.py +0 -0
  26. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/model/user_limit_policy.py +0 -0
  27. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/service/access_log_service.py +0 -0
  28. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/service/base_service.py +0 -0
  29. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/service/resource_param_template_service.py +0 -0
  30. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/signals.py +0 -0
  31. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/static/swagger.py +0 -0
  32. {lesscode_flask-0.2.62/lesscode_flask/utils/decorator → lesscode_flask-0.2.89/lesscode_flask/utils}/__init__.py +0 -0
  33. {lesscode_flask-0.2.62/lesscode_flask/utils/task → lesscode_flask-0.2.89/lesscode_flask/utils/decorator}/__init__.py +0 -0
  34. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/decorator/cache.py +0 -0
  35. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/decorator/sql_injection.py +0 -0
  36. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/decorator/swagger.py +0 -0
  37. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/dify_utils.py +0 -0
  38. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/file/file_exporter.py +0 -0
  39. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/file/file_utils.py +0 -0
  40. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/fs_util.py +0 -0
  41. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/helpers.py +0 -0
  42. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/json/NotSortJSONProvider.py +0 -0
  43. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/limit/__init__.py +0 -0
  44. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/limit/consecutive/consecutive_limiter_handler.py +0 -0
  45. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/limit/consecutive/redis_consecutive_limiter.py +0 -0
  46. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/limit/limit_util.py +0 -0
  47. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/limit/req/rate_limiter_handler.py +0 -0
  48. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/limit/req/redis_rate_limiter.py +0 -0
  49. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/limit/req_count/redis_count_limiter.py +0 -0
  50. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/oss/__init__.py +0 -0
  51. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/oss/aliyun_oss.py +0 -0
  52. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/oss/ks3_oss.py +0 -0
  53. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/oss/minio_oss.py +0 -0
  54. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/redis/redis_helper.py +0 -0
  55. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/request/request.py +0 -0
  56. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/swagger/swagger_template.py +0 -0
  57. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/task/task_helper.py +0 -0
  58. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/thread/thread_utils.py +0 -0
  59. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/wsgi.py +0 -0
  60. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask.egg-info/dependency_links.txt +0 -0
  61. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask.egg-info/requires.txt +0 -0
  62. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask.egg-info/top_level.txt +0 -0
  63. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/query_runner/__init__.py +0 -0
  64. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/query_runner/clickhouse.py +0 -0
  65. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/query_runner/elasticsearch.py +0 -0
  66. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/query_runner/kingbase.py +0 -0
  67. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/query_runner/mysql.py +0 -0
  68. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/query_runner/pg.py +0 -0
  69. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/settings/__init__.py +0 -0
  70. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/settings/helpers.py +0 -0
  71. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/utils/__init__.py +0 -0
  72. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/utils/requests_session.py +0 -0
  73. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/setup.cfg +0 -0
  74. {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lesscode-flask
3
- Version: 0.2.62
3
+ Version: 0.2.89
4
4
  Summary: lesscode-flask 是基于flask的web开发脚手架项目,该项目初衷为简化开发过程,让研发人员更加关注业务。
5
5
  Home-page: https://lesscode-flask
6
6
  Author: Chao.yy
@@ -1,4 +1,4 @@
1
- __version__ = "0.2.62"
1
+ __version__ = "0.2.89"
2
2
 
3
3
  import functools
4
4
  import logging
@@ -16,6 +16,20 @@ class SQ_Blueprint(Blueprint):
16
16
  if not kwargs.get("import_name"):
17
17
  kwargs["import_name"] = __name__
18
18
  super().__init__(name=name, url_prefix=url_prefix, **kwargs)
19
+ # 记录蓝图内显式注册的“路径 + 方法”,供初始化阶段做冲突过滤。
20
+ self._route_method_keys = set()
21
+
22
+ def add_url_rule(self, rule, endpoint=None, view_func=None, provide_automatic_options=None, **options):
23
+ methods = options.get("methods")
24
+ if methods is None:
25
+ methods = ["GET"]
26
+ elif isinstance(methods, str):
27
+ methods = [methods]
28
+ methods = [str(item).upper() for item in methods if item]
29
+ for method in methods:
30
+ self._route_method_keys.add(f"{rule}|{method}")
31
+ return super().add_url_rule(rule, endpoint=endpoint, view_func=view_func,
32
+ provide_automatic_options=provide_automatic_options, **options)
19
33
 
20
34
  def decorator_handler(
21
35
  self,
@@ -18,7 +18,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
18
18
  from lesscode_flask.model.response_result import ResponseResult
19
19
  from lesscode_flask.setup import setup_blueprint, setup_logging, setup_query_runner, setup_swagger, setup_sql_alchemy, \
20
20
  setup_redis, setup_login_manager, setup_resource_register, setup_data_download, setup_scheduler, \
21
- setup_common_resource, setup_task
21
+ setup_common_resource, setup_task, setup_api_blueprint, prepare_api_blueprint_routes
22
22
  from lesscode_flask.signals import app_runed
23
23
  from lesscode_flask.utils.decorator.sql_injection import contains_sql_injection
24
24
  from lesscode_flask.utils.helpers import inject_args, generate_uuid, app_config
@@ -31,6 +31,7 @@ from lesscode_flask.utils.limit.req.rate_limiter_handler import RateLimitHandler
31
31
  from lesscode_flask.utils.limit.req_count.count_limiter_handler import CountLimitHandler
32
32
  from lesscode_flask.utils.limit.req_count.redis_count_limiter import RedisCountLimiter
33
33
  from lesscode_flask.utils.redis.redis_helper import RedisHelper
34
+ from lesscode_flask.utils.sign import verify_request_signature
34
35
  from lesscode_flask.utils.swagger.swagger_util import generate_openapi_spec
35
36
 
36
37
  # import collections.abc as cabc
@@ -74,6 +75,7 @@ class Lesscoder(Flask):
74
75
  self.consecutiveAccessLimiter = RedisConsecutiveAccessLimiter(self.config.get("REDIS_LIMIT_KEY", "redis"))
75
76
 
76
77
  def preprocess_request(self) -> ft.ResponseReturnValue | None:
78
+ verify_request_signature(self)
77
79
  v = super(Lesscoder, self).preprocess_request()
78
80
  user_limit_policy = None
79
81
  if hasattr(self, 'countLimiter') and self.countLimiter:
@@ -164,7 +166,9 @@ class Lesscoder(Flask):
164
166
  params_dict.update(view_args)
165
167
  SQL_INJECTION_ENABLE = self.config.get("SQL_INJECTION_ENABLE", False)
166
168
  if SQL_INJECTION_ENABLE:
167
- if contains_sql_injection(params_dict):
169
+ sql_injection_white_list = self.config.get("SQL_INJECTION_WHITE_LIST", []) or []
170
+ is_sql_injection_white_path = any(req.path.startswith(path) for path in sql_injection_white_list)
171
+ if not is_sql_injection_white_path and contains_sql_injection(params_dict):
168
172
  ResponseResult.fail("参数包含非法字符,请调整后重试!", status_code="403", http_code="403")
169
173
  # 调用处理函数执行请求处理
170
174
  result = self.ensure_sync(func)(**params_dict)
@@ -183,15 +187,80 @@ class Lesscoder(Flask):
183
187
  return result
184
188
 
185
189
  def setup(self):
190
+ def _collect_blueprint_route_method_keys(blueprint_map):
191
+ # 从项目内已定义的蓝图中提取“完整路径 + 方法”,用于后续排除同名自动接口。
192
+ route_method_keys = set()
193
+ for blueprint in blueprint_map.values():
194
+ prefix = (getattr(blueprint, "url_prefix", "") or "").rstrip("/")
195
+ # 优先使用 SQ_Blueprint 记录的路由元数据,避免依赖 Flask 闭包结构。
196
+ blueprint_keys = getattr(blueprint, "_route_method_keys", None)
197
+ if isinstance(blueprint_keys, set) and blueprint_keys:
198
+ for key in blueprint_keys:
199
+ if "|" not in key:
200
+ continue
201
+ route, method = key.rsplit("|", 1)
202
+ full_route = f"{prefix}{route}" if prefix else route
203
+ if not full_route.startswith("/"):
204
+ full_route = f"/{full_route}"
205
+ full_route = full_route.replace("//", "/")
206
+ route_method_keys.add(f"{full_route}|{str(method).upper()}")
207
+ continue
208
+
209
+ # 兜底方案:兼容普通 Blueprint 或未记录元数据的蓝图。
210
+ deferred_functions = getattr(blueprint, "deferred_functions", []) or []
211
+ for deferred in deferred_functions:
212
+ closure = getattr(deferred, "__closure__", None) or []
213
+ route = None
214
+ methods = None
215
+ for cell in closure:
216
+ value = cell.cell_contents
217
+ if isinstance(value, str) and value.startswith("/"):
218
+ route = value
219
+ elif isinstance(value, dict) and isinstance(value.get("methods"), (list, tuple, set)):
220
+ methods = [str(item).upper() for item in value.get("methods") if item]
221
+ if not route:
222
+ continue
223
+ full_route = f"{prefix}{route}" if prefix else route
224
+ if not full_route.startswith("/"):
225
+ full_route = f"/{full_route}"
226
+ full_route = full_route.replace("//", "/")
227
+ methods = methods or ["GET"]
228
+ for method in methods:
229
+ route_method_keys.add(f"{full_route}|{method}")
230
+ return route_method_keys
231
+
186
232
  setup_logging(self)
233
+ # 1) 先从能力平台准备自动蓝图路由计划(仅组织数据,不立即注册)。
234
+ api_capability_server, api_route_prefix, api_route_list = prepare_api_blueprint_routes(self)
235
+ # 1) 先收集项目代码中的蓝图定义。
187
236
  blueprint_map = setup_blueprint(self)
237
+ # 2) 用项目接口签名作为排除条件,确保同路径同方法时由项目接口覆盖自动接口。
238
+ project_route_method_keys = _collect_blueprint_route_method_keys(blueprint_map)
239
+ auto_blueprints = setup_api_blueprint(
240
+ self,
241
+ exclude_route_method_keys=project_route_method_keys,
242
+ existing_blueprint_names=set(blueprint_map.keys()),
243
+ route_list=api_route_list,
244
+ capability_server=api_capability_server,
245
+ route_prefix=api_route_prefix
246
+ )
188
247
  setup_query_runner()
189
248
  setup_swagger(self)
190
249
  setup_sql_alchemy(self)
191
250
  setup_redis(self)
192
251
  setup_login_manager(self)
252
+ # 3) 先注册自动蓝图,再注册项目蓝图;同时自动蓝图已做冲突过滤,保证项目实现优先。
253
+ if auto_blueprints:
254
+ for auto_blueprint in auto_blueprints:
255
+ self.register_blueprint(
256
+ auto_blueprint,
257
+ name=getattr(auto_blueprint, "_registration_name", auto_blueprint.name)
258
+ )
193
259
  for blueprint_name, blueprint in blueprint_map.items():
194
- self.register_blueprint(blueprint)
260
+ self.register_blueprint(
261
+ blueprint,
262
+ name=getattr(blueprint, "_registration_name", blueprint_name)
263
+ )
195
264
  setup_common_resource(self)
196
265
  setup_resource_register(self)
197
266
  setup_data_download(self)
@@ -26,13 +26,13 @@ def execute_query(
26
26
  query_text = query.query
27
27
  # query_text = query_runner.apply_auto_limit(query.text, should_apply_auto_limit)
28
28
  start_time = time.perf_counter()
29
- data = QueryExecutor(
29
+ data = QueryExecutor(
30
30
  query_text,
31
31
  query_runner
32
32
  ).run()
33
33
  end_time = time.perf_counter()
34
34
  elapsed_time = end_time - start_time
35
- logging.debug(f"query_text:\n{query_text}\nquery_time: {elapsed_time:.6f}s")
35
+ logging.info(f"query_text:\n{query_text}\nquery_time: {elapsed_time:.6f}s")
36
36
  return data
37
37
  except Exception as e:
38
38
  # models.db.session.rollback()
@@ -56,13 +56,18 @@ def execute(query_text, parameters, query_runner, should_apply_auto_limit=True):
56
56
  query.apply(parameters)
57
57
  query_text = query.query
58
58
  # query_text = query_runner.apply_auto_limit(query.text, should_apply_auto_limit)
59
- logging.info("query_text:{}".format(query_text))
60
- return QueryExecutor(
59
+ start_time = time.perf_counter()
60
+ data = QueryExecutor(
61
61
  query_text,
62
62
  query_runner
63
63
  ).exec()
64
+ end_time = time.perf_counter()
65
+ elapsed_time = end_time - start_time
66
+ logging.info(f"execute_text:\n{query_text}\nexecute_time: {elapsed_time:.6f}s")
67
+ return data
64
68
  except Exception as e:
65
69
  # models.db.session.rollback()
70
+ logging.error("error_execute_text:\n{}".format(query_text))
66
71
  raise e
67
72
 
68
73
 
@@ -45,6 +45,12 @@ class AccessLogHandler(Handler):
45
45
  app_key = request.headers.get("App-Key")
46
46
  url = request.path
47
47
  url_info_key = f"upms:url_info:{url}"
48
+ # 签名
49
+ signature = request.headers.get("signature") or request.headers.get('Signature')
50
+ # 随机数
51
+ nonce = request.headers.get("nonce") or request.headers.get('Nonce')
52
+ # 时间戳
53
+ timestamp = request.headers.get("timestamp") or request.headers.get('Timestamp')
48
54
 
49
55
  resource_id = "-"
50
56
  resource_label = url
@@ -66,7 +72,8 @@ class AccessLogHandler(Handler):
66
72
  obj_id=current_user.id, type=current_user.type, client_id=client_id,
67
73
  resource_id=resource_id, location=location, sub=current_user.sub,
68
74
  resource_label=resource_label, url=url, referrer=referrer, client_ip=client_ip,
69
- user_agent=user_agent_string, token=token, app_key=app_key, start_time=start_time,
75
+ user_agent=user_agent_string, token=token, app_key=app_key, signature=signature,
76
+ nonce=nonce, timestamp=timestamp, start_time=start_time,
70
77
  end_time=end_time, duration=end_time - start_time, status_code=status_code,
71
78
  params=params)
72
79
 
@@ -24,6 +24,9 @@ class AccessLog(BaseModel):
24
24
  user_agent = Column(String(512), comment='客户端')
25
25
  token = Column(String(64), comment='token')
26
26
  app_key = Column(String(64), comment='请求的app_key')
27
+ signature = Column(String(64), comment='签名')
28
+ nonce = Column(String(64), comment='签名随机数')
29
+ timestamp = Column(String(64), comment='签名时间戳')
27
30
  params = Column(JSONEncodedDict)
28
31
  start_time = Column(DOUBLE, comment='开始时间')
29
32
  end_time = Column(DOUBLE, comment='结束时间')
@@ -90,6 +90,22 @@ class BaseConfig:
90
90
  AUTH_DEFAULT_ACCESS = 0
91
91
  # 启用生成刷新token
92
92
  OAUTH2_REFRESH_TOKEN_GENERATOR = True
93
+ # 请求签名校验开关(在token校验前执行)
94
+ SIGN_ENABLE: bool = False
95
+ # 固定签名密钥,建议在业务配置中覆盖
96
+ SIGN_SECRET: str = ""
97
+ # 签名有效时间窗口(秒)
98
+ SIGN_WINDOW_SEC: int = 300
99
+ # 防重放校验开关
100
+ SIGN_NONCE_ENABLE: bool = True
101
+ # nonce缓存时长(秒)
102
+ SIGN_NONCE_TTL_SEC: int = 300
103
+ # 签名白名单路径前缀
104
+ SIGN_WHITE_LIST: list = [SWAGGER_URL, SWAGGER_API_URL, f"{ROUTE_PREFIX}/oauth/token", f"{ROUTE_PREFIX}/oauth/captcha"]
105
+ # 签名IP白名单(支持单IP或CIDR,命中后跳过签名校验)
106
+ SIGN_IP_WHITE_LIST: list = []
107
+ # 忽略签名校验的方法
108
+ SIGN_IGNORE_METHODS: list = ["OPTIONS"]
93
109
  # 是否启用限流频率验证
94
110
  RATE_LIMIT_ENABLE: bool = False
95
111
 
@@ -132,6 +148,8 @@ class BaseConfig:
132
148
  FS_OAM_SERVICE_URL = "https://oa.shangqi.com.cn"
133
149
  # sql 注入验证器开关
134
150
  SQL_INJECTION_ENABLE = False
151
+ # sql 注入检测白名单,命中路径前缀时跳过检测
152
+ SQL_INJECTION_WHITE_LIST: list = []
135
153
  #
136
154
  #
137
155
  # # 外网地址
@@ -141,6 +159,9 @@ class BaseConfig:
141
159
  #
142
160
  # 数据服务
143
161
  CAPABILITY_PLATFORM_SERVER: str = "http://127.0.0.1:8976"
162
+ # 转调能力平台时附加的自定义请求头(key/value 由业务配置覆盖)
163
+ DATA_SOURCE_KEY: str = "Data-Source-Id"
164
+ DATA_SOURCE_VALUE: str = ""
144
165
  # # 权限服务地址
145
166
  # OAUTH_SERVER: str = ""
146
167
  # # 后端管理地址
@@ -165,6 +186,9 @@ class BaseConfig:
165
186
  REGISTER_ENABLE = False
166
187
  REGISTER_SERVER = "http://127.0.0.1:8976"
167
188
 
189
+ # 通过固定 API 拉取地址并自动注册蓝图(默认关闭)
190
+ AUTO_BLUEPRINT_ENABLE = False
191
+
168
192
  # 本地环境
169
193
  ENV = "local"
170
194
  # 是否代理能力平台的公共接口
@@ -73,7 +73,7 @@ def setup_logging(app):
73
73
  logging.getLogger().addHandler(access_log_handler)
74
74
 
75
75
 
76
- def setup_blueprint(app, path=None, pkg_name="handlers", blueprint_map={}):
76
+ def setup_blueprint(app, path=None, pkg_name="handlers", blueprint_map=None):
77
77
  import os
78
78
  from flask import Blueprint
79
79
  import inspect
@@ -83,6 +83,17 @@ def setup_blueprint(app, path=None, pkg_name="handlers", blueprint_map={}):
83
83
  :param path: 项目内Handler的文件路径
84
84
  :param pkg_name: 引入模块前缀
85
85
  """
86
+ if blueprint_map is None:
87
+ blueprint_map = {}
88
+ seen_blueprint_ids = {id(blueprint) for blueprint in blueprint_map.values()}
89
+
90
+ def _alloc_blueprint_name(blueprint_name):
91
+ if blueprint_name not in blueprint_map:
92
+ return blueprint_name
93
+ index = 2
94
+ while f"{blueprint_name}-{index}" in blueprint_map:
95
+ index += 1
96
+ return f"{blueprint_name}-{index}"
86
97
  if path is None:
87
98
  # 项目内Handler的文件路径,使用当前工作目录作为根
88
99
  path = os.path.join(os.getcwd(), pkg_name)
@@ -102,17 +113,296 @@ def setup_blueprint(app, path=None, pkg_name="handlers", blueprint_map={}):
102
113
  for name, obj in inspect.getmembers(module):
103
114
  # 找到Blueprint 的属性进行注册
104
115
  if isinstance(obj, Blueprint):
105
- # 如果有配置统一前缀则作为蓝图路径的统一前缀
106
- blueprint_name = obj.name
107
- if blueprint_name in blueprint_map:
116
+ # 同一个蓝图对象可能以多个变量名暴露,按对象身份去重,避免重复注册。
117
+ if id(obj) in seen_blueprint_ids:
108
118
  continue
109
- else:
110
- if hasattr(obj, "url_prefix") and app.config.get("ROUTE_PREFIX", ""):
111
- obj.url_prefix = f'{app.config.get("ROUTE_PREFIX")}{obj.url_prefix}'
112
- blueprint_map[blueprint_name] = obj
119
+ # 如果有配置统一前缀则作为蓝图路径的统一前缀
120
+ blueprint_name = _alloc_blueprint_name(obj.name)
121
+ # 为蓝图保存唯一注册名,真正注册时通过 register_blueprint(name=...) 指定。
122
+ setattr(obj, "_registration_name", blueprint_name)
123
+ if hasattr(obj, "url_prefix") and app.config.get("ROUTE_PREFIX", ""):
124
+ obj.url_prefix = f'{app.config.get("ROUTE_PREFIX")}{obj.url_prefix}'
125
+ blueprint_map[blueprint_name] = obj
126
+ seen_blueprint_ids.add(id(obj))
113
127
  return blueprint_map
114
128
 
115
129
 
130
+ def _normalize_route(raw_route):
131
+ if not raw_route:
132
+ return None
133
+ route = str(raw_route).strip()
134
+ if not route.startswith("/"):
135
+ route = f"/{route}"
136
+ return route.replace("{", "<").replace("}", ">")
137
+
138
+
139
+ def _normalize_route_prefix(route_prefix):
140
+ route_prefix = (route_prefix or "").strip()
141
+ if route_prefix and not route_prefix.startswith("/"):
142
+ route_prefix = f"/{route_prefix}"
143
+ if route_prefix.endswith("/") and route_prefix != "/":
144
+ route_prefix = route_prefix.rstrip("/")
145
+ return route_prefix
146
+
147
+
148
+ def _map_local_route(route, route_prefix):
149
+ if not route_prefix:
150
+ return route
151
+ if route == "/icp":
152
+ return route_prefix
153
+ if route.startswith("/icp/"):
154
+ return f"{route_prefix}{route[4:]}"
155
+ return route
156
+
157
+
158
+ def _build_target_url(capability_server, route):
159
+ return f"{capability_server}/{route.lstrip('/')}"
160
+
161
+
162
+ def _normalize_methods(raw_methods, supported_methods):
163
+ if isinstance(raw_methods, str):
164
+ methods = [raw_methods.upper()]
165
+ elif isinstance(raw_methods, list):
166
+ methods = [str(item).upper() for item in raw_methods]
167
+ else:
168
+ methods = ["POST"]
169
+ return [item for item in methods if item in supported_methods] or ["POST"]
170
+
171
+
172
+ def _build_proxy_view(app, target_url, title, description=None, dynamic_params=None):
173
+ # 生成一个代理视图:把当前请求透明转发到能力平台目标地址。
174
+ def _proxy_view(**path_params):
175
+ url = target_url
176
+ for key, value in path_params.items():
177
+ value = str(value)
178
+ url = url.replace(f"<{key}>", value).replace(f"{{{key}}}", value)
179
+
180
+ headers = {}
181
+ for key, value in request.headers:
182
+ if key.lower() not in {"host", "content-length"}:
183
+ headers[key] = value
184
+ # 转调时按配置补充自定义请求头,用于能力平台鉴权/路由。
185
+ data_source_key = app.config.get("DATA_SOURCE_KEY")
186
+ data_source_value = app.config.get("DATA_SOURCE_VALUE")
187
+ if data_source_key and data_source_value is not None:
188
+ headers[str(data_source_key)] = str(data_source_value)
189
+
190
+ resp = requests.request(
191
+ method=request.method,
192
+ url=url,
193
+ params=request.args.to_dict(flat=False),
194
+ data=request.get_data(),
195
+ headers=headers,
196
+ timeout=30,
197
+ )
198
+
199
+ excluded_headers = {"content-encoding", "content-length", "transfer-encoding", "connection"}
200
+ response_headers = [(k, v) for k, v in resp.headers.items() if k.lower() not in excluded_headers]
201
+ return Response(resp.content, status=resp.status_code, headers=response_headers)
202
+
203
+ _proxy_view._title = title
204
+ if description is not None:
205
+ _proxy_view.__doc__ = str(description)
206
+ _proxy_view._dynamic_params = list(dynamic_params or [])
207
+ _proxy_view.__name__ = f"auto_proxy_{uuid.uuid1().hex}"
208
+ return _proxy_view
209
+
210
+
211
+ def _fetch_api_tree_data(app, source_url):
212
+ try:
213
+ source_payload = {}
214
+ source_client_id = app.config.get("CLIENT_ID")
215
+ if source_client_id is not None:
216
+ source_payload["client_id"] = source_client_id
217
+ source_resp = requests.request(method="POST", url=source_url, json=source_payload, timeout=10)
218
+ source_resp.raise_for_status()
219
+ source_data = source_resp.json()
220
+ except Exception as e:
221
+ app.logger.exception("动态蓝图拉取失败,source_url=%s,error=%s", source_url, str(e))
222
+ return None, set(), {}
223
+
224
+ data_payload = source_data.get("data", {}) if isinstance(source_data, dict) else {}
225
+ if not isinstance(data_payload, dict):
226
+ app.logger.warning("动态蓝图数据格式错误:data 不是 dict")
227
+ return None, set(), {}
228
+ if str(source_data.get("status", "")) not in {"00000", "0"}:
229
+ app.logger.warning("动态蓝图接口返回失败:status=%s, message=%s",
230
+ source_data.get("status"), source_data.get("message"))
231
+ return None, set(), {}
232
+
233
+ tree_data = data_payload.get("tree", [])
234
+ selected_ids = data_payload.get("selected", [])
235
+ resource_params = data_payload.get("resource_params", {}) or {}
236
+ if not isinstance(tree_data, list):
237
+ app.logger.warning("动态蓝图数据格式错误:data.tree 不是 list")
238
+ return None, set(), {}
239
+ if not isinstance(selected_ids, list):
240
+ selected_ids = []
241
+ if not isinstance(resource_params, dict):
242
+ resource_params = {}
243
+ return tree_data, {str(item) for item in selected_ids if item}, resource_params
244
+
245
+
246
+ def _flatten_tree_by_parent(node_list, permission_ids, resource_params=None, ancestors=None, result=None):
247
+ # 从树结构中提取最后一级接口,并按“根节点到父节点路径”分组。
248
+ if result is None:
249
+ result = []
250
+ if ancestors is None:
251
+ ancestors = []
252
+ for node in node_list:
253
+ if not isinstance(node, dict):
254
+ continue
255
+ node_id = str(node.get("id", "")).strip()
256
+ node_label = node.get("label") or node.get("title") or node.get("name") or "动态接口"
257
+ children = node.get("children", [])
258
+ has_children = isinstance(children, list) and len(children) > 0
259
+ url = node.get("url")
260
+ method = node.get("method") or node.get("request_method") or "POST"
261
+ title = node_label
262
+ description = node.get("description") or node.get("desc") or node.get("remark") or ""
263
+
264
+ # 仅将“有权限且为叶子节点”的接口加入结果,蓝图名由 ancestors 路径拼接而成。
265
+ if url and node_id and node_id in permission_ids and not has_children:
266
+ if ancestors:
267
+ group_label = "-".join([str(item).strip() for item in ancestors if str(item).strip()])
268
+ else:
269
+ group_label = "动态接口"
270
+ group_id = " > ".join([str(item).strip() for item in ancestors if str(item).strip()]) or "root"
271
+ result.append({
272
+ "resource_id": node_id,
273
+ "group_id": group_id,
274
+ "group_label": group_label or "动态接口",
275
+ "url": url,
276
+ "method": method,
277
+ "title": title,
278
+ "description": description,
279
+ "dynamic_params": list(resource_params.get(node_id, []) if isinstance(resource_params, dict) else [])
280
+ })
281
+
282
+ if has_children:
283
+ next_ancestors = list(ancestors)
284
+ if node_label:
285
+ next_ancestors.append(node_label)
286
+ _flatten_tree_by_parent(children, permission_ids, resource_params=resource_params,
287
+ ancestors=next_ancestors, result=result)
288
+ return result
289
+
290
+
291
+ def _group_routes(route_list):
292
+ grouped_routes = {}
293
+ for item in route_list:
294
+ grouped_routes.setdefault(item.get("group_id"), {
295
+ "label": item.get("group_label") or "动态接口",
296
+ "routes": []
297
+ })
298
+ grouped_routes[item.get("group_id")]["routes"].append(item)
299
+ return grouped_routes
300
+
301
+
302
+ def _alloc_blueprint_name(group_label, used_blueprint_names):
303
+ # 如果蓝图名重复,则自动追加 -2、-3...
304
+ base_name = str(group_label or "动态接口").strip() or "动态接口"
305
+ if base_name not in used_blueprint_names:
306
+ used_blueprint_names.add(base_name)
307
+ return base_name
308
+ index = 2
309
+ while f"{base_name}-{index}" in used_blueprint_names:
310
+ index += 1
311
+ unique_name = f"{base_name}-{index}"
312
+ used_blueprint_names.add(unique_name)
313
+ return unique_name
314
+
315
+
316
+ def prepare_api_blueprint_routes(app):
317
+ if not app.config.get("AUTO_BLUEPRINT_ENABLE", False):
318
+ return None, None, []
319
+
320
+ capability_server = app.config.get("CAPABILITY_PLATFORM_SERVER", "").rstrip("/")
321
+ if not capability_server:
322
+ app.logger.warning("AUTO_BLUEPRINT_ENABLE=True 但未配置 CAPABILITY_PLATFORM_SERVER,跳过动态蓝图注册")
323
+ return None, None, []
324
+
325
+ source_url = f"{capability_server}/icp/authPermission/client_resource_param_tree"
326
+ route_prefix = _normalize_route_prefix(app.config.get("ROUTE_PREFIX", ""))
327
+ tree_data, selected_id_set, resource_params = _fetch_api_tree_data(app, source_url)
328
+ if not tree_data:
329
+ return capability_server, route_prefix, []
330
+
331
+ route_list = _flatten_tree_by_parent(tree_data, selected_id_set, resource_params=resource_params)
332
+ if not route_list:
333
+ app.logger.warning("动态蓝图注册完成,但没有提取到可用路由")
334
+ return capability_server, route_prefix, route_list
335
+
336
+
337
+ def setup_api_blueprint(app, exclude_route_method_keys=None, existing_blueprint_names=None,
338
+ route_list=None, capability_server=None, route_prefix=None):
339
+ """
340
+ 调用固定 API 拉取地址列表并动态创建蓝图。
341
+ 固定请求地址:{CAPABILITY_PLATFORM_SERVER}/icp/authPermission/client_resource_param_tree
342
+ """
343
+ if route_list is None:
344
+ capability_server, route_prefix, route_list = prepare_api_blueprint_routes(app)
345
+ route_prefix = _normalize_route_prefix(route_prefix)
346
+ if not capability_server:
347
+ return []
348
+ if not route_list:
349
+ return []
350
+
351
+ exclude_route_method_keys = set(exclude_route_method_keys or [])
352
+ used_blueprint_names = set(existing_blueprint_names or [])
353
+ used_blueprint_names.update(set(app.blueprints.keys()))
354
+ registered_route_method_keys = set()
355
+ supported_methods = {"GET", "POST", "PUT", "PATCH", "DELETE"}
356
+ grouped_routes = _group_routes(route_list)
357
+
358
+ auto_blueprints = []
359
+ total_route_count = 0
360
+ for group_id, group_info in grouped_routes.items():
361
+ bp_name = _alloc_blueprint_name(group_info.get("label"), used_blueprint_names)
362
+ dynamic_bp = SQ_Blueprint(bp_name, url_prefix="")
363
+ group_route_count = 0
364
+
365
+ for index, item in enumerate(group_info.get("routes", [])):
366
+ remote_route = _normalize_route(item.get("url"))
367
+ if not remote_route:
368
+ continue
369
+ local_route = _map_local_route(remote_route, route_prefix)
370
+ target_url = item.get("target_url") or _build_target_url(capability_server, remote_route)
371
+ if not target_url:
372
+ app.logger.warning("动态蓝图未找到目标地址,route=%s,group=%s", remote_route, group_id)
373
+ continue
374
+
375
+ methods = _normalize_methods(item.get("method", "POST"), supported_methods)
376
+ available_methods = []
377
+ for method in methods:
378
+ route_method_key = f"{local_route}|{method}"
379
+ if route_method_key in exclude_route_method_keys:
380
+ continue
381
+ if route_method_key in registered_route_method_keys:
382
+ continue
383
+ available_methods.append(method)
384
+ registered_route_method_keys.add(route_method_key)
385
+ if not available_methods:
386
+ continue
387
+
388
+ title = item.get("title") or f"{bp_name}-{index + 1}"
389
+ endpoint = f"auto_dynamic_{group_id}_{index}_{'_'.join(available_methods).lower()}"
390
+ description = item.get("description") or title
391
+ view_func = _build_proxy_view(app, target_url=target_url, title=title,
392
+ description=description, dynamic_params=item.get("dynamic_params"))
393
+ dynamic_bp.add_url_rule(local_route, endpoint=endpoint, view_func=view_func, methods=available_methods)
394
+ group_route_count += 1
395
+ total_route_count += 1
396
+
397
+ if group_route_count > 0:
398
+ auto_blueprints.append(dynamic_bp)
399
+ app.logger.info("动态蓝图注册成功:%s,路由数量=%s", bp_name, group_route_count)
400
+
401
+ if total_route_count == 0:
402
+ app.logger.warning("动态蓝图注册完成,但没有可用路由")
403
+ return auto_blueprints
404
+
405
+
116
406
  def setup_query_runner():
117
407
  """
118
408
  注入数据查询执行器
@@ -39,20 +39,20 @@ class CountLimitHandler:
39
39
  logout_url = f"{fs_oam_service_url}/icp/oauth/logout_token?token={token}"
40
40
 
41
41
  # 发送 GET 请求到 lock_account_url
42
- try:
43
- lock_response = requests.get(lock_account_url, timeout=30)
44
- logger.info(
45
- "Lock account request sent, status: %s", lock_response.status_code
46
- )
47
- except requests.RequestException as e:
48
- logger.error("Failed to send lock account request: %s", str(e))
42
+ # try:
43
+ # lock_response = requests.get(lock_account_url, timeout=30)
44
+ # logger.info(
45
+ # "Lock account request sent, status: %s", lock_response.status_code
46
+ # )
47
+ # except requests.RequestException as e:
48
+ # logger.error("Failed to send lock account request: %s", str(e))
49
49
 
50
- # 发送 GET 请求到 logout_url
51
- try:
52
- logout_response = requests.get(logout_url, timeout=30)
53
- logger.info("Logout request sent, status: %s", logout_response.status_code)
54
- except requests.RequestException as e:
55
- logger.error("Failed to send logout request: %s", str(e))
50
+ # # 发送 GET 请求到 logout_url
51
+ # try:
52
+ # logout_response = requests.get(logout_url, timeout=30)
53
+ # logger.info("Logout request sent, status: %s", logout_response.status_code)
54
+ # except requests.RequestException as e:
55
+ # logger.error("Failed to send logout request: %s", str(e))
56
56
  limit_fs_webhook_url = current_app.config.get("LIMIT_FS_WEBHOOK_URL")
57
57
  # 如果配置了飞书 webhook URL,则发送告警通知
58
58
  if limit_fs_webhook_url:
@@ -69,9 +69,9 @@ class CountLimitHandler:
69
69
  content.append({"tag": "text", "text": f"资源地址:{request.path}\n"})
70
70
 
71
71
  content.append({"tag": "text", "text": "运维处理:"})
72
- # content.append({"tag": "a", "text": "强制下线", "href": f"{url}"})
73
- # content.append({"tag": "a", "text": " 禁止登录 ",
74
- # "href": f"{lock_account_url}"})
72
+ content.append({"tag": "a", "text": "强制下线", "href": f"{logout_url}"})
73
+ content.append({"tag": "a", "text": " 禁止登录 ",
74
+ "href": f"{lock_account_url}"})
75
75
  ban_ip_url = f"{fs_oam_service_url}/icp/accessLog/ban_ip?ip={request.remote_addr}"
76
76
  content.append({"tag": "a", "text": "封禁IP ", "href": f"{ban_ip_url}"})
77
77
 
@@ -0,0 +1,3 @@
1
+ from lesscode_flask.utils.sign.signature import verify_request_signature
2
+
3
+ __all__ = ["verify_request_signature"]
@@ -0,0 +1,160 @@
1
+ import hashlib
2
+ import hmac
3
+ import ipaddress
4
+ import time
5
+
6
+ from flask import request
7
+
8
+ from lesscode_flask.model.response_result import ResponseResult
9
+ from lesscode_flask.utils.redis.redis_helper import RedisHelper
10
+
11
+ SIGN_ERROR_MESSAGE = "未知错误"
12
+
13
+
14
+ def _body_sha256() -> str:
15
+ # 对原始请求体做哈希,保证不同 content-type 下签名输入一致。
16
+ body_bytes = request.get_data(cache=True) or b""
17
+ return hashlib.sha256(body_bytes).hexdigest()
18
+
19
+
20
+ def _is_upload_request() -> bool:
21
+ # 上传文件请求:multipart/form-data 或存在文件字段。
22
+ content_type = (request.content_type or "").lower()
23
+ return content_type.startswith("multipart/form-data") or bool(request.files)
24
+
25
+
26
+ def _canonical_sign_content(timestamp: str, nonce: str) -> str:
27
+ # 规范签名串格式:
28
+ # METHOD \n PATH \n QUERY_STRING \n BODY_SHA256 \n TIMESTAMP \n NONCE
29
+ method = request.method.upper()
30
+ path = request.path
31
+ query_string = (request.query_string or b"").decode("utf-8")
32
+ if _is_upload_request():
33
+ # 上传请求忽略body参与签名时,固定使用空串哈希。
34
+ body_hash = hashlib.sha256(b"").hexdigest()
35
+ else:
36
+ body_hash = _body_sha256()
37
+ return f"{method}\n{path}\n{query_string}\n{body_hash}\n{timestamp}\n{nonce}"
38
+
39
+
40
+ def _in_sign_white_list(white_list: list) -> bool:
41
+ # 白名单匹配规则:只要请求路径以前缀命中就跳过签名校验。
42
+ if not white_list:
43
+ return False
44
+ request_path = request.path or ""
45
+ for white_item in white_list:
46
+ if white_item and request_path.startswith(white_item):
47
+ return True
48
+ return False
49
+
50
+
51
+ def _request_client_ip() -> str:
52
+ # 优先读取公司网关透传IP,再回退到常见代理头和直连IP。
53
+ remote_addr_header = request.headers.get("remote-addr", "").strip()
54
+ if remote_addr_header:
55
+ return remote_addr_header
56
+
57
+ x_forwarded_for = request.headers.get("X-Forwarded-For", "")
58
+ if x_forwarded_for:
59
+ client_ip = x_forwarded_for.split(",")[0].strip()
60
+ if client_ip:
61
+ return client_ip
62
+ x_real_ip = request.headers.get("X-Real-IP", "").strip()
63
+ if x_real_ip:
64
+ return x_real_ip
65
+ return (request.remote_addr or "").strip()
66
+
67
+
68
+ def _in_sign_ip_white_list(ip_white_list: list) -> bool:
69
+ # IP白名单支持单IP和CIDR网段,例如:["10.0.0.1", "10.10.0.0/16"]。
70
+ if not ip_white_list:
71
+ return False
72
+ client_ip = _request_client_ip()
73
+ if not client_ip:
74
+ return False
75
+ try:
76
+ client_ip_obj = ipaddress.ip_address(client_ip)
77
+ except ValueError:
78
+ return False
79
+
80
+ for item in ip_white_list:
81
+ white_item = (item or "").strip()
82
+ if not white_item:
83
+ continue
84
+ try:
85
+ if "/" in white_item:
86
+ if client_ip_obj in ipaddress.ip_network(white_item, strict=False):
87
+ return True
88
+ elif client_ip_obj == ipaddress.ip_address(white_item):
89
+ return True
90
+ except ValueError:
91
+ # 非法配置项直接跳过,避免影响其它合法项匹配。
92
+ continue
93
+ return False
94
+
95
+
96
+ def verify_request_signature(app):
97
+ # 签名校验在 token/权限校验前执行。
98
+ # 关闭开关时,直接放行,不影响现有接口。
99
+ if not app.config.get("SIGN_ENABLE", False):
100
+ return
101
+ # 公司内部IP可走白名单,命中则跳过签名校验。
102
+ if _in_sign_ip_white_list(app.config.get("SIGN_IP_WHITE_LIST", [])):
103
+ return
104
+ # 预检请求通常不带业务签名,默认忽略。
105
+ if request.method in app.config.get("SIGN_IGNORE_METHODS", ["OPTIONS"]):
106
+ return
107
+ # 白名单接口不做签名校验,例如登录、swagger等入口。
108
+ if _in_sign_white_list(app.config.get("SIGN_WHITE_LIST", [])):
109
+ return
110
+
111
+ # 读取签名相关请求头,固定使用无前缀命名。
112
+ timestamp = request.headers.get("Timestamp", "")
113
+ nonce = request.headers.get("Nonce", "")
114
+ signature = request.headers.get("Signature", "")
115
+ sign_secret = app.config.get("SIGN_SECRET", "")
116
+
117
+ # 任何签名基础参数缺失都统一返回“未知错误”,避免向外暴露细节。
118
+ if not timestamp or not nonce or not signature:
119
+ ResponseResult.fail(message=SIGN_ERROR_MESSAGE, status_code="401", http_code="401")
120
+ # 服务端密钥未配置时也拦截,防止“空密钥”误放行。
121
+ if not sign_secret:
122
+ ResponseResult.fail(message=SIGN_ERROR_MESSAGE, status_code="401", http_code="401")
123
+
124
+ # 时间戳必须是整数秒。
125
+ try:
126
+ ts = int(timestamp)
127
+ except (TypeError, ValueError):
128
+ ResponseResult.fail(message=SIGN_ERROR_MESSAGE, status_code="401", http_code="401")
129
+ return
130
+
131
+ # 时间窗校验,防止旧请求被重放。
132
+ sign_window_sec = int(app.config.get("SIGN_WINDOW_SEC", 300))
133
+ now_ts = int(time.time())
134
+ if abs(now_ts - ts) > sign_window_sec:
135
+ ResponseResult.fail(message=SIGN_ERROR_MESSAGE, status_code="401", http_code="401")
136
+
137
+ if app.config.get("SIGN_NONCE_ENABLE", True):
138
+ # 防重放:同一个 nonce 在 TTL 窗口内只能使用一次。
139
+ nonce_key = f"sign:nonce:{nonce}"
140
+ nonce_ttl = int(app.config.get("SIGN_NONCE_TTL_SEC", sign_window_sec))
141
+ try:
142
+ ok = RedisHelper(app.config.get("REDIS_LIMIT_KEY", "redis")).sync_set(
143
+ nonce_key, "1", ex=nonce_ttl, nx=True
144
+ )
145
+ if not ok:
146
+ ResponseResult.fail(message=SIGN_ERROR_MESSAGE, status_code="401", http_code="401")
147
+ except Exception:
148
+ # Redis 异常按失败处理,避免在防重放失效时放过请求。
149
+ ResponseResult.fail(message=SIGN_ERROR_MESSAGE, status_code="401", http_code="401")
150
+
151
+ # 按统一规则组装签名串并计算服务端签名。
152
+ sign_content = _canonical_sign_content(timestamp=timestamp, nonce=nonce)
153
+ expected = hmac.new(
154
+ sign_secret.encode("utf-8"),
155
+ sign_content.encode("utf-8"),
156
+ hashlib.sha256,
157
+ ).hexdigest()
158
+ # 常量时间比较,降低时序侧信道风险。
159
+ if not hmac.compare_digest(expected, signature):
160
+ ResponseResult.fail(message=SIGN_ERROR_MESSAGE, status_code="401", http_code="401")
@@ -4,6 +4,94 @@ from lesscode_flask.utils.swagger.swagger_template import get_response_template,
4
4
  split_doc, get_param_template
5
5
 
6
6
 
7
+ def _get_dynamic_params(view_func):
8
+ dynamic_params = getattr(view_func, "_dynamic_params", None)
9
+ if isinstance(dynamic_params, list):
10
+ return dynamic_params
11
+ return []
12
+
13
+
14
+ def _map_param_type(param_type):
15
+ param_type = str(param_type or "").lower()
16
+ if param_type in {"int", "integer"}:
17
+ return "integer"
18
+ if param_type in {"float", "number", "double"}:
19
+ return "number"
20
+ if param_type in {"bool", "boolean"}:
21
+ return "boolean"
22
+ if param_type in {"dict", "object"}:
23
+ return "object"
24
+ if param_type in {"list", "array"}:
25
+ return "array"
26
+ return "string"
27
+
28
+
29
+ def _build_dynamic_schema(param_type):
30
+ schema_type = _map_param_type(param_type)
31
+ if schema_type == "array":
32
+ return {"type": "array", "items": {"type": "string"}}
33
+ return {"type": schema_type}
34
+
35
+
36
+ def _build_dynamic_body(view_func, not_allow_list=None):
37
+ dynamic_params = _get_dynamic_params(view_func)
38
+ if not dynamic_params:
39
+ return None
40
+
41
+ not_allow_list = set(not_allow_list or [])
42
+ body = {}
43
+ required = []
44
+ request_type = "application/json"
45
+ for param in dynamic_params:
46
+ param_name = param.get("param_name")
47
+ if not param_name or param_name in not_allow_list:
48
+ continue
49
+ position = str(param.get("param_position") or "").lower()
50
+ if position == "path":
51
+ continue
52
+ if position in {"query", "header"}:
53
+ continue
54
+
55
+ schema = _build_dynamic_schema(param.get("param_type"))
56
+ body[param_name] = {
57
+ **schema,
58
+ "description": param.get("param_description") or param.get("param_ch_name") or f"Path parameter {param_name}",
59
+ "example": get_sample_data(param.get("param_type"))
60
+ }
61
+ required.append(param_name)
62
+
63
+ if not body:
64
+ return None
65
+ return get_request_body(body, required, request_type)
66
+
67
+
68
+ def _build_dynamic_query_params(rule, view_func):
69
+ dynamic_params = _get_dynamic_params(view_func)
70
+ if not dynamic_params:
71
+ return None
72
+
73
+ parameters = []
74
+ path_arg_set = set(rule.arguments)
75
+ for param in dynamic_params:
76
+ param_name = param.get("param_name")
77
+ if not param_name or param_name in path_arg_set:
78
+ continue
79
+ position = str(param.get("param_position") or "").lower()
80
+ if position == "header":
81
+ continue
82
+ if position == "path":
83
+ continue
84
+
85
+ parameters.append({
86
+ "name": param_name,
87
+ "in": "query",
88
+ "description": param.get("param_description") or param.get("param_ch_name") or f"Path parameter {param_name}",
89
+ "required": False,
90
+ "schema": _build_dynamic_schema(param.get("param_type"))
91
+ })
92
+ return parameters
93
+
94
+
7
95
  def generate_openapi_spec(app, is_read_template=False):
8
96
  paths = {}
9
97
  if is_read_template:
@@ -100,11 +188,15 @@ def replace_symbol(path):
100
188
  def get_sample_data(type, param=None, param_template_dict=None):
101
189
  if param_template_dict and param_template_dict.get(param.name):
102
190
  return param_template_dict.get(param.name, {}).get("param_sample")
103
- elif type == "dict":
191
+ elif type in {"dict", "object"}:
104
192
  return {"sample": "sample"}
105
- elif type == "list":
193
+ elif type in {"list", "array"}:
106
194
  return ["sample"]
107
- elif type == "int":
195
+ elif type in {"int", "integer"}:
196
+ return 0
197
+ elif type in {"bool", "boolean"}:
198
+ return False
199
+ elif type in {"float", "number", "double"}:
108
200
  return 0
109
201
  else:
110
202
  return ""
@@ -129,6 +221,9 @@ def get_params_type(param, param_template_dict=None):
129
221
  def extract_post_body(view_func, not_allow_list=None, param_desc_dict=None, param_template_dict=None):
130
222
  if param_desc_dict is None:
131
223
  param_desc_dict = {}
224
+ dynamic_body = _build_dynamic_body(view_func, not_allow_list=not_allow_list)
225
+ if dynamic_body is not None:
226
+ return dynamic_body
132
227
  body = {}
133
228
  # 提取查询参数和表单参数
134
229
  sig = inspect.signature(view_func)
@@ -144,6 +239,9 @@ def extract_post_body(view_func, not_allow_list=None, param_desc_dict=None, para
144
239
 
145
240
  required = []
146
241
  for arg, param in sig.parameters.items():
242
+ # Skip variadic args like **path_params used by dynamic proxy views.
243
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
244
+ continue
147
245
  param_type = get_params_type(param, param_template_dict)
148
246
  param_info = {
149
247
  "type": param_type,
@@ -173,15 +271,19 @@ def extract_post_body(view_func, not_allow_list=None, param_desc_dict=None, para
173
271
  def extract_path_parameters(rule, view_func, param_desc_dict=None, param_template_dict=None):
174
272
  if param_desc_dict is None:
175
273
  param_desc_dict = {}
274
+ dynamic_param_map = {
275
+ str(item.get("param_name")): item for item in _get_dynamic_params(view_func) if item.get("param_name")
276
+ }
176
277
  parameters = []
177
278
  # 提取路径参数
178
279
  for arg in rule.arguments:
280
+ dynamic_param = dynamic_param_map.get(arg, {})
179
281
  parameters.append({
180
282
  "name": arg,
181
283
  "in": "path",
182
284
  "required": True,
183
- "description": get_param_desc(arg, param_desc_dict, param_template_dict),
184
- "schema": {
285
+ "description": dynamic_param.get("param_description") or get_param_desc(arg, param_desc_dict, param_template_dict),
286
+ "schema": _build_dynamic_schema(dynamic_param.get("param_type")) if dynamic_param else {
185
287
  "type": "integer" if "int" in str(view_func.__annotations__.get(arg, '')) else "string"
186
288
  }
187
289
  })
@@ -199,10 +301,16 @@ def get_param_desc(arg, param_desc_dict, param_template_dict):
199
301
  def extract_get_parameters(rule, view_func, param_desc_dict=None, param_template_dict=None):
200
302
  if param_desc_dict is None:
201
303
  param_desc_dict = {}
304
+ dynamic_parameters = _build_dynamic_query_params(rule, view_func)
305
+ if dynamic_parameters is not None:
306
+ return dynamic_parameters
202
307
  parameters = []
203
308
  # 提取查询参数和表单参数
204
309
  sig = inspect.signature(view_func)
205
310
  for arg, param in sig.parameters.items():
311
+ # Skip variadic args like **path_params used by dynamic proxy views.
312
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
313
+ continue
206
314
  if arg not in rule.arguments:
207
315
  param_info = {
208
316
  "name": arg,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lesscode-flask
3
- Version: 0.2.62
3
+ Version: 0.2.89
4
4
  Summary: lesscode-flask 是基于flask的web开发脚手架项目,该项目初衷为简化开发过程,让研发人员更加关注业务。
5
5
  Home-page: https://lesscode-flask
6
6
  Author: Chao.yy
@@ -28,6 +28,7 @@ lesscode_flask/service/resource_param_template_service.py
28
28
  lesscode_flask/setting/__init__.py
29
29
  lesscode_flask/setup/__init__.py
30
30
  lesscode_flask/static/swagger.py
31
+ lesscode_flask/utils/__init__.py
31
32
  lesscode_flask/utils/dify_utils.py
32
33
  lesscode_flask/utils/fs_util.py
33
34
  lesscode_flask/utils/helpers.py
@@ -52,6 +53,8 @@ lesscode_flask/utils/oss/ks3_oss.py
52
53
  lesscode_flask/utils/oss/minio_oss.py
53
54
  lesscode_flask/utils/redis/redis_helper.py
54
55
  lesscode_flask/utils/request/request.py
56
+ lesscode_flask/utils/sign/__init__.py
57
+ lesscode_flask/utils/sign/signature.py
55
58
  lesscode_flask/utils/swagger/swagger_template.py
56
59
  lesscode_flask/utils/swagger/swagger_util.py
57
60
  lesscode_flask/utils/task/__init__.py