@qingflow-tech/qingflow-cli 1.1.46 → 1.1.47-beta.2

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.
@@ -2,9 +2,23 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  from copy import deepcopy
5
+ from pathlib import Path
5
6
 
6
7
  from ..context import CliContext
7
- from ...app_form_validator import AppFormValidationError, validate_declaration
8
+ from ...app_form_validator import (
9
+ AppFormValidationError,
10
+ build_encoded_id_map,
11
+ referenced_formula_field_ids,
12
+ translate_formula_test_case_values,
13
+ encode_code_block_field_ids,
14
+ referenced_code_block_field_ids,
15
+ restore_decimal_ids_in_text,
16
+ strip_placeholder_quotes,
17
+ translate_select_test_case_values,
18
+ validate_code_block_static,
19
+ validate_declaration,
20
+ validate_formula_expression,
21
+ )
8
22
  from ..json_io import load_json_value
9
23
  from .common import load_list_arg, load_object_arg, parse_bool_text, raise_config_error, require_list_arg
10
24
 
@@ -128,6 +142,41 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
128
142
  app_form_validate.add_argument("--schema-version", help="固定使用的 AppForm Schema 版本")
129
143
  app_form_validate.set_defaults(handler=_handle_app_form_validate, format_hint="builder_summary", force_json_output=True)
130
144
 
145
+ app_form_validate_formula = app_form_subparsers.add_parser(
146
+ "validate-formula", help="校验公式表达式(本地语法 + 后端求值)"
147
+ )
148
+ app_form_validate_formula.add_argument("--app-key", required=True, help="目标应用 key")
149
+ app_form_validate_formula.add_argument("--formula", required=True, help="公式文本")
150
+ app_form_validate_formula.add_argument(
151
+ "--test-case",
152
+ required=True,
153
+ help="测试用例 JSON 文件路径,- 表示 stdin;格式 {queId: value}",
154
+ )
155
+ app_form_validate_formula.add_argument(
156
+ "--fields",
157
+ required=False,
158
+ help="字段清单 JSON 文件路径(用于本地字段引用校验);缺省时从后端拉取",
159
+ )
160
+ app_form_validate_formula.set_defaults(
161
+ handler=_handle_app_form_validate_formula, format_hint="builder_summary", force_json_output=True
162
+ )
163
+
164
+ app_form_validate_code_block = app_form_subparsers.add_parser(
165
+ "validate-code-block", help="校验代码块(本地静态 + 后端真跑)"
166
+ )
167
+ app_form_validate_code_block.add_argument("--app-key", required=True, help="目标应用 key")
168
+ app_form_validate_code_block.add_argument(
169
+ "--code", required=True, help="代码文本(或 @文件路径读取)"
170
+ )
171
+ app_form_validate_code_block.add_argument(
172
+ "--test-case",
173
+ required=True,
174
+ help="测试用例 JSON 文件路径,- 表示 stdin;格式 [{keyQueId, ordinal?, values}]",
175
+ )
176
+ app_form_validate_code_block.set_defaults(
177
+ handler=_handle_app_form_validate_code_block, format_hint="builder_summary", force_json_output=True
178
+ )
179
+
131
180
  app = builder_subparsers.add_parser("app", help="应用")
132
181
  app_subparsers = app.add_subparsers(dest="builder_app_command", required=True)
133
182
 
@@ -395,6 +444,283 @@ def _handle_app_form_validate(args: argparse.Namespace, _context: CliContext) ->
395
444
  return {"category": "validation", "status": "success", "ok": True, **result, "message": "AppForm declaration is valid"}
396
445
 
397
446
 
447
+ def _handle_app_form_validate_formula(args: argparse.Namespace, context: CliContext) -> dict:
448
+ formula = args.formula
449
+ test_case = load_json_value(
450
+ args.test_case, option_name="--test-case", max_bytes=1 * 1024 * 1024, max_depth=32
451
+ )
452
+ if not isinstance(test_case, dict):
453
+ raise_config_error(
454
+ "--test-case must be a JSON object {queId: value}", error_code="INVALID_TEST_CASE"
455
+ )
456
+
457
+ # 字段定义:优先 --fields;缺省拉全量(保留 option_details 供选择类值翻译)
458
+ fields_path = getattr(args, "fields", None)
459
+ if fields_path:
460
+ fields = _load_fields_for_validation(args, context)
461
+ else:
462
+ fields = _load_code_block_validation_fields(args, context) or _load_fields_for_validation(args, context)
463
+
464
+ # ① 本地语法校验(函数名 + 字段引用存在性)
465
+ syntax_fields = [
466
+ {"queId": f.get("que_id") or f.get("queId"), "name": f.get("name")} for f in (fields or [])
467
+ ] if fields else None
468
+ syntax_errors = validate_formula_expression(formula, syntax_fields)
469
+
470
+ # ①' 选择类值翻译:实测后端只认 [optId 数组],文案/标量 optId 均报错——把文案自动翻成 [optId]
471
+ test_case, select_changes, option_errors = translate_formula_test_case_values(fields, test_case)
472
+
473
+ # ①'' 覆盖校验:公式引用缺值字段会静默变空(CONCAT 直接跳过)→ 空洞通过,这里拦下
474
+ referenced_ids = referenced_formula_field_ids(formula)
475
+ underivable = _underivable_field_ids(fields)
476
+ covered_ids = {int(k) for k in test_case if str(k).isdigit()}
477
+ missing_ids = sorted((referenced_ids - covered_ids) - underivable)
478
+ coverage_errors = [
479
+ {
480
+ "pointer": "test-case",
481
+ "message": f"field queId {que_id} is referenced by formula but has no test-case value "
482
+ f"(reference would evaluate as empty)",
483
+ }
484
+ for que_id in missing_ids
485
+ ]
486
+
487
+ # ② 后端求值校验(本地有错时不浪费后端调用)
488
+ evaluation = None
489
+ evaluation_error = None
490
+ if not syntax_errors and not coverage_errors and not option_errors:
491
+ result = context.builder.app_form_evaluate_formula(
492
+ profile=args.profile, app_key=args.app_key, formula=formula, test_case=test_case
493
+ )
494
+ evaluation = result.get("evaluation")
495
+ evaluation_error = result.get("evaluationError")
496
+
497
+ ok = bool(
498
+ not syntax_errors
499
+ and not coverage_errors
500
+ and not option_errors
501
+ and isinstance(evaluation, dict)
502
+ and evaluation.get("valid") is True
503
+ )
504
+ return {
505
+ "category": "validation",
506
+ "status": "success" if ok else "failed",
507
+ "ok": ok,
508
+ "syntaxErrors": syntax_errors,
509
+ "coverageErrors": coverage_errors,
510
+ "optionErrors": option_errors,
511
+ "autoFixes": {"selectValuesTranslated": select_changes},
512
+ "evaluation": evaluation,
513
+ "evaluationError": evaluation_error,
514
+ "message": "formula is valid" if ok else "formula validation failed",
515
+ }
516
+
517
+
518
+ def _handle_app_form_validate_code_block(args: argparse.Namespace, context: CliContext) -> dict:
519
+ raw_code = _read_code_arg(args.code)
520
+ # 占位符容错:模型常给字段名加引号(qf_field.{"名$$id$$"}),后端替换正则要求结尾恰为
521
+ # `$$}`,带引号直接匹配失败→占位符原样进 JS→语法错误。先剥离引号再进编码。
522
+ code_after_quotes, quote_fixes = strip_placeholder_quotes(raw_code)
523
+ # 后端运行时按 encryptQueId 的编码形态解码代码里的 $$id$$ 段再匹配 test-case 填值;
524
+ # AI 生成的代码是十进制 queId,先编码归一化(含字母的已编码段原样保留,防双重编码)。
525
+ code = encode_code_block_field_ids(code_after_quotes)
526
+ # 错误回显还原映射:后端错误里出现的编码 id 还原为模型写的十进制,避免模型逆向混淆。
527
+ encoded_id_map = build_encoded_id_map(code_after_quotes)
528
+ test_case = load_json_value(
529
+ args.test_case, option_name="--test-case", max_bytes=1 * 1024 * 1024, max_depth=32
530
+ )
531
+ if not isinstance(test_case, list):
532
+ raise_config_error(
533
+ "--test-case must be a JSON array [{keyQueId, ordinal?, values}]",
534
+ error_code="INVALID_TEST_CASE",
535
+ )
536
+
537
+ # 字段定义(选项翻译/覆盖豁免用;拉取失败不阻断,只是少一层自动修正)
538
+ fields = _load_code_block_validation_fields(args, context)
539
+
540
+ # ① 本地静态校验(qf_output 赋值 + 基本语法)
541
+ static_errors = validate_code_block_static(code)
542
+
543
+ # ①' 选择类值翻译:后端只认选项 optId,传文案静默变 null——把文案自动翻成 optId。
544
+ key_que_values = _normalize_code_block_test_case(test_case)
545
+ key_que_values, select_changes, option_errors = translate_select_test_case_values(fields, key_que_values)
546
+
547
+ # ①'' 测试覆盖校验:代码引用的字段必须在 test-case 中给值(成员/部门类豁免——
548
+ # 其值是 uid/部门 id,模型无从得知,代码须自行 null 安全)。缺值时后端会把占位符
549
+ # 替换成字面量 null 静默跑通(空洞通过)——在这里拦下,逼 test-case 补全而不是放行。
550
+ covered_ids = _code_block_test_case_field_ids(key_que_values)
551
+ underivable = _underivable_field_ids(fields)
552
+ referenced_ids = referenced_code_block_field_ids(raw_code)
553
+ missing_ids = sorted((referenced_ids - covered_ids) - underivable)
554
+ coverage_errors = [
555
+ {
556
+ "pointer": "test-case",
557
+ "message": f"field queId {que_id} is referenced by code but has no test-case value "
558
+ f"(placeholder would run as literal null)",
559
+ }
560
+ for que_id in missing_ids
561
+ ]
562
+
563
+ # ② 后端真跑校验(本地有错时不浪费后端调用)
564
+ run_result = None
565
+ run_error = None
566
+ if not static_errors and not coverage_errors and not option_errors:
567
+ result = context.builder.app_form_run_code_block(
568
+ profile=args.profile, app_key=args.app_key, code=code, key_que_values=key_que_values
569
+ )
570
+ run_result = result.get("runResult")
571
+ run_error = result.get("runError")
572
+
573
+ if isinstance(run_error, str):
574
+ run_error = restore_decimal_ids_in_text(run_error, encoded_id_map)
575
+
576
+ ok = not static_errors and not coverage_errors and not option_errors and run_result is not None
577
+ return {
578
+ "category": "validation",
579
+ "status": "success" if ok else "failed",
580
+ "ok": ok,
581
+ "staticErrors": static_errors,
582
+ "coverageErrors": coverage_errors,
583
+ "optionErrors": option_errors,
584
+ "autoFixes": {
585
+ "placeholderQuotesStripped": quote_fixes,
586
+ "selectValuesTranslated": select_changes,
587
+ },
588
+ "runResult": run_result,
589
+ "runError": run_error,
590
+ "message": "code block is valid" if ok else "code block validation failed",
591
+ }
592
+
593
+
594
+ def _load_code_block_validation_fields(
595
+ args: argparse.Namespace, context: CliContext
596
+ ) -> list[dict] | None:
597
+ """加载字段定义(类型+选项 optId),供选择类值翻译与成员/部门覆盖豁免;失败返回 None。"""
598
+ try:
599
+ fetched = context.builder.app_get_fields(profile=args.profile, app_key=args.app_key)
600
+ except Exception: # noqa: BLE001 - 字段拉取失败不阻断校验主流程
601
+ return None
602
+ if not isinstance(fetched, dict) or fetched.get("status") != "success":
603
+ return None
604
+ raw_fields = fetched.get("fields")
605
+ if not isinstance(raw_fields, list):
606
+ return None
607
+ return [field for field in raw_fields if isinstance(field, dict)]
608
+
609
+
610
+ def _underivable_field_ids(fields: list[dict] | None) -> set[int]:
611
+ """值不可由模型推导的字段(成员/部门):test-case 覆盖校验豁免。"""
612
+ if not fields:
613
+ return set()
614
+ result: set[int] = set()
615
+ for field in fields:
616
+ field_type = str(field.get("type") or "")
617
+ if field_type in {"member", "department"}:
618
+ que_id = field.get("que_id") or field.get("queId")
619
+ if isinstance(que_id, int):
620
+ result.add(que_id)
621
+ return result
622
+
623
+
624
+ def _code_block_test_case_field_ids(key_que_values: list[dict]) -> set[int]:
625
+ """test-case 覆盖的字段 id 集合:顶层 keyQueId + 表格 tableValues 行内 keyQueId。"""
626
+ covered: set[int] = set()
627
+ for item in key_que_values:
628
+ key = item.get("keyQueId")
629
+ if isinstance(key, int):
630
+ covered.add(key)
631
+ for row in item.get("tableValues") or []:
632
+ if isinstance(row, list):
633
+ for cell in row:
634
+ if isinstance(cell, dict) and isinstance(cell.get("keyQueId"), int):
635
+ covered.add(cell["keyQueId"])
636
+ return covered
637
+
638
+
639
+ def _load_fields_for_validation(args: argparse.Namespace, context: CliContext) -> list[dict] | None:
640
+ """加载字段清单:优先 --fields 参数,缺省时从后端拉取应用字段。
641
+
642
+ 后端拉取失败时返回 None(跳过字段引用校验,不阻断流程)。
643
+ """
644
+ fields_path = getattr(args, "fields", None)
645
+ if fields_path:
646
+ fields = load_json_value(
647
+ fields_path, option_name="--fields", max_bytes=2 * 1024 * 1024, max_depth=64
648
+ )
649
+ if not isinstance(fields, list):
650
+ raise_config_error(
651
+ "--fields must be a JSON array of field objects", error_code="INVALID_FIELDS"
652
+ )
653
+ return fields
654
+ fetched = context.builder.app_get_fields(profile=args.profile, app_key=args.app_key)
655
+ if not isinstance(fetched, dict) or fetched.get("status") != "success":
656
+ return None
657
+ raw_fields = fetched.get("fields")
658
+ if not isinstance(raw_fields, list):
659
+ return None
660
+ return [
661
+ {"queId": field.get("que_id"), "name": field.get("name")}
662
+ for field in raw_fields
663
+ if isinstance(field, dict)
664
+ ]
665
+
666
+
667
+ def _read_code_arg(code_arg: str) -> str:
668
+ """读取 --code 参数:@ 前缀读文件内容,否则当文本。"""
669
+ if not isinstance(code_arg, str) or not code_arg:
670
+ raise_config_error("--code is required", error_code="INVALID_CODE")
671
+ if code_arg.startswith("@"):
672
+ path = code_arg[1:]
673
+ try:
674
+ return Path(path).expanduser().read_text(encoding="utf-8")
675
+ except OSError as exc:
676
+ raise_config_error(
677
+ f"failed to read --code from '{path}': {exc}", error_code="INVALID_CODE"
678
+ )
679
+ return code_arg
680
+
681
+
682
+ def _normalize_code_block_test_case(test_case: list) -> list[dict]:
683
+ """将 --test-case 规范化为后端 OpenMatchQueValue 结构 [{keyQueId, ordinal?, values}]。"""
684
+ normalized: list[dict] = []
685
+ for item in test_case:
686
+ if not isinstance(item, dict):
687
+ raise_config_error(
688
+ "--test-case items must be JSON objects {keyQueId, ordinal?, values}",
689
+ error_code="INVALID_TEST_CASE",
690
+ )
691
+ raw_que_id = item.get("keyQueId", item.get("key_que_id"))
692
+ if raw_que_id is None:
693
+ raise_config_error(
694
+ "--test-case items require keyQueId", error_code="INVALID_TEST_CASE"
695
+ )
696
+ try:
697
+ key_que_id = int(raw_que_id)
698
+ except (TypeError, ValueError):
699
+ raise_config_error(
700
+ f"--test-case keyQueId must be an integer, got {raw_que_id!r}",
701
+ error_code="INVALID_TEST_CASE",
702
+ )
703
+ entry: dict = {"keyQueId": key_que_id}
704
+ if item.get("ordinal") is not None:
705
+ try:
706
+ entry["ordinal"] = int(item.get("ordinal"))
707
+ except (TypeError, ValueError):
708
+ raise_config_error(
709
+ f"--test-case ordinal must be an integer, got {item.get('ordinal')!r}",
710
+ error_code="INVALID_TEST_CASE",
711
+ )
712
+ values = item.get("values", [])
713
+ if not isinstance(values, list):
714
+ raise_config_error(
715
+ "--test-case item values must be a JSON array", error_code="INVALID_TEST_CASE"
716
+ )
717
+ entry["values"] = [str(value) for value in values]
718
+ if item.get("tableValues") is not None:
719
+ entry["tableValues"] = item.get("tableValues")
720
+ normalized.append(entry)
721
+ return normalized
722
+
723
+
398
724
  def _handle_app_delete(args: argparse.Namespace, context: CliContext) -> dict:
399
725
  return context.builder.app_delete(profile=args.profile, app_key=args.app_key)
400
726
 
@@ -15,6 +15,7 @@ from ..tools.export_tools import ExportTools
15
15
  from ..tools.feedback_tools import FeedbackTools
16
16
  from ..tools.file_tools import FileTools
17
17
  from ..tools.import_tools import ImportTools
18
+ from ..tools.login_link_tools import LoginLinkTools
18
19
  from ..tools.record_tools import RecordTools
19
20
  from ..tools.resource_read_tools import ResourceReadTools
20
21
  from ..tools.task_context_tools import TaskContextTools
@@ -27,6 +28,7 @@ class CliContext:
27
28
  sessions: CliSessionFacade
28
29
  backend: BackendClient
29
30
  auth: AuthTools
31
+ login_link: LoginLinkTools
30
32
  workspace: WorkspaceTools
31
33
  app: AppTools
32
34
  resource: ResourceReadTools
@@ -57,6 +59,7 @@ def build_cli_context() -> CliContext:
57
59
  sessions=sessions,
58
60
  backend=backend,
59
61
  auth=AuthTools(sessions, backend),
62
+ login_link=LoginLinkTools(sessions, backend),
60
63
  workspace=WorkspaceTools(sessions, backend),
61
64
  app=AppTools(sessions, backend),
62
65
  resource=ResourceReadTools(sessions, backend),
@@ -191,9 +191,10 @@ def _format_app_items(result: dict[str, Any]) -> str:
191
191
  str(item.get("app_key") or item.get("appKey") or ""),
192
192
  str(item.get("app_name") or item.get("name") or item.get("title") or ""),
193
193
  str(item.get("package_name") or item.get("tag_name") or ""),
194
+ str(item.get("uri") or ""),
194
195
  ]
195
196
  )
196
- return _render_titled_table("Apps", ["app_key", "app_name", "package"], rows)
197
+ return _render_titled_table("Apps", ["app_key", "app_name", "package", "uri"], rows)
197
198
 
198
199
 
199
200
  def _format_app_get(result: dict[str, Any]) -> str:
@@ -201,6 +202,7 @@ def _format_app_get(result: dict[str, Any]) -> str:
201
202
  lines = [
202
203
  f"App: {data.get('app_name') or '-'}",
203
204
  f"App Key: {data.get('app_key') or '-'}",
205
+ f"URI: {data.get('uri') or '-'}",
204
206
  f"Can Create: {data.get('can_create')}",
205
207
  ]
206
208
  import_capability = data.get("import_capability")
@@ -224,7 +226,7 @@ def _format_app_get(result: dict[str, Any]) -> str:
224
226
  lines.append(f"Accessible Views: {len(views)}")
225
227
  for item in views[:10]:
226
228
  if isinstance(item, dict):
227
- lines.append(f"- {item.get('view_id')}: {item.get('name')}")
229
+ lines.append(f"- {item.get('view_id')}: {item.get('name')} / {item.get('uri') or '-'}")
228
230
  _append_warnings(lines, result.get("warnings"))
229
231
  return "\n".join(lines) + "\n"
230
232
 
@@ -296,6 +298,7 @@ def _format_view_get(result: dict[str, Any]) -> str:
296
298
  f"View Key: {data.get('view_key') or '-'}",
297
299
  f"App Key: {data.get('app_key') or '-'}",
298
300
  f"View Type: {data.get('view_type') or '-'}",
301
+ f"URI: {data.get('uri') or '-'}",
299
302
  f"Analysis Supported: {data.get('analysis_supported')}",
300
303
  ]
301
304
  if export_capability:
@@ -1184,6 +1187,7 @@ def _format_builder_summary(result: dict[str, Any]) -> str:
1184
1187
  ("view_key", "View Key"),
1185
1188
  ("chart_id", "Chart ID"),
1186
1189
  ("package_id", "Package ID"),
1190
+ ("uri", "URI"),
1187
1191
  ("verified", "Verified"),
1188
1192
  ("write_executed", "Write Executed"),
1189
1193
  ("delete_executed", "Delete Executed"),
@@ -1216,6 +1220,7 @@ def _format_builder_summary(result: dict[str, Any]) -> str:
1216
1220
  "associations",
1217
1221
  "package_ids",
1218
1222
  "app_keys",
1223
+ "resources",
1219
1224
  ):
1220
1225
  value = result.get(key)
1221
1226
  if not isinstance(value, list) and isinstance(data, dict):
@@ -1226,6 +1231,15 @@ def _format_builder_summary(result: dict[str, Any]) -> str:
1226
1231
  lines.append("- " + _builder_item_summary(item))
1227
1232
  if len(value) > 8:
1228
1233
  lines.append(f"... {len(value) - 8} more")
1234
+ if key == "apps":
1235
+ uri_lines = [
1236
+ line
1237
+ for item in value
1238
+ for line in _builder_batch_item_uri_lines(item)
1239
+ ]
1240
+ if uri_lines:
1241
+ lines.append("Resource URIs:")
1242
+ lines.extend(f"- {line}" for line in uri_lines)
1229
1243
  components = result.get("components") if isinstance(result.get("components"), list) else []
1230
1244
  if components:
1231
1245
  lines.append("Component Refs:")
@@ -1294,6 +1308,7 @@ def _builder_item_summary(item: Any) -> str:
1294
1308
  "uid",
1295
1309
  "userId",
1296
1310
  "email",
1311
+ "uri",
1297
1312
  )
1298
1313
  parts = [
1299
1314
  f"{key}={item.get(key)}"
@@ -1305,6 +1320,50 @@ def _builder_item_summary(item: Any) -> str:
1305
1320
  return " / ".join(parts) if parts else json.dumps(item, ensure_ascii=False)
1306
1321
 
1307
1322
 
1323
+ def _builder_batch_item_uri_lines(item: Any) -> list[str]:
1324
+ if not isinstance(item, dict):
1325
+ return []
1326
+ item_status = str(item.get("status") or "").lower()
1327
+ if item_status not in {"success", "partial_success"}:
1328
+ return []
1329
+ data = item.get("data") if isinstance(item.get("data"), dict) else item.get("result")
1330
+ if not isinstance(data, dict) or not _builder_uri_is_confirmed(data, fallback_status=item_status):
1331
+ return []
1332
+ lines: list[str] = []
1333
+ if data.get("uri"):
1334
+ lines.append(f"URI: {data['uri']}")
1335
+ views = data.get("views") if isinstance(data.get("views"), list) else []
1336
+ for view in views:
1337
+ if (
1338
+ isinstance(view, dict)
1339
+ and view.get("uri")
1340
+ and _builder_uri_is_confirmed(view, fallback_status=item_status)
1341
+ ):
1342
+ lines.append(f"View URI: {view['uri']}")
1343
+ return lines
1344
+
1345
+
1346
+ def _builder_uri_is_confirmed(value: dict[str, Any], *, fallback_status: str) -> bool:
1347
+ status = str(value.get("status") or fallback_status).lower()
1348
+ operation = str(value.get("operation") or "").lower()
1349
+ return (
1350
+ status in {"success", "partial_success", "unchanged", "verified"}
1351
+ and operation
1352
+ not in {
1353
+ "failed",
1354
+ "pending",
1355
+ "planned",
1356
+ "readback_pending",
1357
+ "pending_readback",
1358
+ "unknown",
1359
+ "remove",
1360
+ "removed",
1361
+ "delete",
1362
+ "deleted",
1363
+ }
1364
+ )
1365
+
1366
+
1308
1367
  def _format_file_upload_local(result: dict[str, Any]) -> str:
1309
1368
  lines = [
1310
1369
  f"Upload Kind: {result.get('upload_kind') or result.get('requested_upload_kind') or '-'}",
@@ -1566,7 +1625,12 @@ def _task_action_debug_lines(result: dict[str, Any]) -> list[str]:
1566
1625
  return lines
1567
1626
 
1568
1627
 
1628
+ def _format_login_link(result: dict[str, Any]) -> str:
1629
+ return f"登录链接:{result['url']}\n过期时间(Unix 毫秒):{result['expires_at']}\n链接只能使用一次,请勿泄露;仅在用户要求打开时才打开。\n"
1630
+
1631
+
1569
1632
  _FORMATTERS = {
1633
+ "login_link": _format_login_link,
1570
1634
  "auth_whoami": _format_whoami,
1571
1635
  "workspace_list": _format_workspace_list,
1572
1636
  "workspace_get": _format_workspace_get,
@@ -2,16 +2,20 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import os
5
+ import re
5
6
  from pathlib import Path
6
7
  from typing import Any
7
8
  from urllib.parse import urlsplit, urlunsplit
8
9
 
10
+ from .errors import QingflowApiError
11
+
9
12
  DEFAULT_PROFILE = "default"
10
13
  DEFAULT_TIMEOUT_SECONDS = 30.0
11
14
  DEFAULT_USER_AGENT = "qingflow-mcp/1.0"
12
15
  DEFAULT_RECORD_LIST_TYPE = 8
13
16
  ATTACHMENT_QUESTION_TYPE = 13
14
17
  DEFAULT_BASE_URL = "https://qingflow.com/api"
18
+ DEFAULT_WEB_BASE_URL = "https://qingflow.com"
15
19
  DEFAULT_CLI_OAUTH_RESOURCE_URL = "https://mcp.qingflow.com/mcp"
16
20
  DEFAULT_FEEDBACK_APP_KEY = "e0d017kju002"
17
21
  DEFAULT_FEEDBACK_QSOURCE_TOKEN = "mcp-feedback-7755d14748fc"
@@ -153,6 +157,41 @@ def get_default_base_url() -> str | None:
153
157
  return normalize_base_url(value) if value else None
154
158
 
155
159
 
160
+ def get_web_base_url() -> str:
161
+ """读取网页根地址;空白配置逐级回退,不复用后端或 OAuth 地址。"""
162
+ value = os.getenv("QINGFLOW_MCP_WEB_BASE_URL")
163
+ if value is None or not value.strip():
164
+ value = get_config_value("web_base_url", default=DEFAULT_WEB_BASE_URL)
165
+ if isinstance(value, str) and not value.strip():
166
+ value = DEFAULT_WEB_BASE_URL
167
+ try:
168
+ if not isinstance(value, str):
169
+ raise ValueError
170
+ value = value.strip()
171
+ parsed = urlsplit(value)
172
+ host = parsed.hostname or ""
173
+ # urlsplit 会校验 IPv6;域名标签额外拒绝空格、转义字符及无效边界。
174
+ valid_host = bool(host) and (":" in host or all(
175
+ re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label)
176
+ for label in host.rstrip(".").split(".")
177
+ ))
178
+ if (
179
+ parsed.scheme not in {"http", "https"}
180
+ or not valid_host or host.lower() in {"null", "undefined"}
181
+ or parsed.username is not None or parsed.password is not None
182
+ or parsed.path not in {"", "/"} or "?" in value or "#" in value
183
+ or any(character.isspace() or ord(character) < 32 for character in value)
184
+ ):
185
+ raise ValueError
186
+ # 访问 port 触发 urllib 的端口格式与范围校验。
187
+ _ = parsed.port
188
+ return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
189
+ except ValueError:
190
+ raise QingflowApiError.config_error(
191
+ "QINGFLOW_MCP_WEB_BASE_URL(web_base_url)必须是有效的 HTTP(S) 网页根地址,不包含路径、查询参数或用户凭证"
192
+ ) from None
193
+
194
+
156
195
  def get_default_qf_version() -> str | None:
157
196
  """获取默认的 qfVersion 路由值"""
158
197
  value = get_config_value(
@@ -33,6 +33,7 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
33
33
  PublicToolSpec(USER_DOMAIN, "auth_login", cli_route=("auth", "login"), mcp_public=False),
34
34
  PublicToolSpec(USER_DOMAIN, "auth_use_credential", ("auth_use_credential",), ("auth", "use-credential")),
35
35
  PublicToolSpec(USER_DOMAIN, "auth_whoami", ("auth_whoami",), ("auth", "whoami")),
36
+ PublicToolSpec(USER_DOMAIN, "get_login_link", ("get_login_link",), ("auth", "login-link")),
36
37
  PublicToolSpec(USER_DOMAIN, "auth_logout", ("auth_logout",), ("auth", "logout")),
37
38
  PublicToolSpec(USER_DOMAIN, "workspace_list", ("workspace_list",), ("workspace", "list")),
38
39
  PublicToolSpec(USER_DOMAIN, "workspace_get", ("workspace_get",), ("workspace", "get")),
@@ -127,6 +128,7 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
127
128
  PublicToolSpec(BUILDER_DOMAIN, "auth_use_credential", ("auth_use_credential",), ("builder", "auth", "use-credential"), cli_public=False),
128
129
  PublicToolSpec(BUILDER_DOMAIN, "auth_whoami", ("auth_whoami",), ("builder", "auth", "whoami"), cli_public=False),
129
130
  PublicToolSpec(BUILDER_DOMAIN, "auth_logout", ("auth_logout",), ("builder", "auth", "logout"), cli_public=False),
131
+ PublicToolSpec(BUILDER_DOMAIN, "get_login_link", ("get_login_link",), mcp_public=True, cli_public=False),
130
132
  PublicToolSpec(BUILDER_DOMAIN, "workspace_list", ("workspace_list",), ("builder", "workspace", "list"), cli_public=False),
131
133
  PublicToolSpec(BUILDER_DOMAIN, "workspace_get", ("workspace_get",), ("builder", "workspace", "get"), cli_public=False),
132
134
  PublicToolSpec(BUILDER_DOMAIN, "file_upload_local", ("file_upload_local",), ("builder", "file", "upload-local"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
@@ -144,6 +146,8 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
144
146
  PublicToolSpec(BUILDER_DOMAIN, "app_resolve", ("app_resolve",), ("builder", "app", "resolve"), has_contract=True, cli_show_effective_context=True),
145
147
  PublicToolSpec(BUILDER_DOMAIN, "app_form_schema", ("app_form_schema",), ("builder", "app-form", "schema")),
146
148
  PublicToolSpec(BUILDER_DOMAIN, "app_form_validate", (), ("builder", "app-form", "validate"), mcp_public=False),
149
+ PublicToolSpec(BUILDER_DOMAIN, "app_form_validate_formula", (), ("builder", "app-form", "validate-formula"), mcp_public=False),
150
+ PublicToolSpec(BUILDER_DOMAIN, "app_form_validate_code_block", (), ("builder", "app-form", "validate-code-block"), mcp_public=False),
147
151
  PublicToolSpec(BUILDER_DOMAIN, "app_form_get", ("app_form_get",), ("builder", "app-form", "get"), cli_show_effective_context=True),
148
152
  PublicToolSpec(BUILDER_DOMAIN, "app_form_apply", ("app_form_apply",), ("builder", "app-form", "apply"), cli_show_effective_context=True, cli_context_write=True),
149
153
  PublicToolSpec(BUILDER_DOMAIN, "app_delete", ("app_delete",), ("builder", "app", "delete"), cli_show_effective_context=True, cli_context_write=True),