@josephyan/qingflow-cli 0.2.0-beta.55

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 (79) hide show
  1. package/README.md +30 -0
  2. package/docs/local-agent-install.md +235 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +204 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +5 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +547 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +985 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +8243 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +78 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +184 -0
  21. package/src/qingflow_mcp/cli/commands/common.py +47 -0
  22. package/src/qingflow_mcp/cli/commands/imports.py +86 -0
  23. package/src/qingflow_mcp/cli/commands/record.py +202 -0
  24. package/src/qingflow_mcp/cli/commands/task.py +87 -0
  25. package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
  26. package/src/qingflow_mcp/cli/context.py +48 -0
  27. package/src/qingflow_mcp/cli/formatters.py +269 -0
  28. package/src/qingflow_mcp/cli/json_io.py +50 -0
  29. package/src/qingflow_mcp/cli/main.py +147 -0
  30. package/src/qingflow_mcp/config.py +221 -0
  31. package/src/qingflow_mcp/errors.py +66 -0
  32. package/src/qingflow_mcp/import_store.py +121 -0
  33. package/src/qingflow_mcp/json_types.py +18 -0
  34. package/src/qingflow_mcp/list_type_labels.py +76 -0
  35. package/src/qingflow_mcp/server.py +211 -0
  36. package/src/qingflow_mcp/server_app_builder.py +387 -0
  37. package/src/qingflow_mcp/server_app_user.py +317 -0
  38. package/src/qingflow_mcp/session_store.py +289 -0
  39. package/src/qingflow_mcp/solution/__init__.py +6 -0
  40. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  41. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  42. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  43. package/src/qingflow_mcp/solution/compiler/form_compiler.py +466 -0
  44. package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
  45. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  46. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  47. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  48. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  49. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  50. package/src/qingflow_mcp/solution/design_session.py +222 -0
  51. package/src/qingflow_mcp/solution/design_store.py +100 -0
  52. package/src/qingflow_mcp/solution/executor.py +2339 -0
  53. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  54. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  55. package/src/qingflow_mcp/solution/run_store.py +244 -0
  56. package/src/qingflow_mcp/solution/spec_models.py +853 -0
  57. package/src/qingflow_mcp/tools/__init__.py +1 -0
  58. package/src/qingflow_mcp/tools/ai_builder_tools.py +2063 -0
  59. package/src/qingflow_mcp/tools/app_tools.py +850 -0
  60. package/src/qingflow_mcp/tools/approval_tools.py +833 -0
  61. package/src/qingflow_mcp/tools/auth_tools.py +697 -0
  62. package/src/qingflow_mcp/tools/base.py +81 -0
  63. package/src/qingflow_mcp/tools/code_block_tools.py +679 -0
  64. package/src/qingflow_mcp/tools/directory_tools.py +648 -0
  65. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  66. package/src/qingflow_mcp/tools/file_tools.py +385 -0
  67. package/src/qingflow_mcp/tools/import_tools.py +1971 -0
  68. package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
  69. package/src/qingflow_mcp/tools/package_tools.py +240 -0
  70. package/src/qingflow_mcp/tools/portal_tools.py +131 -0
  71. package/src/qingflow_mcp/tools/qingbi_report_tools.py +269 -0
  72. package/src/qingflow_mcp/tools/record_tools.py +12739 -0
  73. package/src/qingflow_mcp/tools/role_tools.py +94 -0
  74. package/src/qingflow_mcp/tools/solution_tools.py +3887 -0
  75. package/src/qingflow_mcp/tools/task_context_tools.py +1423 -0
  76. package/src/qingflow_mcp/tools/task_tools.py +843 -0
  77. package/src/qingflow_mcp/tools/view_tools.py +280 -0
  78. package/src/qingflow_mcp/tools/workflow_tools.py +312 -0
  79. package/src/qingflow_mcp/tools/workspace_tools.py +219 -0
@@ -0,0 +1,230 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from ..backend_client import BackendClient
8
+ from ..config import get_feedback_app_key, get_feedback_base_url, get_feedback_qsource_token, normalize_base_url
9
+ from ..errors import QingflowApiError, raise_tool_error
10
+ from ..json_types import JSONObject
11
+
12
+
13
+ CATEGORY_MAP = {
14
+ "feature_request": "功能需求",
15
+ "bug_report": "问题反馈",
16
+ "ux_feedback": "体验建议",
17
+ "unsupported_scenario": "不支持场景",
18
+ "other": "其他",
19
+ }
20
+
21
+ IMPACT_SCOPE_MAP = {
22
+ "personal": "仅个人",
23
+ "small_team": "小范围团队",
24
+ "cross_team": "跨团队",
25
+ "global": "全局",
26
+ }
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class FeedbackTools:
31
+ backend: BackendClient
32
+ mcp_side: str
33
+
34
+ def register(self, mcp: FastMCP) -> None:
35
+ @mcp.tool()
36
+ def feedback_submit(
37
+ category: str = "",
38
+ title: str = "",
39
+ description: str = "",
40
+ expected_behavior: str | None = None,
41
+ actual_behavior: str | None = None,
42
+ impact_scope: str | None = None,
43
+ tool_name: str | None = None,
44
+ app_key: str | None = None,
45
+ record_id: str | int | None = None,
46
+ workflow_node_id: str | int | None = None,
47
+ note: str | None = None,
48
+ ) -> JSONObject:
49
+ """Submit product feedback to the Qingflow MCP team.
50
+
51
+ Use this when the current MCP capability is unsupported, awkward, or still cannot satisfy the user's need
52
+ after reasonable attempts. This helper writes through the internal q-source feedback intake, does not
53
+ require Qingflow login or workspace selection, and should be called only after explicit user confirmation.
54
+ """
55
+ return self.feedback_submit(
56
+ category=category,
57
+ title=title,
58
+ description=description,
59
+ expected_behavior=expected_behavior,
60
+ actual_behavior=actual_behavior,
61
+ impact_scope=impact_scope,
62
+ tool_name=tool_name,
63
+ app_key=app_key,
64
+ record_id=record_id,
65
+ workflow_node_id=workflow_node_id,
66
+ note=note,
67
+ )
68
+
69
+ def feedback_submit(
70
+ self,
71
+ *,
72
+ category: str,
73
+ title: str,
74
+ description: str,
75
+ expected_behavior: str | None,
76
+ actual_behavior: str | None,
77
+ impact_scope: str | None,
78
+ tool_name: str | None,
79
+ app_key: str | None,
80
+ record_id: str | int | None,
81
+ workflow_node_id: str | int | None,
82
+ note: str | None,
83
+ ) -> JSONObject:
84
+ qsource_token = get_feedback_qsource_token()
85
+ if not qsource_token:
86
+ raise_tool_error(
87
+ QingflowApiError(
88
+ category="config",
89
+ message=(
90
+ "feedback_submit is not configured. Set "
91
+ "feedback.qsource_token or QINGFLOW_MCP_FEEDBACK_QSOURCE_TOKEN first."
92
+ ),
93
+ details={"error_code": "FEEDBACK_NOT_CONFIGURED"},
94
+ )
95
+ )
96
+
97
+ base_url = get_feedback_base_url()
98
+ if not base_url:
99
+ raise_tool_error(
100
+ QingflowApiError.config_error(
101
+ "feedback_submit requires a base_url. Configure feedback.base_url or default_base_url."
102
+ )
103
+ )
104
+
105
+ normalized_payload = self._build_payload(
106
+ category=category,
107
+ title=title,
108
+ description=description,
109
+ expected_behavior=expected_behavior,
110
+ actual_behavior=actual_behavior,
111
+ impact_scope=impact_scope,
112
+ tool_name=tool_name,
113
+ app_key=app_key,
114
+ record_id=record_id,
115
+ workflow_node_id=workflow_node_id,
116
+ note=note,
117
+ )
118
+
119
+ try:
120
+ response = self.backend.public_request_with_meta(
121
+ "POST",
122
+ base_url,
123
+ f"/qsource/{qsource_token}",
124
+ json_body=normalized_payload,
125
+ unwrap=True,
126
+ qf_version=None,
127
+ )
128
+ except QingflowApiError as exc:
129
+ raise_tool_error(exc)
130
+ result = response.data if isinstance(response.data, dict) else {}
131
+ feedback_request_id = result.get("requestId") if isinstance(result, dict) else None
132
+
133
+ return {
134
+ "ok": True,
135
+ "request_route": {
136
+ "base_url": normalize_base_url(base_url) or base_url,
137
+ "qf_version": None,
138
+ "qf_version_source": "not_applicable",
139
+ },
140
+ "submission_mode": "qsource_passive",
141
+ "feedback_target": {
142
+ "app_key": get_feedback_app_key(),
143
+ "mcp_side": self.mcp_side,
144
+ },
145
+ "normalized_payload": normalized_payload,
146
+ "feedback_request_id": feedback_request_id,
147
+ }
148
+
149
+ def _build_payload(
150
+ self,
151
+ *,
152
+ category: str,
153
+ title: str,
154
+ description: str,
155
+ expected_behavior: str | None,
156
+ actual_behavior: str | None,
157
+ impact_scope: str | None,
158
+ tool_name: str | None,
159
+ app_key: str | None,
160
+ record_id: str | int | None,
161
+ workflow_node_id: str | int | None,
162
+ note: str | None,
163
+ ) -> JSONObject:
164
+ payload: JSONObject = {
165
+ "title": self._require_text("title", title),
166
+ "category": self._normalize_label("category", category, CATEGORY_MAP, required=True),
167
+ "description": self._require_text("description", description),
168
+ "submit_method": "AI代提",
169
+ "status": "待处理",
170
+ "mcp_side": self.mcp_side,
171
+ }
172
+
173
+ optional_text = {
174
+ "expected_result": expected_behavior,
175
+ "actual_behavior": actual_behavior,
176
+ "tool_name": tool_name,
177
+ "app_key": app_key,
178
+ "note": note,
179
+ }
180
+ for key, value in optional_text.items():
181
+ normalized = self._normalize_optional_text(value)
182
+ if normalized is not None:
183
+ payload[key] = normalized
184
+
185
+ if impact_scope is not None and str(impact_scope).strip():
186
+ payload["impact_scope"] = self._normalize_label("impact_scope", impact_scope, IMPACT_SCOPE_MAP, required=False)
187
+
188
+ if record_id is not None and str(record_id).strip():
189
+ payload["record_id"] = str(record_id).strip()
190
+ if workflow_node_id is not None and str(workflow_node_id).strip():
191
+ payload["workflow_node_id"] = str(workflow_node_id).strip()
192
+
193
+ return payload
194
+
195
+ def _normalize_label(self, field: str, value: str, mapping: dict[str, str], *, required: bool) -> str:
196
+ text = str(value or "").strip()
197
+ if not text:
198
+ if required:
199
+ raise_tool_error(QingflowApiError.config_error(f"{field} is required"))
200
+ return ""
201
+
202
+ canonical = text.lower()
203
+ if canonical in mapping:
204
+ return mapping[canonical]
205
+ if text in mapping.values():
206
+ return text
207
+ supported_values = list(mapping.keys()) + list(mapping.values())
208
+ raise_tool_error(
209
+ QingflowApiError(
210
+ category="config",
211
+ message=f"{field} must be one of the supported canonical values or labels",
212
+ details={
213
+ "error_code": "FEEDBACK_INVALID_INPUT",
214
+ "field": field,
215
+ "supported_values": supported_values,
216
+ },
217
+ )
218
+ )
219
+
220
+ def _require_text(self, field: str, value: str) -> str:
221
+ normalized = str(value or "").strip()
222
+ if not normalized:
223
+ raise_tool_error(QingflowApiError.config_error(f"{field} is required"))
224
+ return normalized
225
+
226
+ def _normalize_optional_text(self, value: str | None) -> str | None:
227
+ if value is None:
228
+ return None
229
+ normalized = str(value).strip()
230
+ return normalized or None
@@ -0,0 +1,385 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import mimetypes
6
+ import random
7
+ import string
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib.parse import quote
11
+
12
+ from mcp.server.fastmcp import FastMCP
13
+
14
+ from ..config import DEFAULT_PROFILE
15
+ from ..errors import QingflowApiError, raise_tool_error
16
+ from ..json_types import JSONObject
17
+ from .base import ToolBase
18
+
19
+
20
+ ATTACHMENT_UPLOAD_INFO_FALLBACK_CODES = {"40118", 40118}
21
+ LEGACY_OSS_FORM_REQUIRED_KEYS = ("key", "policy", "signature", "ossAccessKeyId")
22
+
23
+
24
+ class FileTools(ToolBase):
25
+ def register(self, mcp: FastMCP) -> None:
26
+ @mcp.tool()
27
+ def file_get_upload_info(
28
+ profile: str = DEFAULT_PROFILE,
29
+ upload_kind: str = "attachment",
30
+ file_name: str = "",
31
+ file_size: int = 0,
32
+ upload_mark: str | None = None,
33
+ content_type: str | None = None,
34
+ bucket_type: str | None = None,
35
+ path_id: int | None = None,
36
+ file_related_url: str | None = None,
37
+ ) -> dict[str, Any]:
38
+ return self.file_get_upload_info(
39
+ profile=profile,
40
+ upload_kind=upload_kind,
41
+ file_name=file_name,
42
+ file_size=file_size,
43
+ upload_mark=upload_mark,
44
+ content_type=content_type,
45
+ bucket_type=bucket_type,
46
+ path_id=path_id,
47
+ file_related_url=file_related_url,
48
+ )
49
+
50
+ @mcp.tool()
51
+ def file_upload_local(
52
+ profile: str = DEFAULT_PROFILE,
53
+ upload_kind: str = "attachment",
54
+ file_path: str = "",
55
+ upload_mark: str | None = None,
56
+ content_type: str | None = None,
57
+ bucket_type: str | None = None,
58
+ path_id: int | None = None,
59
+ file_related_url: str | None = None,
60
+ ) -> dict[str, Any]:
61
+ return self.file_upload_local(
62
+ profile=profile,
63
+ upload_kind=upload_kind,
64
+ file_path=file_path,
65
+ upload_mark=upload_mark,
66
+ content_type=content_type,
67
+ bucket_type=bucket_type,
68
+ path_id=path_id,
69
+ file_related_url=file_related_url,
70
+ )
71
+
72
+ def file_get_upload_info(
73
+ self,
74
+ *,
75
+ profile: str,
76
+ upload_kind: str,
77
+ file_name: str,
78
+ file_size: int,
79
+ upload_mark: str | None = None,
80
+ content_type: str | None = None,
81
+ bucket_type: str | None = None,
82
+ path_id: int | None = None,
83
+ file_related_url: str | None = None,
84
+ ) -> dict[str, Any]:
85
+ def runner(session_profile, context):
86
+ upload_info = self._request_upload_info_with_fallback(
87
+ context,
88
+ upload_kind=upload_kind,
89
+ file_name=file_name,
90
+ file_size=file_size,
91
+ upload_mark=upload_mark,
92
+ content_type=content_type,
93
+ bucket_type=bucket_type,
94
+ path_id=path_id,
95
+ file_related_url=file_related_url,
96
+ )
97
+ return {
98
+ "profile": profile,
99
+ "ws_id": session_profile.selected_ws_id,
100
+ "upload_kind": upload_kind,
101
+ "requested_upload_kind": upload_info["requested_upload_kind"],
102
+ "effective_upload_kind": upload_info["effective_upload_kind"],
103
+ "upload_fallback_applied": upload_info["fallback_applied"],
104
+ "upload_fallback_reason": upload_info["fallback_reason"],
105
+ "file_name": file_name,
106
+ "file_size": file_size,
107
+ "request_route": self.backend.describe_route(context),
108
+ "result": upload_info["result"],
109
+ }
110
+
111
+ return self._run(profile, runner)
112
+
113
+ def file_upload_local(
114
+ self,
115
+ *,
116
+ profile: str,
117
+ upload_kind: str,
118
+ file_path: str,
119
+ upload_mark: str | None = None,
120
+ content_type: str | None = None,
121
+ bucket_type: str | None = None,
122
+ path_id: int | None = None,
123
+ file_related_url: str | None = None,
124
+ ) -> dict[str, Any]:
125
+ path = Path(file_path).expanduser()
126
+ if not path.is_file():
127
+ raise_tool_error(QingflowApiError.config_error("file_path must point to an existing file"))
128
+ file_name = path.name
129
+ file_size = path.stat().st_size
130
+ resolved_content_type = content_type or mimetypes.guess_type(file_name)[0] or "application/octet-stream"
131
+
132
+ def runner(session_profile, context):
133
+ upload_info = self._request_upload_info_with_fallback(
134
+ context,
135
+ upload_kind=upload_kind,
136
+ file_name=file_name,
137
+ file_size=file_size,
138
+ upload_mark=upload_mark,
139
+ content_type=resolved_content_type,
140
+ bucket_type=bucket_type,
141
+ path_id=path_id,
142
+ file_related_url=file_related_url,
143
+ )
144
+ result = upload_info["result"]
145
+ if not isinstance(result, dict):
146
+ raise QingflowApiError.config_error("upload endpoint did not return a structured upload payload")
147
+ content = path.read_bytes()
148
+ upload_protocol = "binary_put"
149
+ if self._is_legacy_oss_form_upload(result):
150
+ upload_result = self._upload_legacy_oss_form(
151
+ result,
152
+ file_name=file_name,
153
+ content=content,
154
+ content_type=resolved_content_type,
155
+ )
156
+ upload_protocol = "oss_form_post"
157
+ else:
158
+ upload_url = str(result.get("uploadUrl") or "").strip()
159
+ if not upload_url:
160
+ raise QingflowApiError.config_error("upload endpoint did not return uploadUrl")
161
+ upload_result = self.backend.upload_binary(upload_url, content, content_type=resolved_content_type)
162
+ download_url = result.get("downloadUrl")
163
+ return {
164
+ "profile": profile,
165
+ "ws_id": session_profile.selected_ws_id,
166
+ "upload_kind": upload_kind,
167
+ "requested_upload_kind": upload_info["requested_upload_kind"],
168
+ "effective_upload_kind": upload_info["effective_upload_kind"],
169
+ "upload_fallback_applied": upload_info["fallback_applied"],
170
+ "upload_fallback_reason": upload_info["fallback_reason"],
171
+ "file_name": file_name,
172
+ "file_size": file_size,
173
+ "content_type": resolved_content_type,
174
+ "request_route": self.backend.describe_route(context),
175
+ "upload_protocol": upload_protocol,
176
+ "result": result,
177
+ "upload_result": upload_result,
178
+ "download_url": download_url,
179
+ "attachment_value": {
180
+ "value": download_url,
181
+ "otherInfo": file_name,
182
+ "name": file_name,
183
+ },
184
+ "comment_file_info": {
185
+ "url": download_url,
186
+ "name": file_name,
187
+ "uploadFileSize": file_size,
188
+ },
189
+ }
190
+
191
+ return self._run(profile, runner)
192
+
193
+ def _build_file_upload_bo(
194
+ self,
195
+ *,
196
+ upload_kind: str,
197
+ file_name: str,
198
+ file_size: int,
199
+ upload_mark: str | None,
200
+ content_type: str | None,
201
+ bucket_type: str | None,
202
+ path_id: int | None,
203
+ file_related_url: str | None,
204
+ ) -> dict[str, Any]:
205
+ if not file_name:
206
+ raise_tool_error(QingflowApiError.config_error("file_name is required"))
207
+ if file_size <= 0:
208
+ raise_tool_error(QingflowApiError.config_error("file_size must be positive"))
209
+ if upload_kind == "attachment" and not upload_mark:
210
+ raise_tool_error(QingflowApiError.config_error("upload_mark is required for attachment uploads and should usually be the app_key"))
211
+ payload: dict[str, Any] = {
212
+ "fileName": file_name,
213
+ "fileSize": file_size,
214
+ }
215
+ if upload_mark:
216
+ payload["uploadMark"] = upload_mark
217
+ if content_type:
218
+ payload["contentType"] = content_type
219
+ if bucket_type:
220
+ payload["bucketType"] = bucket_type
221
+ if path_id is not None:
222
+ payload["pathId"] = path_id
223
+ if file_related_url:
224
+ payload["fileRelatedUrl"] = file_related_url
225
+ return payload
226
+
227
+ def _resolve_upload_endpoint(self, upload_kind: str) -> str:
228
+ normalized = upload_kind.strip().lower()
229
+ if normalized == "attachment":
230
+ return "/upload/puburl"
231
+ if normalized == "login":
232
+ return "/upload/url"
233
+ if normalized == "anonymous":
234
+ return "/upload/anonymousurl"
235
+ raise_tool_error(QingflowApiError.config_error("upload_kind must be one of: attachment, login, anonymous"))
236
+ raise AssertionError("unreachable")
237
+
238
+ def _encode_formula(self, formula: str) -> str:
239
+ encoded = base64.b64encode(quote(formula, safe="").encode("utf-8")).decode("utf-8")
240
+ return f"{self._random_string(16)}{encoded}{self._random_string(16)}"
241
+
242
+ def _random_string(self, length: int) -> str:
243
+ alphabet = string.ascii_letters + string.digits
244
+ return "".join(random.choice(alphabet) for _ in range(length))
245
+
246
+ def _request_upload_info_with_fallback(
247
+ self,
248
+ context,
249
+ *,
250
+ upload_kind: str,
251
+ file_name: str,
252
+ file_size: int,
253
+ upload_mark: str | None,
254
+ content_type: str | None,
255
+ bucket_type: str | None,
256
+ path_id: int | None,
257
+ file_related_url: str | None,
258
+ ) -> dict[str, Any]:
259
+ requested_kind = upload_kind.strip().lower()
260
+ attempted_kinds = [requested_kind]
261
+ if requested_kind == "attachment":
262
+ attempted_kinds.append("login")
263
+ last_error: QingflowApiError | None = None
264
+ for current_kind in attempted_kinds:
265
+ try:
266
+ result = self._request_upload_info(
267
+ context,
268
+ upload_kind=current_kind,
269
+ file_name=file_name,
270
+ file_size=file_size,
271
+ upload_mark=upload_mark,
272
+ content_type=content_type,
273
+ bucket_type=bucket_type,
274
+ path_id=path_id,
275
+ file_related_url=file_related_url,
276
+ )
277
+ return {
278
+ "requested_upload_kind": requested_kind,
279
+ "effective_upload_kind": current_kind,
280
+ "fallback_applied": current_kind != requested_kind,
281
+ "fallback_reason": (
282
+ f"backend rejected '{requested_kind}' upload info; retried with '{current_kind}'"
283
+ if current_kind != requested_kind
284
+ else None
285
+ ),
286
+ "result": result,
287
+ }
288
+ except QingflowApiError as error:
289
+ last_error = error
290
+ if not self._should_retry_attachment_upload_info(requested_kind, current_kind, error):
291
+ raise
292
+ assert last_error is not None
293
+ raise last_error
294
+
295
+ def _request_upload_info(
296
+ self,
297
+ context,
298
+ *,
299
+ upload_kind: str,
300
+ file_name: str,
301
+ file_size: int,
302
+ upload_mark: str | None,
303
+ content_type: str | None,
304
+ bucket_type: str | None,
305
+ path_id: int | None,
306
+ file_related_url: str | None,
307
+ ) -> JSONObject:
308
+ endpoint = self._resolve_upload_endpoint(upload_kind)
309
+ file_upload_bo = self._build_file_upload_bo(
310
+ upload_kind=upload_kind,
311
+ file_name=file_name,
312
+ file_size=file_size,
313
+ upload_mark=upload_mark,
314
+ content_type=content_type,
315
+ bucket_type=bucket_type,
316
+ path_id=path_id,
317
+ file_related_url=file_related_url,
318
+ )
319
+ encrypted_payload = {
320
+ "upload": self._encode_formula(json.dumps(file_upload_bo, ensure_ascii=False, separators=(",", ":"))),
321
+ "fileName": file_name,
322
+ "fileSize": file_size,
323
+ }
324
+ if path_id is not None:
325
+ encrypted_payload["pathId"] = path_id
326
+ if bucket_type:
327
+ encrypted_payload["bucketType"] = bucket_type
328
+ result = self.backend.request("POST", context, endpoint, json_body=encrypted_payload)
329
+ if not isinstance(result, dict):
330
+ raise QingflowApiError.config_error("upload endpoint did not return a structured upload payload")
331
+ return result
332
+
333
+ def _should_retry_attachment_upload_info(
334
+ self,
335
+ requested_kind: str,
336
+ attempted_kind: str,
337
+ error: QingflowApiError,
338
+ ) -> bool:
339
+ return (
340
+ requested_kind == "attachment"
341
+ and attempted_kind == "attachment"
342
+ and error.backend_code in ATTACHMENT_UPLOAD_INFO_FALLBACK_CODES
343
+ )
344
+
345
+ def _is_legacy_oss_form_upload(self, payload: JSONObject) -> bool:
346
+ return all(str(payload.get(key) or "").strip() for key in LEGACY_OSS_FORM_REQUIRED_KEYS)
347
+
348
+ def _upload_legacy_oss_form(
349
+ self,
350
+ upload_info: JSONObject,
351
+ *,
352
+ file_name: str,
353
+ content: bytes,
354
+ content_type: str,
355
+ ) -> dict[str, Any]:
356
+ upload_url = self._resolve_legacy_oss_form_url(upload_info)
357
+ if not upload_url:
358
+ raise QingflowApiError.config_error("legacy upload payload is missing host/uploadAccelerateHost")
359
+ form_fields = {
360
+ "key": str(upload_info["key"]),
361
+ "policy": str(upload_info["policy"]),
362
+ "signature": str(upload_info["signature"]),
363
+ "OSSAccessKeyId": str(upload_info["ossAccessKeyId"]),
364
+ }
365
+ callback = str(upload_info.get("callback") or "").strip()
366
+ if callback:
367
+ form_fields["callback"] = callback
368
+ return self.backend.upload_form_file(
369
+ upload_url,
370
+ form_fields=form_fields,
371
+ file_field="file",
372
+ file_name=file_name,
373
+ content=content,
374
+ content_type=content_type,
375
+ )
376
+
377
+ def _resolve_legacy_oss_form_url(self, upload_info: JSONObject) -> str:
378
+ for key in ("uploadAccelerateHost", "host", "uploadUrl"):
379
+ value = str(upload_info.get(key) or "").strip()
380
+ if not value:
381
+ continue
382
+ if value.startswith(("http://", "https://")):
383
+ return value
384
+ return f"https://{value.lstrip('/')}"
385
+ return ""