@geoly-ai/social-hub-cli 0.3.47 → 0.3.49
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.
- package/CHANGELOG.md +30 -0
- package/dist/cmd-manifest.json +10 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/permissions-gates-intake.d.ts +8 -0
- package/dist/permissions-gates-intake.d.ts.map +1 -0
- package/dist/permissions-gates-intake.js +38 -0
- package/dist/permissions-gates-intake.js.map +1 -0
- package/dist/permissions-gates-intake.test.d.ts +2 -0
- package/dist/permissions-gates-intake.test.d.ts.map +1 -0
- package/dist/permissions-gates-intake.test.js +37 -0
- package/dist/permissions-gates-intake.test.js.map +1 -0
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +2 -0
- package/dist/permissions.js.map +1 -1
- package/dist/register-intake.d.ts +17 -0
- package/dist/register-intake.d.ts.map +1 -0
- package/dist/register-intake.js +188 -0
- package/dist/register-intake.js.map +1 -0
- package/dist/register-intake.test.d.ts +2 -0
- package/dist/register-intake.test.d.ts.map +1 -0
- package/dist/register-intake.test.js +108 -0
- package/dist/register-intake.test.js.map +1 -0
- package/dist/register-subreddit-assets.js +1 -1
- package/dist/register-subreddit-assets.js.map +1 -1
- package/package.json +3 -3
- package/skills/integrity.json +16 -15
- package/skills/manifest.json +1 -1
- package/skills/reddit-content-rewrite/SKILL.md +1 -1
- package/skills/reddit-content-rewrite/prompts.md +2 -2
- package/skills/reddit-content-rewrite/sop-reference.md +18 -123
- package/skills/reddit-ext-post-audit/references/ext-post-audit.md +6 -7
- package/skills/reddit-matrix.lock.json +3 -3
- package/skills/reddit-voc-volume/SKILL.md +13 -9
- package/skills/reddit-voc-volume/references/arctic-shift.md +1 -1
- package/skills/reddit-voc-volume/requirements.txt +1 -4
- package/skills/reddit-voc-volume/scripts/reddit_voc_volume.py +22 -234
- package/skills/reddit-voc-volume/scripts/setup_env.sh +3 -40
- package/skills/reddit-voc-volume/services/firecrawl/examples/o1_web_crawler/o1_web_crawler.py +152 -152
- package/skills/reddit-voc-volume/tests/test_links_fallback.py +7 -7
- package/skills/reddit-voc-volume/tests/test_no_direct_reddit.py +315 -0
- package/skills/social-hub-admin/SKILL.md +25 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""v2.12.28+ 契约:脚本不得直连 reddit.com,`request_identity` 输出形状保持兼容。
|
|
2
|
+
|
|
3
|
+
静态扫描 + 输出契约;全部离线。成对对照组保证检查器本身不会静默变空。
|
|
4
|
+
|
|
5
|
+
注意:静态部分是**启发式防回归**,不是「无直连」的完整证明——它只看 urllib 请求调用的实参
|
|
6
|
+
(字面量、同函数内的局部赋值、模块级常量)是否指向 reddit.com 主机,动态拼接的 URL 抓不到。
|
|
7
|
+
运行时的兜底是 test_links_fallback / 本文件中把 `urlopen` 打成「调用即失败」的用例。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import contextlib
|
|
13
|
+
import importlib.util
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
import unittest
|
|
20
|
+
from unittest import mock
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "reddit_voc_volume.py"
|
|
24
|
+
SPEC = importlib.util.spec_from_file_location("reddit_voc_no_direct_under_test", SCRIPT)
|
|
25
|
+
assert SPEC is not None and SPEC.loader is not None
|
|
26
|
+
voc = importlib.util.module_from_spec(SPEC)
|
|
27
|
+
SPEC.loader.exec_module(voc)
|
|
28
|
+
|
|
29
|
+
FORBIDDEN_TOKENS = (
|
|
30
|
+
"curl_cffi",
|
|
31
|
+
"browser_cookie3",
|
|
32
|
+
"http_get_json",
|
|
33
|
+
"cffi_requests",
|
|
34
|
+
"RedditHttpClient",
|
|
35
|
+
"CookieStore",
|
|
36
|
+
"IMPERSONATE",
|
|
37
|
+
)
|
|
38
|
+
# 精确匹配 reddit.com 主机:不误报 photon-reddit.com、reddit.com.example.org。
|
|
39
|
+
REDDIT_URL_RE = re.compile(r"^https?://(?:[\w-]+\.)*reddit\.com(?:[:/?#]|$)")
|
|
40
|
+
# urllib 请求调用;subprocess 仅在命令里出现 curl/wget 时才算 HTTP 请求。
|
|
41
|
+
URLLIB_CALLS = {"urlopen", "Request", "firecrawl_urlopen"}
|
|
42
|
+
SUBPROCESS_CALLS = {"run", "Popen", "check_output"}
|
|
43
|
+
HTTP_CLIENT_BINARIES = {"curl", "wget"}
|
|
44
|
+
POST_URL = "https://www.reddit.com/r/example/comments/abc123/slug/"
|
|
45
|
+
IDENTITY_KEYS = {
|
|
46
|
+
"user_agent_kind",
|
|
47
|
+
"fetch_mode",
|
|
48
|
+
"used_cookies",
|
|
49
|
+
"cookie_source",
|
|
50
|
+
"cookie_cache_reused",
|
|
51
|
+
"impersonation",
|
|
52
|
+
"proxy_used",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _strings(node: ast.AST) -> list[str]:
|
|
57
|
+
return [n.value for n in ast.walk(node) if isinstance(n, ast.Constant) and isinstance(n.value, str)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _has_reddit_url(node: ast.AST) -> bool:
|
|
61
|
+
return any(REDDIT_URL_RE.match(s) for s in _strings(node))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _call_name(call: ast.Call) -> str:
|
|
65
|
+
f = call.func
|
|
66
|
+
if isinstance(f, ast.Attribute):
|
|
67
|
+
return f.attr
|
|
68
|
+
if isinstance(f, ast.Name):
|
|
69
|
+
return f.id
|
|
70
|
+
return ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _names_bound_to_reddit_url(scope: ast.AST) -> set[str]:
|
|
74
|
+
out: set[str] = set()
|
|
75
|
+
for n in ast.walk(scope):
|
|
76
|
+
if isinstance(n, ast.Assign) and _has_reddit_url(n.value):
|
|
77
|
+
out.update(t.id for t in n.targets if isinstance(t, ast.Name))
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_http_request(call: ast.Call) -> bool:
|
|
82
|
+
name = _call_name(call)
|
|
83
|
+
if name in URLLIB_CALLS:
|
|
84
|
+
return True
|
|
85
|
+
if name in SUBPROCESS_CALLS:
|
|
86
|
+
return any(s in HTTP_CLIENT_BINARIES for s in _strings(call))
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def functions_requesting_reddit(source: str) -> tuple[list[str], list[str]]:
|
|
91
|
+
"""返回 (发起 HTTP 请求的函数名, 其中请求实参指向 reddit.com 的函数名)。
|
|
92
|
+
|
|
93
|
+
只检查请求调用的实参:reddit URL 作为 permalink / 展示字段,或放进发给 Hub 的 payload,
|
|
94
|
+
都不在请求实参里,不算违规。
|
|
95
|
+
"""
|
|
96
|
+
tree = ast.parse(source)
|
|
97
|
+
module_consts = _names_bound_to_reddit_url(ast.Module(body=[n for n in tree.body if isinstance(n, ast.Assign)], type_ignores=[]))
|
|
98
|
+
requesting: list[str] = []
|
|
99
|
+
offending: list[str] = []
|
|
100
|
+
for fn in ast.walk(tree):
|
|
101
|
+
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
102
|
+
continue
|
|
103
|
+
calls = [n for n in ast.walk(fn) if isinstance(n, ast.Call) and _is_http_request(n)]
|
|
104
|
+
if not calls:
|
|
105
|
+
continue
|
|
106
|
+
requesting.append(fn.name)
|
|
107
|
+
reddit_names = module_consts | _names_bound_to_reddit_url(fn)
|
|
108
|
+
for call in calls:
|
|
109
|
+
arg_nodes = [*call.args, *(kw.value for kw in call.keywords)]
|
|
110
|
+
used = {n.id for a in arg_nodes for n in ast.walk(a) if isinstance(n, ast.Name)}
|
|
111
|
+
if any(_has_reddit_url(a) for a in arg_nodes) or used & reddit_names:
|
|
112
|
+
offending.append(fn.name)
|
|
113
|
+
break
|
|
114
|
+
return requesting, offending
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class StaticScanTests(unittest.TestCase):
|
|
118
|
+
@classmethod
|
|
119
|
+
def setUpClass(cls) -> None:
|
|
120
|
+
cls.source = SCRIPT.read_text(encoding="utf-8")
|
|
121
|
+
|
|
122
|
+
def test_legacy_direct_fetch_tokens_are_gone(self) -> None:
|
|
123
|
+
for token in FORBIDDEN_TOKENS:
|
|
124
|
+
with self.subTest(token=token):
|
|
125
|
+
self.assertNotIn(token, self.source)
|
|
126
|
+
|
|
127
|
+
def test_no_function_that_sends_requests_references_reddit_com(self) -> None:
|
|
128
|
+
requesting, offending = functions_requesting_reddit(self.source)
|
|
129
|
+
# 对照:真实脚本里确实有发请求的函数(Arctic / Firecrawl / Hub),扫描器不是空转。
|
|
130
|
+
self.assertIn("arctic_get", requesting)
|
|
131
|
+
self.assertGreaterEqual(len(requesting), 3)
|
|
132
|
+
self.assertEqual(offending, [])
|
|
133
|
+
|
|
134
|
+
def test_scanner_control_flags_a_direct_reddit_request(self) -> None:
|
|
135
|
+
bad_literal = (
|
|
136
|
+
"import urllib.request\n"
|
|
137
|
+
"def fetch():\n"
|
|
138
|
+
" return urllib.request.urlopen('https://www.reddit.com/r/x/.json')\n"
|
|
139
|
+
)
|
|
140
|
+
bad_module_const = (
|
|
141
|
+
"import urllib.request\n"
|
|
142
|
+
"REDDIT_URL = 'https://old.reddit.com/r/x/'\n"
|
|
143
|
+
"def fetch():\n"
|
|
144
|
+
" req = urllib.request.Request(REDDIT_URL)\n"
|
|
145
|
+
" return urllib.request.urlopen(req)\n"
|
|
146
|
+
)
|
|
147
|
+
bad_local_var = (
|
|
148
|
+
"import urllib.request\n"
|
|
149
|
+
"def fetch(sub):\n"
|
|
150
|
+
" url = f'https://www.reddit.com/r/{sub}/about.json'\n"
|
|
151
|
+
" return urllib.request.urlopen(url)\n"
|
|
152
|
+
)
|
|
153
|
+
bad_curl = (
|
|
154
|
+
"import subprocess\n"
|
|
155
|
+
"def fetch():\n"
|
|
156
|
+
" return subprocess.run(['curl', 'https://www.reddit.com/.json'])\n"
|
|
157
|
+
)
|
|
158
|
+
for name, src in (
|
|
159
|
+
("literal", bad_literal),
|
|
160
|
+
("module_const", bad_module_const),
|
|
161
|
+
("local_var", bad_local_var),
|
|
162
|
+
("curl", bad_curl),
|
|
163
|
+
):
|
|
164
|
+
with self.subTest(case=name):
|
|
165
|
+
self.assertEqual(functions_requesting_reddit(src)[1], ["fetch"])
|
|
166
|
+
|
|
167
|
+
def test_scanner_control_does_not_flag_display_payload_or_lookalike_hosts(self) -> None:
|
|
168
|
+
good = (
|
|
169
|
+
"import subprocess, urllib.request\n"
|
|
170
|
+
"REDDIT_URL = 'https://www.reddit.com/'\n"
|
|
171
|
+
"def fetch():\n"
|
|
172
|
+
" return urllib.request.urlopen('https://arctic-shift.photon-reddit.com/api')\n"
|
|
173
|
+
"def lookalike():\n"
|
|
174
|
+
" return urllib.request.urlopen('https://reddit.com.example.org/x')\n"
|
|
175
|
+
"def permalink(sub):\n"
|
|
176
|
+
" return f'https://www.reddit.com/r/{sub}/'\n"
|
|
177
|
+
"def display(post):\n"
|
|
178
|
+
" post['url'] = REDDIT_URL + 'r/x'\n"
|
|
179
|
+
" return urllib.request.urlopen('https://arctic-shift.photon-reddit.com/api')\n"
|
|
180
|
+
"def to_hub(url):\n"
|
|
181
|
+
" payload = {'url': 'https://www.reddit.com/r/x/', 'target': REDDIT_URL}\n"
|
|
182
|
+
" return subprocess.run(['social-hub', 'scrape', '--json', str(payload)])\n"
|
|
183
|
+
)
|
|
184
|
+
requesting, offending = functions_requesting_reddit(good)
|
|
185
|
+
# 检查器确实扫到了这些请求函数,不是因为扫描为空才没有误报。
|
|
186
|
+
self.assertTrue({"fetch", "lookalike", "display"} <= set(requesting))
|
|
187
|
+
self.assertEqual(offending, [])
|
|
188
|
+
|
|
189
|
+
def test_requirements_have_no_legacy_packages(self) -> None:
|
|
190
|
+
req = (SCRIPT.parents[1] / "requirements.txt").read_text(encoding="utf-8")
|
|
191
|
+
lines = [ln.strip() for ln in req.splitlines() if ln.strip() and not ln.strip().startswith("#")]
|
|
192
|
+
self.assertEqual(lines, [])
|
|
193
|
+
|
|
194
|
+
def test_via_curl_is_deprecated_noop_on_all_four_subcommands(self) -> None:
|
|
195
|
+
cmds = {
|
|
196
|
+
"keyword": ["keyword", "battery"],
|
|
197
|
+
"subreddit": ["subreddit", "battery", "-r", "example"],
|
|
198
|
+
"stickies": ["stickies", "-r", "example"],
|
|
199
|
+
"links": ["links", POST_URL],
|
|
200
|
+
}
|
|
201
|
+
parser_help = io.StringIO()
|
|
202
|
+
for mode, argv in cmds.items():
|
|
203
|
+
with self.subTest(mode=mode):
|
|
204
|
+
stderr = io.StringIO()
|
|
205
|
+
with (
|
|
206
|
+
mock.patch.object(sys, "argv", [str(SCRIPT), *argv, "--via-curl", "--help"]),
|
|
207
|
+
contextlib.redirect_stderr(stderr),
|
|
208
|
+
contextlib.redirect_stdout(parser_help),
|
|
209
|
+
self.assertRaises(SystemExit) as ctx,
|
|
210
|
+
):
|
|
211
|
+
voc.main()
|
|
212
|
+
self.assertEqual(ctx.exception.code, 0) # --help 正常退出,说明参数已注册
|
|
213
|
+
self.assertIn("DEPRECATED", parser_help.getvalue())
|
|
214
|
+
|
|
215
|
+
def test_links_with_via_curl_warns_and_keeps_output_shape(self) -> None:
|
|
216
|
+
def run(extra: list[str]) -> tuple[int, dict, str]:
|
|
217
|
+
stdout, stderr = io.StringIO(), io.StringIO()
|
|
218
|
+
with (
|
|
219
|
+
mock.patch.object(voc.urllib.request, "urlopen", side_effect=AssertionError("direct network call")),
|
|
220
|
+
mock.patch.object(voc, "resolve_proxy_url", return_value=""),
|
|
221
|
+
mock.patch.object(voc, "arctic_post_by_permalink", return_value=None),
|
|
222
|
+
mock.patch.object(voc, "fetch_page_scrape", return_value={"markdown": "Public page"}),
|
|
223
|
+
mock.patch.object(voc, "collect_subreddit_metrics", return_value=[]),
|
|
224
|
+
mock.patch.object(voc, "hub_writeback_items", return_value={"status": "pending"}),
|
|
225
|
+
mock.patch.object(voc, "sleep_with_jitter", return_value=0.0),
|
|
226
|
+
mock.patch.object(sys, "argv", [str(SCRIPT), "links", POST_URL, "--fallback", "auto", "--sleep-sec", "0", *extra]),
|
|
227
|
+
contextlib.redirect_stdout(stdout),
|
|
228
|
+
contextlib.redirect_stderr(stderr),
|
|
229
|
+
):
|
|
230
|
+
code = voc.main()
|
|
231
|
+
return code, json.loads(stdout.getvalue()), stderr.getvalue()
|
|
232
|
+
|
|
233
|
+
code_a, out_a, err_a = run([])
|
|
234
|
+
code_b, out_b, err_b = run(["--via-curl"])
|
|
235
|
+
self.assertEqual((code_a, code_b), (0, 0))
|
|
236
|
+
self.assertNotIn("DEPRECATED", err_a) # 对照:不传则无告警
|
|
237
|
+
self.assertIn("DEPRECATED: --via-curl 已无效果(v2.12.28 起不直连 reddit)", err_b)
|
|
238
|
+
self.assertEqual(set(out_a), set(out_b))
|
|
239
|
+
self.assertEqual(out_a["request_identity"], out_b["request_identity"])
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class RequestIdentityContractTests(unittest.TestCase):
|
|
243
|
+
def build(self, proxy: str, mode: str | None = None) -> dict:
|
|
244
|
+
tracker = voc.RequestTracker()
|
|
245
|
+
if mode is not None:
|
|
246
|
+
tracker.last_fetch_mode = mode
|
|
247
|
+
with mock.patch.object(voc, "resolve_proxy_url", return_value=proxy):
|
|
248
|
+
return voc.build_output(
|
|
249
|
+
ok=True,
|
|
250
|
+
mode="keyword",
|
|
251
|
+
query="q",
|
|
252
|
+
source_urls=[],
|
|
253
|
+
items=[],
|
|
254
|
+
subreddit_metrics=[],
|
|
255
|
+
requested_limit=10,
|
|
256
|
+
effective_limit=10,
|
|
257
|
+
limit_truncated=False,
|
|
258
|
+
sleep_sec=6.0,
|
|
259
|
+
max_pages=1,
|
|
260
|
+
tracker=tracker,
|
|
261
|
+
notes=[],
|
|
262
|
+
errors=[],
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def test_request_identity_shape_and_fixed_values(self) -> None:
|
|
266
|
+
identity = self.build("")["request_identity"]
|
|
267
|
+
self.assertEqual(set(identity), IDENTITY_KEYS)
|
|
268
|
+
self.assertEqual(
|
|
269
|
+
identity,
|
|
270
|
+
{
|
|
271
|
+
"user_agent_kind": "chrome",
|
|
272
|
+
"fetch_mode": "none",
|
|
273
|
+
"used_cookies": False,
|
|
274
|
+
"cookie_source": None,
|
|
275
|
+
"cookie_cache_reused": False,
|
|
276
|
+
"impersonation": None,
|
|
277
|
+
"proxy_used": False,
|
|
278
|
+
},
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def test_proxy_used_follows_resolved_proxy(self) -> None:
|
|
282
|
+
# 对照组:proxy_used 是唯一随环境变化的键,防止断言被写死成常量。
|
|
283
|
+
self.assertIs(self.build("http://127.0.0.1:7890")["request_identity"]["proxy_used"], True)
|
|
284
|
+
self.assertIs(self.build("")["request_identity"]["proxy_used"], False)
|
|
285
|
+
|
|
286
|
+
def test_fetch_mode_reports_tracker_mode_but_never_curl_identity(self) -> None:
|
|
287
|
+
identity = self.build("", mode="browser_fallback")["request_identity"]
|
|
288
|
+
self.assertEqual(identity["fetch_mode"], "browser_fallback")
|
|
289
|
+
self.assertIsNone(identity["impersonation"])
|
|
290
|
+
self.assertIs(identity["used_cookies"], False)
|
|
291
|
+
|
|
292
|
+
def test_links_run_emits_same_identity_without_network(self) -> None:
|
|
293
|
+
stdout = io.StringIO()
|
|
294
|
+
with (
|
|
295
|
+
mock.patch.object(voc.urllib.request, "urlopen", side_effect=AssertionError("direct network call")) as urlopen,
|
|
296
|
+
mock.patch.object(voc, "resolve_proxy_url", return_value=""),
|
|
297
|
+
mock.patch.object(voc, "arctic_post_by_permalink", return_value=None),
|
|
298
|
+
mock.patch.object(voc, "fetch_page_scrape", return_value={"markdown": "Public page"}),
|
|
299
|
+
mock.patch.object(voc, "collect_subreddit_metrics", return_value=[]),
|
|
300
|
+
mock.patch.object(voc, "hub_writeback_items", return_value={"status": "pending"}),
|
|
301
|
+
mock.patch.object(voc, "sleep_with_jitter", return_value=0.0),
|
|
302
|
+
mock.patch.object(sys, "argv", [str(SCRIPT), "links", POST_URL, "--fallback", "auto", "--sleep-sec", "0"]),
|
|
303
|
+
contextlib.redirect_stdout(stdout),
|
|
304
|
+
):
|
|
305
|
+
code = voc.main()
|
|
306
|
+
out = json.loads(stdout.getvalue())
|
|
307
|
+
self.assertEqual(code, 0)
|
|
308
|
+
self.assertEqual(set(out["request_identity"]), IDENTITY_KEYS)
|
|
309
|
+
self.assertEqual(out["request_identity"]["fetch_mode"], "none")
|
|
310
|
+
self.assertIs(out["request_identity"]["used_cookies"], False)
|
|
311
|
+
urlopen.assert_not_called()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
if __name__ == "__main__":
|
|
315
|
+
unittest.main()
|
|
@@ -194,6 +194,31 @@ revoke **不会**恢复已经被拦成 `blocked` 的 job,需要人重新排期
|
|
|
194
194
|
runner 侧的 `restrictions check` / `restrictions report` 同样见 `social-hub-graph-compliance`;
|
|
195
195
|
执行链路上 409 `TARGET_BLOCKED` / `JOB_BLOCKED` 的识别与 agent 停止规则见 `social-hub-ops-runtime`。
|
|
196
196
|
|
|
197
|
+
## 派单品牌映射与接单团队(intake,系统级,仅 admin)
|
|
198
|
+
|
|
199
|
+
CyberRedd 派单进来的订单带外部品牌 `client.brand.{ref,name}`。Hub 用「映射」把外部 ref 对应到 Hub 品牌,并指定哪些团队的账号可以接这个品牌的单;
|
|
200
|
+
订单本身挂在「接单团队」下。**系统级、不需要 `-t`**,只给**不受限登录的 admin**(API key / 服务凭据 / 绑定 job·account 或带动作白名单的 token 全 403)。
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
social-hub intake mappings list [--mapped pending|mapped] [--status active|disabled] [--limit 50] [--offset 0]
|
|
204
|
+
social-hub intake mappings get <id> # 带 rowVersion;set 必须带上它
|
|
205
|
+
# 写:一律 --dry-run 预览 / --apply 执行;--row-version 必填(乐观锁),409 = 版本过期,重新 get 再改
|
|
206
|
+
social-hub intake mappings set <id> --row-version <n> --brand-id <uuid> --team-ids <uuid,uuid> --apply
|
|
207
|
+
social-hub intake mappings set <id> --row-version <n> --status disabled --apply
|
|
208
|
+
social-hub intake order-team get
|
|
209
|
+
social-hub intake order-team set --team-id <uuid> --apply # 团队须存在;未配置时 CyberRedd 建单 503
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
- 新出现的外部品牌 ref 会被 worker 自动登记成「待映射」行(`--mapped pending`),并发一条飞书内部提醒。提醒是**至少一次**:确认发送成功后不再主动重发;发送失败会按指数退避(1 分钟起,上限 6 小时)重试;进程在「发送成功、确认之前」崩溃等异常窗口里可能重复。
|
|
213
|
+
- 已映射的行**至少要有 1 个可用团队**;**已映射的行不能改回未映射**——要停用请 `--status disabled`(停用的映射不触发重评)。
|
|
214
|
+
- 没有物理删除(历史订单引用外部 ref)。
|
|
215
|
+
- **补齐映射后,该品牌下仍是 received 的订单会被自动标记为「品牌已解析」**(写入订单品牌、清除未映射原因码)。注意两个时间不是一回事:扫描 cron 默认每 5 分钟一轮,但每轮只处理一批(最多 200 张订单);received 订单很多时,一张订单要等扫完一整圈才会被重评,延迟可能明显长于 5 分钟。
|
|
216
|
+
**接单(accepted)在后续版本上线**——现在补齐映射不会让订单变成 accepted。CyberRedd 的事件流目前**看不到**「品牌已解析」,
|
|
217
|
+
只会看到未映射期间每天最多一条的 `BRAND_UNMAPPED` 注解。
|
|
218
|
+
- 订单已经解析品牌后,映射再被停用或改了品牌,不会回头改这些订单。
|
|
219
|
+
- 接单团队不能经通用 `settings put` 写(会被 403),只能走 `intake order-team set`(有团队存在性校验与审计)。
|
|
220
|
+
- 每次映射 / 接单团队变更都有审计记录(`intake_config_audit_events`)。
|
|
221
|
+
|
|
197
222
|
## Agent teams
|
|
198
223
|
|
|
199
224
|
```bash
|