diff --git a/.DS_Store b/.DS_Store
deleted file mode 100644
index 8d07aef..0000000
Binary files a/.DS_Store and /dev/null differ
diff --git a/.dockerignore b/.dockerignore
index fe512c9..09dc6ca 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -2,7 +2,11 @@
.run
.cursor
**/node_modules
+**/.env
backend/bin
+backend/web/dist
+backend/web/public/downloads/*.zip
+backend/dev-console/dist
frontend/dist
**/*_test.go
**/.DS_Store
diff --git a/.gitignore b/.gitignore
index 2eefe95..eae3dac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,13 +1,29 @@
-# --- secrets / env(不要 commit 實值)---
+# --- secrets / env ---
deploy/.env
+deploy/.env.dev
+deploy/.env.prod
+infra/.env
+*.env
+**/.env
+!.env.example
+!*.env.example
-# --- build 產物 ---
-backend/bin/
-frontend/dist/
-
-# --- 依賴 ---
-node_modules/
-
-# --- 其他 ---
+# --- runtime / logs ---
+.run/
*.log
+
+# --- build artifacts ---
+backend/bin/
+backend/web/dist/
+backend/dev-console/dist/
+frontend/dist/
+dist/
+backend/web/public/downloads/*.zip
+
+# --- dependencies ---
+node_modules/
+**/node_modules/
+
+# --- OS / editor ---
.DS_Store
+.cursor/
diff --git a/.run/api.pid b/.run/api.pid
deleted file mode 100644
index 8ce9362..0000000
--- a/.run/api.pid
+++ /dev/null
@@ -1 +0,0 @@
-72708
diff --git a/.run/logs/api.log b/.run/logs/api.log
deleted file mode 100644
index 7266262..0000000
--- a/.run/logs/api.log
+++ /dev/null
@@ -1,2857 +0,0 @@
-2026/06/25 23:20:58 job worker started: id=wangxinghuadeMacBook-Pro-204.local-go-worker type=go
-2026/06/25 23:20:58 job scheduler started: holder=wangxinghuadeMacBook-Pro-204.local-scheduler interval=1m0s
-2026/06/25 23:20:58 job reaper started: interval=30s
-Starting backend backend at 0.0.0.0:8890...
-{"@timestamp":"2026-06-25T23:21:00.699+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2063.8ms)","duration":"2063.8ms","level":"slow","span":"903a2ea0cc8e8bfb","trace":"951a1587d9fbdbe17a5acd4b7b26d150"}
-{"@timestamp":"2026-06-25T23:21:00.700+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2063.8ms","level":"info","span":"903a2ea0cc8e8bfb","trace":"951a1587d9fbdbe17a5acd4b7b26d150"}
-{"@timestamp":"2026-06-25T23:21:05.761+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2049.5ms)","duration":"2049.5ms","level":"slow","span":"e2cd040270064cad","trace":"d1cc0d0f6dcce31e4d5c435b488e8bd6"}
-{"@timestamp":"2026-06-25T23:21:05.761+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2049.5ms","level":"info","span":"e2cd040270064cad","trace":"d1cc0d0f6dcce31e4d5c435b488e8bd6"}
-{"@timestamp":"2026-06-25T23:21:10.804+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2036.3ms)","duration":"2036.3ms","level":"slow","span":"e3b6ee784a8391bc","trace":"7684fdb33f70cef5006271bdd481652e"}
-{"@timestamp":"2026-06-25T23:21:10.804+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2036.3ms","level":"info","span":"e3b6ee784a8391bc","trace":"7684fdb33f70cef5006271bdd481652e"}
-{"@timestamp":"2026-06-25T23:21:15.872+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2063.1ms)","duration":"2063.1ms","level":"slow","span":"4c14f55871b3932a","trace":"d44a7bb31a00cad76a395c9bc0a1fb12"}
-{"@timestamp":"2026-06-25T23:21:15.873+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2063.1ms","level":"info","span":"4c14f55871b3932a","trace":"d44a7bb31a00cad76a395c9bc0a1fb12"}
-{"@timestamp":"2026-06-25T23:21:16.853+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63243 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.9ms","level":"info","span":"effd0f41a0300cef","trace":"4cf94eb44e02d137a61273e756709fe0"}
-{"@timestamp":"2026-06-25T23:21:17.735+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:63273 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.5ms","level":"info","span":"b4f7ac175c7572d8","trace":"ec669045143b50bbbda9b98a7011c6f1"}
-{"@timestamp":"2026-06-25T23:21:17.735+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:63272 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"15789f2e479bc44e","trace":"16fc58333cb5c300eefd47f7bb69ba6d"}
-{"@timestamp":"2026-06-25T23:21:17.758+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d5e71242-8a86-4508-90fb-7a74edf2cf6f - 127.0.0.1:63277 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.9ms","level":"info","span":"ca68f085b8ad5df8","trace":"33c0a41ddd31e47be6ff89a45c410190"}
-{"@timestamp":"2026-06-25T23:21:17.758+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d5e71242-8a86-4508-90fb-7a74edf2cf6f - 127.0.0.1:63282 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"0491558a5937a781","trace":"b5a6930db45c46dab604d1be861da49c"}
-{"@timestamp":"2026-06-25T23:21:17.758+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=20&scope=persona&scope_id=d5e71242-8a86-4508-90fb-7a74edf2cf6f - 127.0.0.1:63280 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.5ms","level":"info","span":"0ad3987aa1064db9","trace":"f30b25ac2897032944596e175e2e2a8c"}
-{"@timestamp":"2026-06-25T23:21:17.759+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63278 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.0ms","level":"info","span":"0554a2ed825e1fb9","trace":"2e8e0da6f56f2194f706eca465c4fd45"}
-{"@timestamp":"2026-06-25T23:21:17.760+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63279 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.4ms","level":"info","span":"e03d4e22744edabb","trace":"38c6287c386be304b6bc0cbf83c83bff"}
-{"@timestamp":"2026-06-25T23:21:17.761+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63297 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.8ms","level":"info","span":"4c2250d1477e79f6","trace":"9e4a89ef0f394e99dd75f673ef350983"}
-{"@timestamp":"2026-06-25T23:21:17.762+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=20&scope=persona&scope_id=d5e71242-8a86-4508-90fb-7a74edf2cf6f - 127.0.0.1:63295 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.3ms","level":"info","span":"6ac756f7d913ede3","trace":"c609cf74102c5f3c5bab0791056645d3"}
-{"@timestamp":"2026-06-25T23:21:17.762+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63296 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"e08b3248446cfb96","trace":"14302bfdab1befc06c8ab446c4f8e637"}
-{"@timestamp":"2026-06-25T23:21:17.762+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:63298 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.9ms","level":"info","span":"9864285aeb64c8ed","trace":"7db16781b17317dadd902ff648c782d9"}
-{"@timestamp":"2026-06-25T23:21:17.762+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:63281 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"7.3ms","level":"info","span":"de5aebb764afd4f2","trace":"c40205b4395e8f4aba8f728cb3e22245"}
-{"@timestamp":"2026-06-25T23:21:17.762+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63300 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.5ms","level":"info","span":"2058046e10189a38","trace":"90715a0615cf0827296837c8ab94bad4"}
-{"@timestamp":"2026-06-25T23:21:17.930+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63308 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.8ms","level":"info","span":"768bd1a780d445f1","trace":"ca488dc314cd71d221580c8110b0f6b2"}
-{"@timestamp":"2026-06-25T23:21:17.930+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/cdfe3861-7238-4874-9744-cd913b4245ee/connection - 127.0.0.1:63307 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"9.5ms","level":"info","span":"98d694dfe8e0018e","trace":"27a607ae067aa4bb1f9183caced8a01e"}
-{"@timestamp":"2026-06-25T23:21:17.933+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/cdfe3861-7238-4874-9744-cd913b4245ee/connection - 127.0.0.1:63309 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.3ms","level":"info","span":"1a17383ec74564ae","trace":"ce1a5f75e7599a66f1f6c8700e7644bd"}
-{"@timestamp":"2026-06-25T23:21:17.935+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/cdfe3861-7238-4874-9744-cd913b4245ee/connection - 127.0.0.1:63310 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"d8b7ae97cf24becc","trace":"ed0bd6aa1c098c925442d5e1e6b293a4"}
-{"@timestamp":"2026-06-25T23:21:17.954+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/cdfe3861-7238-4874-9744-cd913b4245ee/connection - 127.0.0.1:63311 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.4ms","level":"info","span":"0e9c7c0261c095a3","trace":"14de14347ed1ea912cde0f4a9fb68f03"}
-{"@timestamp":"2026-06-25T23:21:20.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63313 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.1ms","level":"info","span":"fc41382db181dd08","trace":"034d565a1f9acb479b75d58af79428c7"}
-{"@timestamp":"2026-06-25T23:21:20.916+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2038.3ms)","duration":"2038.3ms","level":"slow","span":"754c61dc40d2ef0d","trace":"3be52fcbae07592e00baea8870e5a567"}
-{"@timestamp":"2026-06-25T23:21:20.916+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2038.3ms","level":"info","span":"754c61dc40d2ef0d","trace":"3be52fcbae07592e00baea8870e5a567"}
-{"@timestamp":"2026-06-25T23:21:22.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63319 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"3878a6f834201848","trace":"91170382c6cbcc53e59acf416a7e29e4"}
-{"@timestamp":"2026-06-25T23:21:24.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63321 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.4ms","level":"info","span":"823376e7838edc25","trace":"a4a429dae24bc4c2216d3f0706a7727b"}
-{"@timestamp":"2026-06-25T23:21:25.975+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2053.2ms)","duration":"2053.2ms","level":"slow","span":"aaf691ab935b4810","trace":"12ed2d62b6386e4afbbc04c152b95298"}
-{"@timestamp":"2026-06-25T23:21:25.975+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2053.2ms","level":"info","span":"aaf691ab935b4810","trace":"12ed2d62b6386e4afbbc04c152b95298"}
-{"@timestamp":"2026-06-25T23:21:26.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63329 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"1c41db93125a0549","trace":"c3b611c6c82f3dcf5a1a7ef64c210591"}
-{"@timestamp":"2026-06-25T23:21:28.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63332 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.0ms","level":"info","span":"688f04cb011a7535","trace":"b8cbafdca9a896ff8ba0b68b94690d6f"}
-{"@timestamp":"2026-06-25T23:21:30.167+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63338 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.9ms","level":"info","span":"800a15c4f2015764","trace":"a14481f5b7bd0f33981a70746aab9daf"}
-{"@timestamp":"2026-06-25T23:21:30.991+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2010.7ms)","duration":"2010.7ms","level":"slow","span":"604c7ccdb93456bd","trace":"a347dca3f0ec6421ec686da6bed56965"}
-{"@timestamp":"2026-06-25T23:21:30.991+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2010.7ms","level":"info","span":"604c7ccdb93456bd","trace":"a347dca3f0ec6421ec686da6bed56965"}
-{"@timestamp":"2026-06-25T23:21:32.166+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63340 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.6ms","level":"info","span":"e95cdcee16b4c99b","trace":"8d8893d4253b03a9f75fdc74c7c79192"}
-{"@timestamp":"2026-06-25T23:21:34.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63346 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.6ms","level":"info","span":"b074893dba71f86e","trace":"ecc9b10291b11aacef7154ed6ddb9dd7"}
-{"@timestamp":"2026-06-25T23:21:36.027+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.2ms)","duration":"2027.2ms","level":"slow","span":"06e85e8241d5cb00","trace":"93bc622224fd16c9a0eb1de5f35ac8b1"}
-{"@timestamp":"2026-06-25T23:21:36.028+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.2ms","level":"info","span":"06e85e8241d5cb00","trace":"93bc622224fd16c9a0eb1de5f35ac8b1"}
-{"@timestamp":"2026-06-25T23:21:36.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63349 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.1ms","level":"info","span":"23530fb79a86c4df","trace":"e851c79fe04e8243e061045f0733db44"}
-{"@timestamp":"2026-06-25T23:21:38.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63354 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.2ms","level":"info","span":"5da62ae56d6506a8","trace":"a73ee0845e5f218c9d3d0e7e46f3e5fb"}
-{"@timestamp":"2026-06-25T23:21:40.167+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63356 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"a6c2a5a39530255e","trace":"8c6c6f98af098f485fdeb69e53d52929"}
-{"@timestamp":"2026-06-25T23:21:41.070+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2039.1ms)","duration":"2039.1ms","level":"slow","span":"3c253c633b600628","trace":"2ee0493c4d10a1bbd236970a51b64c15"}
-{"@timestamp":"2026-06-25T23:21:41.070+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2039.1ms","level":"info","span":"3c253c633b600628","trace":"2ee0493c4d10a1bbd236970a51b64c15"}
-{"@timestamp":"2026-06-25T23:21:42.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63361 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.9ms","level":"info","span":"95c63cae162791ea","trace":"c94f8dd7084e40e51b68a2111c316b89"}
-{"@timestamp":"2026-06-25T23:21:43.765+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63363 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"71eef89ea02669d0","trace":"e5f58d36443f0a8f9ad0927110229b5d"}
-{"@timestamp":"2026-06-25T23:21:45.765+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63371 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.2ms","level":"info","span":"9aa1ee1cd3979657","trace":"2435f14daa1afff96e74a26d0812a334"}
-{"@timestamp":"2026-06-25T23:21:46.100+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.3ms)","duration":"2025.3ms","level":"slow","span":"59732de2a23f90e6","trace":"a787dae2fb17486a4b9f976de1e903ac"}
-{"@timestamp":"2026-06-25T23:21:46.101+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.3ms","level":"info","span":"59732de2a23f90e6","trace":"a787dae2fb17486a4b9f976de1e903ac"}
-{"@timestamp":"2026-06-25T23:21:47.767+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63373 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.9ms","level":"info","span":"8a7cb88b44d24dad","trace":"36e034eccf2d9185c64a76ff7b8ec4dd"}
-{"@timestamp":"2026-06-25T23:21:49.767+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63379 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.2ms","level":"info","span":"e8cfd88aedde15eb","trace":"3105ba922bdc5761ee531cec50104739"}
-{"@timestamp":"2026-06-25T23:21:51.127+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2020.2ms)","duration":"2020.2ms","level":"slow","span":"a2af25cc528cd86f","trace":"ef8c81d72f6e4b2a711e9fe1980a9b0e"}
-{"@timestamp":"2026-06-25T23:21:51.127+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2020.2ms","level":"info","span":"a2af25cc528cd86f","trace":"ef8c81d72f6e4b2a711e9fe1980a9b0e"}
-{"@timestamp":"2026-06-25T23:21:51.766+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63381 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"a6d67220fc6840c7","trace":"3beea41e65dc01a8ed571d79c16cf6fd"}
-{"@timestamp":"2026-06-25T23:21:53.765+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63387 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.4ms","level":"info","span":"7b56c646e51af1a5","trace":"d6d9a50b40bd53468de070c5e24ac146"}
-{"@timestamp":"2026-06-25T23:21:55.765+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63393 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.5ms","level":"info","span":"1ff19d75fd160853","trace":"9c1d8281af4b2d8762d0d7282644d6bf"}
-{"@timestamp":"2026-06-25T23:21:56.174+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2042.1ms)","duration":"2042.1ms","level":"slow","span":"2d841a5f4f34474b","trace":"045bd9467926afd2d1f134d0817a30da"}
-{"@timestamp":"2026-06-25T23:21:56.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2042.1ms","level":"info","span":"2d841a5f4f34474b","trace":"045bd9467926afd2d1f134d0817a30da"}
-{"@timestamp":"2026-06-25T23:21:57.761+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63404 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"49ba950da981cd94","trace":"b3fe8ea5748c3da7d34673f2066ff4d7"}
-{"@timestamp":"2026-06-25T23:21:58.070+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=7.5Mi, Sys=19.3Mi, NumGC=5","level":"stat"}
-{"@timestamp":"2026-06-25T23:21:58.086+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 51, pass: 51, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:21:59.761+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63406 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.7ms","level":"info","span":"e4b4d378f6f3bb6c","trace":"37787f4fb3bf9d32d67969ddbf69f5ff"}
-{"@timestamp":"2026-06-25T23:22:00.701+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.9/s, drops: 0, avg time: 473.0ms, med: 3.5ms, 90th: 2042.1ms, 99th: 2063.7ms, 99.9th: 2063.7ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:22:01.205+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.8ms)","duration":"2025.8ms","level":"slow","span":"363e589de331a69e","trace":"45ac7af54d852e00b50d6c01f653c6c2"}
-{"@timestamp":"2026-06-25T23:22:01.205+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.8ms","level":"info","span":"363e589de331a69e","trace":"45ac7af54d852e00b50d6c01f653c6c2"}
-{"@timestamp":"2026-06-25T23:22:01.766+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63412 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"48e857a47436452a","trace":"3f4c442bbee7823d166a489fc138c249"}
-{"@timestamp":"2026-06-25T23:22:03.764+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63414 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.7ms","level":"info","span":"e7b58c68c98ec6d0","trace":"d0a3922485f8e05d05e2b535ba6a013e"}
-{"@timestamp":"2026-06-25T23:22:05.763+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63420 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.5ms","level":"info","span":"ddfa91c6d5dfed5e","trace":"7a03b77431e942a88681f67f7a16d61c"}
-{"@timestamp":"2026-06-25T23:22:06.263+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2054.5ms)","duration":"2054.5ms","level":"slow","span":"052cc45f99ac36a4","trace":"350596b81248879cfc3ec3dde0cc05ee"}
-{"@timestamp":"2026-06-25T23:22:06.263+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2054.5ms","level":"info","span":"052cc45f99ac36a4","trace":"350596b81248879cfc3ec3dde0cc05ee"}
-{"@timestamp":"2026-06-25T23:22:07.760+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63422 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.6ms","level":"info","span":"5fb29bbf4d081b56","trace":"3653798b54c6ae2bef50c778a8170ea8"}
-{"@timestamp":"2026-06-25T23:22:09.764+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63428 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"fb8a6e65403bd40e","trace":"025eb708616782c91b1f542ee814e16e"}
-{"@timestamp":"2026-06-25T23:22:11.285+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2018.6ms)","duration":"2018.6ms","level":"slow","span":"11c39814b740b7d1","trace":"41af0482f61997a9a0a4be7e22d0eda6"}
-{"@timestamp":"2026-06-25T23:22:11.285+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2018.6ms","level":"info","span":"11c39814b740b7d1","trace":"41af0482f61997a9a0a4be7e22d0eda6"}
-{"@timestamp":"2026-06-25T23:22:11.764+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63430 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.5ms","level":"info","span":"227e65e0c97506a8","trace":"59e37f01211497684520440add1f5ec8"}
-{"@timestamp":"2026-06-25T23:22:13.762+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63436 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.0ms","level":"info","span":"96a15f74cfd10956","trace":"f89ddbcbb791c21eeee12bf7954b5c39"}
-{"@timestamp":"2026-06-25T23:22:15.763+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63438 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.9ms","level":"info","span":"b33095d0a0ac3004","trace":"0680deab2d35db5f7dadd7a3e5b70eed"}
-{"@timestamp":"2026-06-25T23:22:16.340+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2051.0ms)","duration":"2051.0ms","level":"slow","span":"544e48ac9c63e029","trace":"468b5513c77a0bf43bf14db58cb0914c"}
-{"@timestamp":"2026-06-25T23:22:16.340+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2051.0ms","level":"info","span":"544e48ac9c63e029","trace":"468b5513c77a0bf43bf14db58cb0914c"}
-{"@timestamp":"2026-06-25T23:22:17.767+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63444 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"6744f4e8dee37b12","trace":"a2f7cb8a63cd6f38d1c49ac662e36897"}
-{"@timestamp":"2026-06-25T23:22:19.766+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63446 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.5ms","level":"info","span":"723340c04ee348fd","trace":"c14652f1992a0468dbd2b21b01af335a"}
-{"@timestamp":"2026-06-25T23:22:21.368+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.7ms)","duration":"2023.7ms","level":"slow","span":"08d11fe7394cf164","trace":"e3bcabc670bcc01a6fb84a8c5aa3832a"}
-{"@timestamp":"2026-06-25T23:22:21.368+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.7ms","level":"info","span":"08d11fe7394cf164","trace":"e3bcabc670bcc01a6fb84a8c5aa3832a"}
-{"@timestamp":"2026-06-25T23:22:22.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63453 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.9ms","level":"info","span":"3cd93daf66cea043","trace":"f162b0aed6d136640f96b453e816a39b"}
-{"@timestamp":"2026-06-25T23:22:23.996+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63455 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.2ms","level":"info","span":"c732c027456b325a","trace":"848a03cf4238a6e5b4b3a997bc26e910"}
-{"@timestamp":"2026-06-25T23:22:25.028+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:63468 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.8ms","level":"info","span":"79bde6146e277512","trace":"b2e50f3da3ce9f8b6ef806449ea68c82"}
-{"@timestamp":"2026-06-25T23:22:25.028+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:63467 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.5ms","level":"info","span":"42bc5ee5efaa9817","trace":"4cbad254020c873745ef2abe614fdf4e"}
-{"@timestamp":"2026-06-25T23:22:25.044+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d5e71242-8a86-4508-90fb-7a74edf2cf6f - 127.0.0.1:63472 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.9ms","level":"info","span":"e8358c066d592b1b","trace":"2db77fcdc615879546cff2aeafd788c5"}
-{"@timestamp":"2026-06-25T23:22:25.044+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63475 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.9ms","level":"info","span":"f7a4c1592cf05806","trace":"70cd3f1f6725b3261d19ae5719af0d2f"}
-{"@timestamp":"2026-06-25T23:22:25.044+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=20&scope=persona&scope_id=d5e71242-8a86-4508-90fb-7a74edf2cf6f - 127.0.0.1:63473 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.3ms","level":"info","span":"e6c3ebd5b4f98faf","trace":"7fde13c6a69b92b79146c8a82f0496e7"}
-{"@timestamp":"2026-06-25T23:22:25.044+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63474 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.6ms","level":"info","span":"9544c8c2935c438b","trace":"9eacdd577c7ce6394b2ba4fcb6be53d8"}
-{"@timestamp":"2026-06-25T23:22:25.045+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:63476 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.7ms","level":"info","span":"6a16294ff454a2eb","trace":"c88a923a583a90f00f1e3cd0a65fdf90"}
-{"@timestamp":"2026-06-25T23:22:25.046+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/d5e71242-8a86-4508-90fb-7a74edf2cf6f - 127.0.0.1:63479 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.0ms","level":"info","span":"570217f08bbf3357","trace":"d173b645237cca6d8adb6de268599152"}
-{"@timestamp":"2026-06-25T23:22:25.047+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63483 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"1dd7ba230086d25b","trace":"bbe1cdd10f68086bc775cf90ef23df7c"}
-{"@timestamp":"2026-06-25T23:22:25.048+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:63486 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.6ms","level":"info","span":"d22d84e2d2c984e7","trace":"426583f79b421debf6255d30a017e00d"}
-{"@timestamp":"2026-06-25T23:22:25.048+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=20&scope=persona&scope_id=d5e71242-8a86-4508-90fb-7a74edf2cf6f - 127.0.0.1:63484 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.1ms","level":"info","span":"c2b58e4d91891213","trace":"67ea1d3171d74939db16fc66c61f0c52"}
-{"@timestamp":"2026-06-25T23:22:25.048+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63485 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.5ms","level":"info","span":"844ef73576e61912","trace":"1e929e1cc822c299dff9af2e6c3d4cdc"}
-{"@timestamp":"2026-06-25T23:22:25.049+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63488 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.8ms","level":"info","span":"6b2c435ae5e519eb","trace":"36b7968305967d5e00816c805bf41a91"}
-{"@timestamp":"2026-06-25T23:22:25.205+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/cdfe3861-7238-4874-9744-cd913b4245ee/connection - 127.0.0.1:63495 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.3ms","level":"info","span":"7ce807ec119552f8","trace":"9472847e9c983a658b7962d64f89cdec"}
-{"@timestamp":"2026-06-25T23:22:25.208+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63496 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.1ms","level":"info","span":"778a5874f45be4ec","trace":"b680a224d66d78349cfa765136fb7243"}
-{"@timestamp":"2026-06-25T23:22:25.208+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/cdfe3861-7238-4874-9744-cd913b4245ee/connection - 127.0.0.1:63497 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.9ms","level":"info","span":"5635d892cdf52f08","trace":"90b8bf6d1383c9c631992ca546e8d4fd"}
-{"@timestamp":"2026-06-25T23:22:25.211+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/cdfe3861-7238-4874-9744-cd913b4245ee/connection - 127.0.0.1:63498 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.8ms","level":"info","span":"f48af1260d2bfb82","trace":"a5f66e7dddf9ba8b8c7f5cfad14d9c7d"}
-{"@timestamp":"2026-06-25T23:22:25.215+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/cdfe3861-7238-4874-9744-cd913b4245ee/connection - 127.0.0.1:63499 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.5ms","level":"info","span":"7c7fa79f1b0cf9cb","trace":"37a27ded03bf28343ecb321a0c029d58"}
-{"@timestamp":"2026-06-25T23:22:26.406+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.8ms)","duration":"2035.8ms","level":"slow","span":"1d71f11280c6dbb5","trace":"38e02b70cf9c6fe1c0f57df3475e5ee3"}
-{"@timestamp":"2026-06-25T23:22:26.406+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.8ms","level":"info","span":"1d71f11280c6dbb5","trace":"38e02b70cf9c6fe1c0f57df3475e5ee3"}
-{"@timestamp":"2026-06-25T23:22:27.167+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63501 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.7ms","level":"info","span":"dc8f643ed3090184","trace":"2396a240af108cb847a7328425c379d4"}
-{"@timestamp":"2026-06-25T23:22:29.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63506 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.6ms","level":"info","span":"9c0759611fd0e353","trace":"ff867104b043fa97cd6aae00329aa004"}
-{"@timestamp":"2026-06-25T23:22:31.164+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63509 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.6ms","level":"info","span":"13aeab5d0bd473c4","trace":"e13e6ab7d4a8fd330ea77cb8790f4bea"}
-{"@timestamp":"2026-06-25T23:22:31.438+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.2ms)","duration":"2028.2ms","level":"slow","span":"5b5c3fee4f7873c4","trace":"c70c131eae02ef97c67e68b8e04e5349"}
-{"@timestamp":"2026-06-25T23:22:31.438+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.2ms","level":"info","span":"5b5c3fee4f7873c4","trace":"c70c131eae02ef97c67e68b8e04e5349"}
-{"@timestamp":"2026-06-25T23:22:33.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63515 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.1ms","level":"info","span":"8bdc8c74998a89d6","trace":"836d663cad7f3c632bc0e0151fada70a"}
-{"@timestamp":"2026-06-25T23:22:35.176+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63519 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"8.5ms","level":"info","span":"1b2b9b85e67ee453","trace":"2b2f9779684a6fc04cea78a0cc77a1ce"}
-{"@timestamp":"2026-06-25T23:22:36.503+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2061.8ms)","duration":"2061.8ms","level":"slow","span":"347260fd20dad4bb","trace":"b196b7aac59603b735f05343f70b6c12"}
-{"@timestamp":"2026-06-25T23:22:36.503+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2061.8ms","level":"info","span":"347260fd20dad4bb","trace":"b196b7aac59603b735f05343f70b6c12"}
-{"@timestamp":"2026-06-25T23:22:37.167+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63526 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.6ms","level":"info","span":"c771b5422396ec22","trace":"0e454f4a6e8af1b61bbb88bcdab91c4b"}
-{"@timestamp":"2026-06-25T23:22:39.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63529 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.4ms","level":"info","span":"c637e670cfc480b1","trace":"8fe0af9921a6a4fe1aeed77a468998ff"}
-{"@timestamp":"2026-06-25T23:22:41.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63536 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.4ms","level":"info","span":"8e11e6e9563c0f16","trace":"954e21a8e0116bea9db570187d629593"}
-{"@timestamp":"2026-06-25T23:22:41.556+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2049.4ms)","duration":"2049.4ms","level":"slow","span":"b4d13ebff479b016","trace":"709d098accd8fa6230db75f9f3cd97fe"}
-{"@timestamp":"2026-06-25T23:22:41.556+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2049.4ms","level":"info","span":"b4d13ebff479b016","trace":"709d098accd8fa6230db75f9f3cd97fe"}
-{"@timestamp":"2026-06-25T23:22:43.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63538 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.2ms","level":"info","span":"6bb35798fcd33e42","trace":"8234f5d4e99e7628cc7f132f57e96604"}
-{"@timestamp":"2026-06-25T23:22:45.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63546 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.5ms","level":"info","span":"77480f97deb4a8b1","trace":"f90f207e1f17b24ab5e93c486dc9d691"}
-{"@timestamp":"2026-06-25T23:22:46.592+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.2ms)","duration":"2031.2ms","level":"slow","span":"a851442be0c158b6","trace":"62f38ff4fbc7f91d9457922e30701735"}
-{"@timestamp":"2026-06-25T23:22:46.592+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.2ms","level":"info","span":"a851442be0c158b6","trace":"62f38ff4fbc7f91d9457922e30701735"}
-{"@timestamp":"2026-06-25T23:22:47.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63548 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.8ms","level":"info","span":"7198577a0449cf01","trace":"78eda92a7e1d77ecb325858c0b3971ee"}
-{"@timestamp":"2026-06-25T23:22:49.166+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63554 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"d4b9c4c52133371f","trace":"f9e1c5db86df24ae63a133b741cfb493"}
-{"@timestamp":"2026-06-25T23:22:51.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63556 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.1ms","level":"info","span":"d2a69fc320e5c5b4","trace":"b0499a06e4c5d991ba2a5f146d4e6fee"}
-{"@timestamp":"2026-06-25T23:22:51.658+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2061.9ms)","duration":"2061.9ms","level":"slow","span":"68b92bcf59896207","trace":"1cd4fae0b22c9bb2635ac6bd15a0ddd3"}
-{"@timestamp":"2026-06-25T23:22:51.658+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2061.9ms","level":"info","span":"68b92bcf59896207","trace":"1cd4fae0b22c9bb2635ac6bd15a0ddd3"}
-{"@timestamp":"2026-06-25T23:22:53.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63562 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.9ms","level":"info","span":"d8834c60c0239c26","trace":"a4d50b05e8f0b1d5dd211f7d7d8bd0da"}
-{"@timestamp":"2026-06-25T23:22:55.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63564 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.1ms","level":"info","span":"e48294e69df2ea8c","trace":"09db5395467c780c5e02898828fea981"}
-{"@timestamp":"2026-06-25T23:22:56.699+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.8ms)","duration":"2034.8ms","level":"slow","span":"786fee78440cafc1","trace":"e1b1f24c08e8839372c1574fd6caf457"}
-{"@timestamp":"2026-06-25T23:22:56.699+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.8ms","level":"info","span":"786fee78440cafc1","trace":"e1b1f24c08e8839372c1574fd6caf457"}
-{"@timestamp":"2026-06-25T23:22:57.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63569 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.7ms","level":"info","span":"78ef6ae9f75097ab","trace":"34fa750d5f668bbc82436fd70d562034"}
-{"@timestamp":"2026-06-25T23:22:58.070+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.6Mi, TotalAlloc=10.6Mi, Sys=19.6Mi, NumGC=7","level":"stat"}
-{"@timestamp":"2026-06-25T23:22:58.086+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 59, pass: 59, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:22:59.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63572 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"8.4ms","level":"info","span":"ac05efbc5f01c6f4","trace":"d4ca9da8243dca41f26658335b37cdb4"}
-{"@timestamp":"2026-06-25T23:23:00.702+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 1.0/s, drops: 0, avg time: 417.2ms, med: 3.4ms, 90th: 2049.3ms, 99th: 2061.9ms, 99.9th: 2061.9ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:23:01.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63580 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"27dd7b6ce508c9c1","trace":"bb1471b5807e22bbbd3277ddf4cc6b5d"}
-{"@timestamp":"2026-06-25T23:23:01.752+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2050.4ms)","duration":"2050.4ms","level":"slow","span":"c8e0c80e8e75fe1e","trace":"f0078efa94a54ada139226d86873f307"}
-{"@timestamp":"2026-06-25T23:23:01.753+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2050.4ms","level":"info","span":"c8e0c80e8e75fe1e","trace":"f0078efa94a54ada139226d86873f307"}
-{"@timestamp":"2026-06-25T23:23:03.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63582 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.9ms","level":"info","span":"6e85bb2b211a5fc2","trace":"4452858af3443e8ab5ee0329b0ae9be4"}
-{"@timestamp":"2026-06-25T23:23:05.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63588 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.3ms","level":"info","span":"0bdbb1f5df8ee51e","trace":"92541e7b78163584e966eed7fc523f9e"}
-{"@timestamp":"2026-06-25T23:23:06.817+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2059.5ms)","duration":"2059.5ms","level":"slow","span":"f106ae0692959ea8","trace":"7ba603fd2b4bbc3ec1e932ac2a3fc86b"}
-{"@timestamp":"2026-06-25T23:23:06.817+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2059.5ms","level":"info","span":"f106ae0692959ea8","trace":"7ba603fd2b4bbc3ec1e932ac2a3fc86b"}
-{"@timestamp":"2026-06-25T23:23:07.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63590 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.3ms","level":"info","span":"988ce8d48182b3c2","trace":"439300bb124856f10612884bfda7eb92"}
-{"@timestamp":"2026-06-25T23:23:09.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63596 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.9ms","level":"info","span":"d5afd0967247f80b","trace":"c155f9f43205255a6f2f9c56b089af87"}
-{"@timestamp":"2026-06-25T23:23:11.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63598 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.2ms","level":"info","span":"937931e3e7b54ba9","trace":"f920b5163cc169a271493c751a943f71"}
-{"@timestamp":"2026-06-25T23:23:11.872+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2051.4ms)","duration":"2051.4ms","level":"slow","span":"16d15031896a7022","trace":"3058e8c8264e024dc27e82bdb006153b"}
-{"@timestamp":"2026-06-25T23:23:11.872+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2051.4ms","level":"info","span":"16d15031896a7022","trace":"3058e8c8264e024dc27e82bdb006153b"}
-{"@timestamp":"2026-06-25T23:23:13.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63604 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.5ms","level":"info","span":"0b9cddb3c03007d3","trace":"bd5b77c04ab4ef33184301582d45fd1d"}
-{"@timestamp":"2026-06-25T23:23:15.167+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63606 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.8ms","level":"info","span":"0867713ec24ff448","trace":"380cdf53ddc34c66fe0a5567d8da7dc7"}
-{"@timestamp":"2026-06-25T23:23:16.939+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2061.0ms)","duration":"2061.0ms","level":"slow","span":"8d97a28a5719552d","trace":"7f5e44f0692991ace124110d9f68a575"}
-{"@timestamp":"2026-06-25T23:23:16.939+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2061.0ms","level":"info","span":"8d97a28a5719552d","trace":"7f5e44f0692991ace124110d9f68a575"}
-{"@timestamp":"2026-06-25T23:23:17.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63612 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"506898ab04c12dc1","trace":"fddb37cdd3491fb3c0eae585f8087771"}
-{"@timestamp":"2026-06-25T23:23:19.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63614 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.9ms","level":"info","span":"65a5a0d3e02a4608","trace":"8ede8803e7f9223755a163927366e7a2"}
-{"@timestamp":"2026-06-25T23:23:21.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63620 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"66a054a0ee52b684","trace":"65c1a75e5545fcf0f0213932efc8407e"}
-{"@timestamp":"2026-06-25T23:23:21.995+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2050.6ms)","duration":"2050.6ms","level":"slow","span":"a97c628265e9ae50","trace":"84ff6cc9e834ec20311d131fc6536016"}
-{"@timestamp":"2026-06-25T23:23:21.995+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2050.6ms","level":"info","span":"a97c628265e9ae50","trace":"84ff6cc9e834ec20311d131fc6536016"}
-{"@timestamp":"2026-06-25T23:23:23.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63622 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.5ms","level":"info","span":"328db69159e6b205","trace":"a7dd2a115f84e1a515b8c2af2f4aef69"}
-{"@timestamp":"2026-06-25T23:23:25.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63628 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.1ms","level":"info","span":"99d246be7803db8e","trace":"02061ed6186a77b4ee4a3013eea673fe"}
-{"@timestamp":"2026-06-25T23:23:27.063+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2065.3ms)","duration":"2065.3ms","level":"slow","span":"9952d25873e0995b","trace":"c416525a1ae621e41ae76c94f02bf024"}
-{"@timestamp":"2026-06-25T23:23:27.063+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2065.3ms","level":"info","span":"9952d25873e0995b","trace":"c416525a1ae621e41ae76c94f02bf024"}
-{"@timestamp":"2026-06-25T23:23:32.104+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2037.7ms)","duration":"2037.7ms","level":"slow","span":"fcaebc14519b1175","trace":"0092203dcd252c13ea1488733dc02919"}
-{"@timestamp":"2026-06-25T23:23:32.104+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2037.7ms","level":"info","span":"fcaebc14519b1175","trace":"0092203dcd252c13ea1488733dc02919"}
-{"@timestamp":"2026-06-25T23:23:37.187+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2077.1ms)","duration":"2077.1ms","level":"slow","span":"84f8d88d60d67a6c","trace":"32d1f58302ce4437ec65d0eefaad0afd"}
-{"@timestamp":"2026-06-25T23:23:37.187+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2077.1ms","level":"info","span":"84f8d88d60d67a6c","trace":"32d1f58302ce4437ec65d0eefaad0afd"}
-{"@timestamp":"2026-06-25T23:23:42.227+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.0ms)","duration":"2035.0ms","level":"slow","span":"76c7cf2db1e79092","trace":"ab384d50c752ab1a2d9078b6635f5cd6"}
-{"@timestamp":"2026-06-25T23:23:42.227+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.0ms","level":"info","span":"76c7cf2db1e79092","trace":"ab384d50c752ab1a2d9078b6635f5cd6"}
-{"@timestamp":"2026-06-25T23:23:44.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63656 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"786d98a96d021de7","trace":"6b62eea3c2302128ec0a2910fcee116e"}
-{"@timestamp":"2026-06-25T23:23:47.307+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2075.3ms)","duration":"2075.3ms","level":"slow","span":"e0447d8288325637","trace":"c7c4919c6f9151ee38fe4367d10a68e7"}
-{"@timestamp":"2026-06-25T23:23:47.307+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2075.3ms","level":"info","span":"e0447d8288325637","trace":"c7c4919c6f9151ee38fe4367d10a68e7"}
-{"@timestamp":"2026-06-25T23:23:52.340+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.5ms)","duration":"2029.5ms","level":"slow","span":"df109a14ef0d11e7","trace":"58f12ae6736040073e5ed53b5ed3b547"}
-{"@timestamp":"2026-06-25T23:23:52.340+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.5ms","level":"info","span":"df109a14ef0d11e7","trace":"58f12ae6736040073e5ed53b5ed3b547"}
-{"@timestamp":"2026-06-25T23:23:57.410+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2067.0ms)","duration":"2067.0ms","level":"slow","span":"41914d3b838ae0cc","trace":"f76c7dca251088b43cb57ea53a251cd4"}
-{"@timestamp":"2026-06-25T23:23:57.410+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2067.0ms","level":"info","span":"41914d3b838ae0cc","trace":"f76c7dca251088b43cb57ea53a251cd4"}
-{"@timestamp":"2026-06-25T23:23:58.071+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=12.1Mi, Sys=19.6Mi, NumGC=8","level":"stat"}
-{"@timestamp":"2026-06-25T23:23:58.088+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 27, pass: 27, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:24:00.702+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.4/s, drops: 0, avg time: 950.1ms, med: 4.1ms, 90th: 2075.2ms, 99th: 2077.0ms, 99.9th: 2077.0ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:24:02.438+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.6ms)","duration":"2022.6ms","level":"slow","span":"6030f584e227777b","trace":"0a902081df62dc567634a18d44d54c2d"}
-{"@timestamp":"2026-06-25T23:24:02.438+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.6ms","level":"info","span":"6030f584e227777b","trace":"0a902081df62dc567634a18d44d54c2d"}
-{"@timestamp":"2026-06-25T23:24:07.496+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2054.7ms)","duration":"2054.7ms","level":"slow","span":"1605634566769a8d","trace":"0012b6cd78af9530e4b4193ff68c8220"}
-{"@timestamp":"2026-06-25T23:24:07.496+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2054.7ms","level":"info","span":"1605634566769a8d","trace":"0012b6cd78af9530e4b4193ff68c8220"}
-{"@timestamp":"2026-06-25T23:24:12.534+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2036.7ms)","duration":"2036.7ms","level":"slow","span":"e65683837c6f1789","trace":"842cd3a089cbc90e94340c82c96133d9"}
-{"@timestamp":"2026-06-25T23:24:12.534+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2036.7ms","level":"info","span":"e65683837c6f1789","trace":"842cd3a089cbc90e94340c82c96133d9"}
-{"@timestamp":"2026-06-25T23:24:17.567+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.8ms)","duration":"2028.8ms","level":"slow","span":"2b0df0edcb22fa4f","trace":"c97f70ef5bf63e73664f180fc67150ac"}
-{"@timestamp":"2026-06-25T23:24:17.567+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.8ms","level":"info","span":"2b0df0edcb22fa4f","trace":"c97f70ef5bf63e73664f180fc67150ac"}
-{"@timestamp":"2026-06-25T23:24:22.646+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2077.0ms)","duration":"2077.0ms","level":"slow","span":"e2137d3a9a3ebcb2","trace":"65bb6592f429c4c8adeedeeee0e26f1d"}
-{"@timestamp":"2026-06-25T23:24:22.646+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2077.0ms","level":"info","span":"e2137d3a9a3ebcb2","trace":"65bb6592f429c4c8adeedeeee0e26f1d"}
-{"@timestamp":"2026-06-25T23:24:27.711+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2062.2ms)","duration":"2062.2ms","level":"slow","span":"d6ab3b5697def333","trace":"3ebd25c9bdaca513d46adafb23b822dc"}
-{"@timestamp":"2026-06-25T23:24:27.712+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2062.2ms","level":"info","span":"d6ab3b5697def333","trace":"3ebd25c9bdaca513d46adafb23b822dc"}
-{"@timestamp":"2026-06-25T23:24:32.778+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2062.4ms)","duration":"2062.4ms","level":"slow","span":"88a74f00bc07e019","trace":"5bbf116509bde55278bc91a4e65c6e70"}
-{"@timestamp":"2026-06-25T23:24:32.778+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2062.4ms","level":"info","span":"88a74f00bc07e019","trace":"5bbf116509bde55278bc91a4e65c6e70"}
-{"@timestamp":"2026-06-25T23:24:37.834+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2052.8ms)","duration":"2052.8ms","level":"slow","span":"fae2ea40013439ba","trace":"6b42e6dae44a7ab2f5bf3646f01e4937"}
-{"@timestamp":"2026-06-25T23:24:37.834+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2052.8ms","level":"info","span":"fae2ea40013439ba","trace":"6b42e6dae44a7ab2f5bf3646f01e4937"}
-{"@timestamp":"2026-06-25T23:24:42.880+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.4ms)","duration":"2044.4ms","level":"slow","span":"0b42051440f3e49b","trace":"c0fbe9f87c3e48c7d0bf50426dfd388e"}
-{"@timestamp":"2026-06-25T23:24:42.881+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.4ms","level":"info","span":"0b42051440f3e49b","trace":"c0fbe9f87c3e48c7d0bf50426dfd388e"}
-{"@timestamp":"2026-06-25T23:24:44.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63737 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"9ff3f58ac764b9e1","trace":"e42f2316e282a3c7ec6057f4313127ed"}
-{"@timestamp":"2026-06-25T23:24:47.939+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2055.0ms)","duration":"2055.0ms","level":"slow","span":"5def9c071aace7d6","trace":"b8c817b2d393f94066bb4dfb274c45a4"}
-{"@timestamp":"2026-06-25T23:24:47.939+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2055.0ms","level":"info","span":"5def9c071aace7d6","trace":"b8c817b2d393f94066bb4dfb274c45a4"}
-{"@timestamp":"2026-06-25T23:24:53.024+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2080.6ms)","duration":"2080.6ms","level":"slow","span":"61fa88a60c6cccf0","trace":"46f0ed51f4db007bf5b06d610d2be303"}
-{"@timestamp":"2026-06-25T23:24:53.024+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2080.6ms","level":"info","span":"61fa88a60c6cccf0","trace":"46f0ed51f4db007bf5b06d610d2be303"}
-{"@timestamp":"2026-06-25T23:24:58.047+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2014.8ms)","duration":"2014.8ms","level":"slow","span":"9d8499548191b420","trace":"a9492b1534f9aca436e448c4fe6bbf4d"}
-{"@timestamp":"2026-06-25T23:24:58.047+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2014.8ms","level":"info","span":"9d8499548191b420","trace":"a9492b1534f9aca436e448c4fe6bbf4d"}
-{"@timestamp":"2026-06-25T23:24:58.073+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.6Mi, TotalAlloc=12.4Mi, Sys=19.6Mi, NumGC=8","level":"stat"}
-{"@timestamp":"2026-06-25T23:24:58.088+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 13, pass: 13, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:25:00.703+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 1891.8ms, med: 2054.7ms, 90th: 2080.4ms, 99th: 2080.4ms, 99.9th: 2080.4ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:25:03.100+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2049.8ms)","duration":"2049.8ms","level":"slow","span":"f2f7e0a166310730","trace":"40430f698f50d55dd0c2d235251298f9"}
-{"@timestamp":"2026-06-25T23:25:03.100+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2049.8ms","level":"info","span":"f2f7e0a166310730","trace":"40430f698f50d55dd0c2d235251298f9"}
-{"@timestamp":"2026-06-25T23:25:08.113+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2010.7ms)","duration":"2010.7ms","level":"slow","span":"65e31bba9dc0c0a2","trace":"4aaefd73afd02b780b849392b34e7ecf"}
-{"@timestamp":"2026-06-25T23:25:08.113+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2010.7ms","level":"info","span":"65e31bba9dc0c0a2","trace":"4aaefd73afd02b780b849392b34e7ecf"}
-{"@timestamp":"2026-06-25T23:25:13.165+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2047.4ms)","duration":"2047.4ms","level":"slow","span":"8b3d5553a62460cb","trace":"850be981238822f4b38f7068ea7ff22a"}
-{"@timestamp":"2026-06-25T23:25:13.165+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2047.4ms","level":"info","span":"8b3d5553a62460cb","trace":"850be981238822f4b38f7068ea7ff22a"}
-{"@timestamp":"2026-06-25T23:25:18.235+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2067.7ms)","duration":"2067.7ms","level":"slow","span":"37032234a5dc3b25","trace":"7505ed1c7d36f69d82168fef6c7a4ab8"}
-{"@timestamp":"2026-06-25T23:25:18.235+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2067.7ms","level":"info","span":"37032234a5dc3b25","trace":"7505ed1c7d36f69d82168fef6c7a4ab8"}
-{"@timestamp":"2026-06-25T23:25:23.283+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2046.0ms)","duration":"2046.0ms","level":"slow","span":"8e4100d09278e4c1","trace":"fd28dd92338888d470d6148232a38831"}
-{"@timestamp":"2026-06-25T23:25:23.284+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2046.0ms","level":"info","span":"8e4100d09278e4c1","trace":"fd28dd92338888d470d6148232a38831"}
-{"@timestamp":"2026-06-25T23:25:28.341+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2052.4ms)","duration":"2052.4ms","level":"slow","span":"377b62e3df612f8b","trace":"56e75ae626e6203bad71ba332144248a"}
-{"@timestamp":"2026-06-25T23:25:28.341+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2052.4ms","level":"info","span":"377b62e3df612f8b","trace":"56e75ae626e6203bad71ba332144248a"}
-{"@timestamp":"2026-06-25T23:25:33.402+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2057.8ms)","duration":"2057.8ms","level":"slow","span":"f485a6253e6e5935","trace":"9a83f77382788a0f2458a699f97d453f"}
-{"@timestamp":"2026-06-25T23:25:33.402+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2057.8ms","level":"info","span":"f485a6253e6e5935","trace":"9a83f77382788a0f2458a699f97d453f"}
-{"@timestamp":"2026-06-25T23:25:38.466+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2061.1ms)","duration":"2061.1ms","level":"slow","span":"a7afefa973200817","trace":"3f5eb1f47655f0a416c8603b460d59f4"}
-{"@timestamp":"2026-06-25T23:25:38.466+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2061.1ms","level":"info","span":"a7afefa973200817","trace":"3f5eb1f47655f0a416c8603b460d59f4"}
-{"@timestamp":"2026-06-25T23:25:43.514+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.1ms)","duration":"2045.1ms","level":"slow","span":"b6f720b90a1e62eb","trace":"7bc2f898c3388a9477bb99745cc92506"}
-{"@timestamp":"2026-06-25T23:25:43.514+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.1ms","level":"info","span":"b6f720b90a1e62eb","trace":"7bc2f898c3388a9477bb99745cc92506"}
-{"@timestamp":"2026-06-25T23:25:44.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 401 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63896 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.2ms","level":"info","span":"fcb29cf77227c051","trace":"2dd761726491e35edebf9817585a5c50"}
-{"@timestamp":"2026-06-25T23:25:44.178+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/auth/refresh - 127.0.0.1:63898 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.3ms","level":"info","span":"b25a6dd8febec790","trace":"5da6aac1e839d37c4b741c11ea5bc597"}
-{"@timestamp":"2026-06-25T23:25:44.184+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63900 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"da90b69bd455ceb7","trace":"802bbdec4a473eba00b128f601f0e39a"}
-{"@timestamp":"2026-06-25T23:25:48.541+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.7ms)","duration":"2022.7ms","level":"slow","span":"c647b94a9f5d446b","trace":"a5171bdb00ec0e0a37984e97591e70e9"}
-{"@timestamp":"2026-06-25T23:25:48.541+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.7ms","level":"info","span":"c647b94a9f5d446b","trace":"a5171bdb00ec0e0a37984e97591e70e9"}
-{"@timestamp":"2026-06-25T23:25:53.584+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2039.3ms)","duration":"2039.3ms","level":"slow","span":"18caad7f9a7561ba","trace":"449c11bdbce24f6ef9acd87eb33b6c89"}
-{"@timestamp":"2026-06-25T23:25:53.585+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2039.3ms","level":"info","span":"18caad7f9a7561ba","trace":"449c11bdbce24f6ef9acd87eb33b6c89"}
-{"@timestamp":"2026-06-25T23:25:58.072+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=13.1Mi, Sys=19.6Mi, NumGC=9","level":"stat"}
-{"@timestamp":"2026-06-25T23:25:58.089+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 15, pass: 14, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:25:58.684+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2093.8ms)","duration":"2093.8ms","level":"slow","span":"3d64ec431c7afd71","trace":"8082d2fa1fc7b72c767eff7901bd68d0"}
-{"@timestamp":"2026-06-25T23:25:58.684+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2093.8ms","level":"info","span":"3d64ec431c7afd71","trace":"8082d2fa1fc7b72c767eff7901bd68d0"}
-{"@timestamp":"2026-06-25T23:26:00.703+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 1639.9ms, med: 2047.3ms, 90th: 2093.7ms, 99th: 2093.7ms, 99.9th: 2093.7ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:26:03.767+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2078.8ms)","duration":"2078.8ms","level":"slow","span":"c125f7423c6ef84d","trace":"b8f46bb1dfd860d06dbf281fca5b24f9"}
-{"@timestamp":"2026-06-25T23:26:03.767+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2078.8ms","level":"info","span":"c125f7423c6ef84d","trace":"b8f46bb1dfd860d06dbf281fca5b24f9"}
-{"@timestamp":"2026-06-25T23:26:08.854+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2082.9ms)","duration":"2082.9ms","level":"slow","span":"be613cc8d8f1946d","trace":"f5b86234428dacfe05645152d3e8552a"}
-{"@timestamp":"2026-06-25T23:26:08.854+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2082.9ms","level":"info","span":"be613cc8d8f1946d","trace":"f5b86234428dacfe05645152d3e8552a"}
-{"@timestamp":"2026-06-25T23:26:13.916+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2057.9ms)","duration":"2057.9ms","level":"slow","span":"cca695efc87e6450","trace":"cfcc372e4d145662db2f21bbd451b7fb"}
-{"@timestamp":"2026-06-25T23:26:13.916+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2057.9ms","level":"info","span":"cca695efc87e6450","trace":"cfcc372e4d145662db2f21bbd451b7fb"}
-{"@timestamp":"2026-06-25T23:26:18.977+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2057.3ms)","duration":"2057.3ms","level":"slow","span":"20fb6400e9f516d3","trace":"0e77d7b1eca54b4ba65bc0e6e19623ff"}
-{"@timestamp":"2026-06-25T23:26:18.977+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2057.3ms","level":"info","span":"20fb6400e9f516d3","trace":"0e77d7b1eca54b4ba65bc0e6e19623ff"}
-{"@timestamp":"2026-06-25T23:26:24.003+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.3ms)","duration":"2022.3ms","level":"slow","span":"ffdd3e6652bd52c0","trace":"ebae617be52fc678be67088eef0bc389"}
-{"@timestamp":"2026-06-25T23:26:24.003+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.3ms","level":"info","span":"ffdd3e6652bd52c0","trace":"ebae617be52fc678be67088eef0bc389"}
-{"@timestamp":"2026-06-25T23:26:29.052+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.7ms)","duration":"2043.7ms","level":"slow","span":"431c7e3b52862ff5","trace":"63090189484e85a6e68eac1931778d03"}
-{"@timestamp":"2026-06-25T23:26:29.052+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.7ms","level":"info","span":"431c7e3b52862ff5","trace":"63090189484e85a6e68eac1931778d03"}
-{"@timestamp":"2026-06-25T23:26:34.099+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2042.2ms)","duration":"2042.2ms","level":"slow","span":"9da4a66898510b0d","trace":"4db1aa020b278bdef88ba2b190054ae2"}
-{"@timestamp":"2026-06-25T23:26:34.099+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2042.2ms","level":"info","span":"9da4a66898510b0d","trace":"4db1aa020b278bdef88ba2b190054ae2"}
-{"@timestamp":"2026-06-25T23:26:39.167+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2063.8ms)","duration":"2063.8ms","level":"slow","span":"96b93e19c553fd88","trace":"3a299d9c00684e24fae6f548bf202ed9"}
-{"@timestamp":"2026-06-25T23:26:39.167+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2063.8ms","level":"info","span":"96b93e19c553fd88","trace":"3a299d9c00684e24fae6f548bf202ed9"}
-{"@timestamp":"2026-06-25T23:26:44.179+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64040 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"12.0ms","level":"info","span":"ffbb68e4226223de","trace":"b08afb541144c1a4dc889209b7c818c1"}
-{"@timestamp":"2026-06-25T23:26:44.211+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2041.0ms)","duration":"2041.0ms","level":"slow","span":"78a6fba4e8187c82","trace":"0173664952585cf922274f97ea4b15a0"}
-{"@timestamp":"2026-06-25T23:26:44.211+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2041.0ms","level":"info","span":"78a6fba4e8187c82","trace":"0173664952585cf922274f97ea4b15a0"}
-{"@timestamp":"2026-06-25T23:26:49.279+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2063.9ms)","duration":"2063.9ms","level":"slow","span":"d55b16330c6283bc","trace":"ea3bbffaf1ba0388a80b4c59f51f91ec"}
-{"@timestamp":"2026-06-25T23:26:49.279+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2063.9ms","level":"info","span":"d55b16330c6283bc","trace":"ea3bbffaf1ba0388a80b4c59f51f91ec"}
-{"@timestamp":"2026-06-25T23:26:54.311+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.7ms)","duration":"2027.7ms","level":"slow","span":"d59f0173311bf20c","trace":"dcdac3fb8265dcf3c3e224dae09e7933"}
-{"@timestamp":"2026-06-25T23:26:54.311+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.7ms","level":"info","span":"d59f0173311bf20c","trace":"dcdac3fb8265dcf3c3e224dae09e7933"}
-{"@timestamp":"2026-06-25T23:26:58.073+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=13.2Mi, Sys=19.6Mi, NumGC=9","level":"stat"}
-{"@timestamp":"2026-06-25T23:26:58.089+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 13, pass: 13, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:26:59.385+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2071.4ms)","duration":"2071.4ms","level":"slow","span":"9fb1e20eb2377829","trace":"310a14095571fa90b2b919c2c52b3d66"}
-{"@timestamp":"2026-06-25T23:26:59.386+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2071.4ms","level":"info","span":"9fb1e20eb2377829","trace":"310a14095571fa90b2b919c2c52b3d66"}
-{"@timestamp":"2026-06-25T23:27:00.703+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 1897.2ms, med: 2057.8ms, 90th: 2082.8ms, 99th: 2082.8ms, 99.9th: 2082.8ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:27:04.448+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2059.7ms)","duration":"2059.7ms","level":"slow","span":"0bd8a2eda0e62e18","trace":"b142e58a710260723033469a9757a40d"}
-{"@timestamp":"2026-06-25T23:27:04.448+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2059.7ms","level":"info","span":"0bd8a2eda0e62e18","trace":"b142e58a710260723033469a9757a40d"}
-{"@timestamp":"2026-06-25T23:27:09.500+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2047.9ms)","duration":"2047.9ms","level":"slow","span":"2173abea4044a125","trace":"2d8ab3549da67439a957fa6d9113c4c7"}
-{"@timestamp":"2026-06-25T23:27:09.500+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2047.9ms","level":"info","span":"2173abea4044a125","trace":"2d8ab3549da67439a957fa6d9113c4c7"}
-{"@timestamp":"2026-06-25T23:27:14.553+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2048.9ms)","duration":"2048.9ms","level":"slow","span":"226c92146f23fe48","trace":"146779ab2332357229af643c5d1858f5"}
-{"@timestamp":"2026-06-25T23:27:14.553+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2048.9ms","level":"info","span":"226c92146f23fe48","trace":"146779ab2332357229af643c5d1858f5"}
-{"@timestamp":"2026-06-25T23:27:19.602+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.6ms)","duration":"2045.6ms","level":"slow","span":"ce98288e5e171dc0","trace":"148941bb7d25944880b5a9519cff2118"}
-{"@timestamp":"2026-06-25T23:27:19.603+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.6ms","level":"info","span":"ce98288e5e171dc0","trace":"148941bb7d25944880b5a9519cff2118"}
-{"@timestamp":"2026-06-25T23:27:23.789+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64122 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.1ms","level":"info","span":"c5afd75f44f727d6","trace":"f01f1ed214c9ce2475826fdabda730e2"}
-{"@timestamp":"2026-06-25T23:27:24.672+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2068.6ms)","duration":"2068.6ms","level":"slow","span":"4fdb53260e6b4928","trace":"149c88646cada3349945784c408b6ab3"}
-{"@timestamp":"2026-06-25T23:27:24.672+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2068.6ms","level":"info","span":"4fdb53260e6b4928","trace":"149c88646cada3349945784c408b6ab3"}
-{"@timestamp":"2026-06-25T23:27:25.057+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64126 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.4ms","level":"info","span":"d9646fd44b923e18","trace":"32f97dbabdba054d3412cfe32dbf48b1"}
-{"@timestamp":"2026-06-25T23:27:27.052+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64135 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.6ms","level":"info","span":"d4ec4236132624b8","trace":"2398da95caa759b6ab55802da4497de0"}
-{"@timestamp":"2026-06-25T23:27:29.056+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64139 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"8.3ms","level":"info","span":"2159e9e869fb6a7d","trace":"7a86dde370eac69903e67f30b1148607"}
-{"@timestamp":"2026-06-25T23:27:29.779+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2103.4ms)","duration":"2103.4ms","level":"slow","span":"3dfd1fa162259abf","trace":"dd1288a237f971dcac90b92e3bfa540b"}
-{"@timestamp":"2026-06-25T23:27:29.779+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2103.4ms","level":"info","span":"3dfd1fa162259abf","trace":"dd1288a237f971dcac90b92e3bfa540b"}
-{"@timestamp":"2026-06-25T23:27:31.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64145 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.7ms","level":"info","span":"3f53684a1ef06b57","trace":"bb4d8b2db2e3df73cd4e75107f9e6f57"}
-{"@timestamp":"2026-06-25T23:27:33.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64149 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"dbf8c1eb9c24ee00","trace":"e81e3d2aabdde00b424a686692cb5a36"}
-{"@timestamp":"2026-06-25T23:27:34.849+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2065.9ms)","duration":"2065.9ms","level":"slow","span":"67a2397ac7237999","trace":"35257ea163d490bec921eec80f1ec878"}
-{"@timestamp":"2026-06-25T23:27:34.849+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2065.9ms","level":"info","span":"67a2397ac7237999","trace":"35257ea163d490bec921eec80f1ec878"}
-{"@timestamp":"2026-06-25T23:27:35.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64159 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.8ms","level":"info","span":"4c5141b3c66627b9","trace":"763a0894709f23b7a0359942bcc217a1"}
-{"@timestamp":"2026-06-25T23:27:37.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64163 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.1ms","level":"info","span":"d5d1ac431686082f","trace":"c915287ebc7a96757659e2db7bb8f74d"}
-{"@timestamp":"2026-06-25T23:27:39.182+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64171 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"293fd37c84459c83","trace":"d7c31d8545dd3afa2dd76ec95d493d4d"}
-{"@timestamp":"2026-06-25T23:27:39.887+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.0ms)","duration":"2034.0ms","level":"slow","span":"7f9c6154a5a7a389","trace":"e581da17304d58865f98bc5010a889fe"}
-{"@timestamp":"2026-06-25T23:27:39.887+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.0ms","level":"info","span":"7f9c6154a5a7a389","trace":"e581da17304d58865f98bc5010a889fe"}
-{"@timestamp":"2026-06-25T23:27:41.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64176 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.0ms","level":"info","span":"b19891a7273fd208","trace":"9f157e03014618b096d1866359b4f7e3"}
-{"@timestamp":"2026-06-25T23:27:43.179+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64182 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.3ms","level":"info","span":"1503df5e8cc99226","trace":"206b710ffe96e16f16c28d3ab8068274"}
-{"@timestamp":"2026-06-25T23:27:44.920+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.4ms)","duration":"2029.4ms","level":"slow","span":"08708004ada52eb5","trace":"a052948a6b0060095f0a2d8e03c472b8"}
-{"@timestamp":"2026-06-25T23:27:44.921+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.4ms","level":"info","span":"08708004ada52eb5","trace":"a052948a6b0060095f0a2d8e03c472b8"}
-{"@timestamp":"2026-06-25T23:27:45.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64184 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.3ms","level":"info","span":"c58dceea5839c62f","trace":"2d6fc986b13144a263a5735e4c57d58c"}
-{"@timestamp":"2026-06-25T23:27:47.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64197 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.2ms","level":"info","span":"5f855cc6b650f4ad","trace":"1651532fc8bd427e0b5ec1f7ffea5f4d"}
-{"@timestamp":"2026-06-25T23:27:49.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64199 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.2ms","level":"info","span":"d3db9515f0fde7db","trace":"32145af5c2c40f59df3d002da44cc260"}
-{"@timestamp":"2026-06-25T23:27:49.950+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.4ms)","duration":"2026.4ms","level":"slow","span":"c5b93ccb05d27ed0","trace":"e63c3d5d10ac2564cadd6dabb977b825"}
-{"@timestamp":"2026-06-25T23:27:49.951+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.4ms","level":"info","span":"c5b93ccb05d27ed0","trace":"e63c3d5d10ac2564cadd6dabb977b825"}
-{"@timestamp":"2026-06-25T23:27:51.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64202 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.4ms","level":"info","span":"c06f457a4254d3d8","trace":"13e67edb5c4493d5ba56d891915ec726"}
-{"@timestamp":"2026-06-25T23:27:53.176+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64204 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.8ms","level":"info","span":"7aeffc5e1c70af0d","trace":"b107379d40caee2968c2401f324caff6"}
-{"@timestamp":"2026-06-25T23:27:54.990+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.8ms)","duration":"2035.8ms","level":"slow","span":"7633fa78a5b5929d","trace":"32f872266b2daabeb293680982807ae1"}
-{"@timestamp":"2026-06-25T23:27:54.990+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.8ms","level":"info","span":"7633fa78a5b5929d","trace":"32f872266b2daabeb293680982807ae1"}
-{"@timestamp":"2026-06-25T23:27:55.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64211 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.3ms","level":"info","span":"52c49f30d90e4af6","trace":"43d6d5d7192946e64bbd9ce3dffb716a"}
-{"@timestamp":"2026-06-25T23:27:57.177+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64213 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.5ms","level":"info","span":"4c93a0634fe3bf8a","trace":"6af1fd9ea5e926fd9fe040a5dd4aeeb4"}
-{"@timestamp":"2026-06-25T23:27:58.074+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.9Mi, TotalAlloc=14.7Mi, Sys=19.6Mi, NumGC=10","level":"stat"}
-{"@timestamp":"2026-06-25T23:27:58.090+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 30, pass: 30, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:27:59.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64223 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.4ms","level":"info","span":"b59ee02d2e6ab645","trace":"a40ed97121a8e1a8856a73e3540307a0"}
-{"@timestamp":"2026-06-25T23:28:00.015+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.0ms)","duration":"2022.0ms","level":"slow","span":"7bb2a696b3db1870","trace":"ed65f60beedf5697aea2fac68c71db2d"}
-{"@timestamp":"2026-06-25T23:28:00.015+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.0ms","level":"info","span":"7bb2a696b3db1870","trace":"ed65f60beedf5697aea2fac68c71db2d"}
-{"@timestamp":"2026-06-25T23:28:00.704+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.5/s, drops: 0, avg time: 795.6ms, med: 5.3ms, 90th: 2065.8ms, 99th: 2103.3ms, 99.9th: 2103.3ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:28:01.176+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64226 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.0ms","level":"info","span":"24099aa91443c7b3","trace":"afbd9b817b99399d7b3e1d5578a25692"}
-{"@timestamp":"2026-06-25T23:28:03.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64228 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.7ms","level":"info","span":"10fec45090658c25","trace":"66bcb8acaf9bae9fa4c8ffb4c3e84749"}
-{"@timestamp":"2026-06-25T23:28:05.024+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2004.0ms)","duration":"2004.0ms","level":"slow","span":"82fc6b6150850cb8","trace":"af1b30a0e24d1612785d006f8005cffa"}
-{"@timestamp":"2026-06-25T23:28:05.024+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2004.0ms","level":"info","span":"82fc6b6150850cb8","trace":"af1b30a0e24d1612785d006f8005cffa"}
-{"@timestamp":"2026-06-25T23:28:05.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64230 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.5ms","level":"info","span":"a711f7d1e7bbaeb0","trace":"ff60082bff3c25f90ab08fb54698c546"}
-{"@timestamp":"2026-06-25T23:28:07.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64238 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.2ms","level":"info","span":"c0ef429976bf533f","trace":"ead04edc736fcd99f935e77b8063daad"}
-{"@timestamp":"2026-06-25T23:28:09.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64242 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.1ms","level":"info","span":"96238dcbb31de561","trace":"8661a8cd6b65506e59f9f2a5ac64bc21"}
-{"@timestamp":"2026-06-25T23:28:10.030+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2003.2ms)","duration":"2003.2ms","level":"slow","span":"06670bb0c37b6744","trace":"a672471b47b5f781004fb91f3da7eda4"}
-{"@timestamp":"2026-06-25T23:28:10.030+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2003.2ms","level":"info","span":"06670bb0c37b6744","trace":"a672471b47b5f781004fb91f3da7eda4"}
-{"@timestamp":"2026-06-25T23:28:11.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64249 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"19b5471c11f37d07","trace":"1348f5c7fc2422827669a3259cfcfd75"}
-{"@timestamp":"2026-06-25T23:28:13.177+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64251 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.5ms","level":"info","span":"d82c13b7ad6c4e24","trace":"85c1c6d06e7e385e6faee4fde37b7788"}
-{"@timestamp":"2026-06-25T23:28:15.090+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2056.3ms)","duration":"2056.3ms","level":"slow","span":"6f27bc714e35574d","trace":"1e8783d20b916c6abf8f557599879d9b"}
-{"@timestamp":"2026-06-25T23:28:15.090+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2056.3ms","level":"info","span":"6f27bc714e35574d","trace":"1e8783d20b916c6abf8f557599879d9b"}
-{"@timestamp":"2026-06-25T23:28:15.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64256 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"b40a914a13120afd","trace":"1b30b7baafddf3e54ff2f71edd1f9b6c"}
-{"@timestamp":"2026-06-25T23:28:17.177+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64259 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.6ms","level":"info","span":"99f00eacb5089bfb","trace":"a64eeffa5d1fdabc92b638422176de29"}
-{"@timestamp":"2026-06-25T23:28:19.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64266 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.0ms","level":"info","span":"f8f934e247d3318e","trace":"da0b01de4a4eb82e0d2aaab3ca4344a2"}
-{"@timestamp":"2026-06-25T23:28:20.128+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.0ms)","duration":"2034.0ms","level":"slow","span":"cbc778e378f89fac","trace":"9bce7a62ba9f8b2d3eee58392055bdd9"}
-{"@timestamp":"2026-06-25T23:28:20.128+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.0ms","level":"info","span":"cbc778e378f89fac","trace":"9bce7a62ba9f8b2d3eee58392055bdd9"}
-{"@timestamp":"2026-06-25T23:28:21.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64268 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.3ms","level":"info","span":"408cb1576b761b36","trace":"a7cd928a7bd34c8a4f3d57d980a18661"}
-{"@timestamp":"2026-06-25T23:28:23.053+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64274 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.0ms","level":"info","span":"42654f43852de2c1","trace":"860d20962a89f44d29782172f91b0482"}
-{"@timestamp":"2026-06-25T23:28:25.158+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.9ms)","duration":"2026.9ms","level":"slow","span":"0a519afce1f2fb33","trace":"65559d4643b2b4ba14d70574c3661615"}
-{"@timestamp":"2026-06-25T23:28:25.159+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.9ms","level":"info","span":"0a519afce1f2fb33","trace":"65559d4643b2b4ba14d70574c3661615"}
-{"@timestamp":"2026-06-25T23:28:25.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64277 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.6ms","level":"info","span":"8837a37109f7566e","trace":"acf311b4b2d638cd7a585b9f8ddf258e"}
-{"@timestamp":"2026-06-25T23:28:27.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64287 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.2ms","level":"info","span":"c8370c89e9a9fcc2","trace":"0e01fab6f1b9309b96704e8d9eee453b"}
-{"@timestamp":"2026-06-25T23:28:29.167+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64290 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.7ms","level":"info","span":"43f41d63c8cb518c","trace":"82803c7ebb720c3bb76fceea6e738a35"}
-{"@timestamp":"2026-06-25T23:28:30.188+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.3ms)","duration":"2026.3ms","level":"slow","span":"0b62538c9690d6dc","trace":"45b30d882031898ffa3db4786dc24470"}
-{"@timestamp":"2026-06-25T23:28:30.188+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.3ms","level":"info","span":"0b62538c9690d6dc","trace":"45b30d882031898ffa3db4786dc24470"}
-{"@timestamp":"2026-06-25T23:28:31.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64295 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.2ms","level":"info","span":"f8455c0775a0ae50","trace":"2f1282693d56e17535f70bafd0c8680c"}
-{"@timestamp":"2026-06-25T23:28:33.177+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64301 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.0ms","level":"info","span":"49d037b0f0eb827a","trace":"ae6336467303c84541219c53f67a0c87"}
-{"@timestamp":"2026-06-25T23:28:35.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64309 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.2ms","level":"info","span":"45ba8dc54e5eb35e","trace":"ba09f323ef7ba17aaba8b59b5cb63bd3"}
-{"@timestamp":"2026-06-25T23:28:35.221+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.1ms)","duration":"2029.1ms","level":"slow","span":"f3f24841968f4ab9","trace":"60a66d8037057312da5192690241987b"}
-{"@timestamp":"2026-06-25T23:28:35.221+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.1ms","level":"info","span":"f3f24841968f4ab9","trace":"60a66d8037057312da5192690241987b"}
-{"@timestamp":"2026-06-25T23:28:37.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64318 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.8ms","level":"info","span":"8fba98c1a56e43c8","trace":"354bd5ab2edcf73c6def205af08cd964"}
-{"@timestamp":"2026-06-25T23:28:39.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64321 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"cdac7fe5ba30e0ea","trace":"29a9f7b086a6a6764fc244929c414a20"}
-{"@timestamp":"2026-06-25T23:28:40.252+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.1ms)","duration":"2026.1ms","level":"slow","span":"2909371b8d4150db","trace":"c7fbe225c61aa4ce2402889ee0515b88"}
-{"@timestamp":"2026-06-25T23:28:40.252+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.1ms","level":"info","span":"2909371b8d4150db","trace":"c7fbe225c61aa4ce2402889ee0515b88"}
-{"@timestamp":"2026-06-25T23:28:41.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64323 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.3ms","level":"info","span":"8a1a172919540a7e","trace":"d6b91ec50e7a083f1835c9b72ae536d9"}
-{"@timestamp":"2026-06-25T23:28:43.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64331 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.5ms","level":"info","span":"10f813c405b230e8","trace":"6392123895dd9778160f9b3615f8368a"}
-{"@timestamp":"2026-06-25T23:28:45.177+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64333 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.8ms","level":"info","span":"18711de017df6631","trace":"9e2291c807f3cc8db9ea85d662ad488e"}
-{"@timestamp":"2026-06-25T23:28:45.269+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.0ms)","duration":"2013.0ms","level":"slow","span":"f2fe0f31e66fe02b","trace":"15dfa2ad6d23cb07a7d8e8bfdc4c02ef"}
-{"@timestamp":"2026-06-25T23:28:45.269+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.0ms","level":"info","span":"f2fe0f31e66fe02b","trace":"15dfa2ad6d23cb07a7d8e8bfdc4c02ef"}
-{"@timestamp":"2026-06-25T23:28:47.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64339 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.7ms","level":"info","span":"af821ff4d4ededb2","trace":"b5caedcd7a46684ac987606f1b319b0e"}
-{"@timestamp":"2026-06-25T23:28:49.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64341 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.6ms","level":"info","span":"dc442e60870610bf","trace":"119ac5052d06b31c3289524d916269b4"}
-{"@timestamp":"2026-06-25T23:28:50.307+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2033.1ms)","duration":"2033.1ms","level":"slow","span":"c6198edcc989baea","trace":"aa315305e5e0a587622803eae99e0ec8"}
-{"@timestamp":"2026-06-25T23:28:50.307+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2033.1ms","level":"info","span":"c6198edcc989baea","trace":"aa315305e5e0a587622803eae99e0ec8"}
-{"@timestamp":"2026-06-25T23:28:51.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64348 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.3ms","level":"info","span":"90a78a1d88c79e0e","trace":"7bf1dcb16b3fa274c8fbd9d0cde5885b"}
-{"@timestamp":"2026-06-25T23:28:53.176+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64351 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.3ms","level":"info","span":"c9296702b5f16938","trace":"02ada9cd2a6731670bacf6e505b573de"}
-{"@timestamp":"2026-06-25T23:28:55.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64359 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.7ms","level":"info","span":"c5196e4b8b3bf53e","trace":"e56d75a7a6b90754dddadef33cc69fed"}
-{"@timestamp":"2026-06-25T23:28:55.328+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2019.7ms)","duration":"2019.7ms","level":"slow","span":"b0559f3e27d628b7","trace":"be8932ff5825c72d43fdbd55e5dccb2d"}
-{"@timestamp":"2026-06-25T23:28:55.328+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2019.7ms","level":"info","span":"b0559f3e27d628b7","trace":"be8932ff5825c72d43fdbd55e5dccb2d"}
-{"@timestamp":"2026-06-25T23:28:57.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64365 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.1ms","level":"info","span":"31db38cb4434a033","trace":"7372ccf1d3c6cdc086d3f8ef39b57978"}
-{"@timestamp":"2026-06-25T23:28:58.074+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=17.3Mi, Sys=19.6Mi, NumGC=12","level":"stat"}
-{"@timestamp":"2026-06-25T23:28:58.091+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 41, pass: 42, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:28:59.179+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64373 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"9.9ms","level":"info","span":"9891c6f6fc3fd888","trace":"aa2bf8443d349ed5d30f821a2f5ef9e3"}
-{"@timestamp":"2026-06-25T23:29:00.341+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2007.0ms)","duration":"2007.0ms","level":"slow","span":"cee9bed454f2ff11","trace":"7a35456fd9899f86738435456c0298ec"}
-{"@timestamp":"2026-06-25T23:29:00.341+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2007.0ms","level":"info","span":"cee9bed454f2ff11","trace":"7a35456fd9899f86738435456c0298ec"}
-{"@timestamp":"2026-06-25T23:29:00.705+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.7/s, drops: 0, avg time: 581.0ms, med: 4.5ms, 90th: 2029.0ms, 99th: 2056.2ms, 99.9th: 2056.2ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:29:01.177+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64375 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.0ms","level":"info","span":"0132e1c82ae2803d","trace":"3b415e6d5dc65e84def1933c4ec2cb8d"}
-{"@timestamp":"2026-06-25T23:29:03.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64383 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.6ms","level":"info","span":"a25dee13b288e97b","trace":"716616ff3f95fe49e05b3273ab16370f"}
-{"@timestamp":"2026-06-25T23:29:05.176+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64386 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.6ms","level":"info","span":"8e16fd08ab8f47dc","trace":"74637d6d0e3c3dca28f3bf90448c377b"}
-{"@timestamp":"2026-06-25T23:29:05.360+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2015.4ms)","duration":"2015.4ms","level":"slow","span":"1b666a3a93b5f97e","trace":"2d17227d856dda08970bc324e922e28f"}
-{"@timestamp":"2026-06-25T23:29:05.360+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2015.4ms","level":"info","span":"1b666a3a93b5f97e","trace":"2d17227d856dda08970bc324e922e28f"}
-{"@timestamp":"2026-06-25T23:29:07.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64396 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.3ms","level":"info","span":"57e1d23c89dc7996","trace":"62073bc3f1a054dec6dda8dbf272e0bb"}
-{"@timestamp":"2026-06-25T23:29:09.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64399 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.5ms","level":"info","span":"1e6ad96837d6584a","trace":"cc375464480588614bc2086266e466fd"}
-{"@timestamp":"2026-06-25T23:29:10.390+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.1ms)","duration":"2026.1ms","level":"slow","span":"5cd6bee3be8b1ac4","trace":"3e8cc4fecfea0a1fd9dc892d82b82c3d"}
-{"@timestamp":"2026-06-25T23:29:10.390+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.1ms","level":"info","span":"5cd6bee3be8b1ac4","trace":"3e8cc4fecfea0a1fd9dc892d82b82c3d"}
-{"@timestamp":"2026-06-25T23:29:11.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64407 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.3ms","level":"info","span":"988582218947032e","trace":"4588ff1a4f405028c39912fe16cf2f0d"}
-{"@timestamp":"2026-06-25T23:29:13.169+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64410 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.5ms","level":"info","span":"f8e4d7668c1a5843","trace":"3e31252897c95d9ed1ab6badc4fc192f"}
-{"@timestamp":"2026-06-25T23:29:15.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64413 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.7ms","level":"info","span":"dfffeec421988f7d","trace":"ad5fd23e24f88546ccc488d56853bb86"}
-{"@timestamp":"2026-06-25T23:29:15.401+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2007.8ms)","duration":"2007.8ms","level":"slow","span":"75bea38511a6ddd2","trace":"9af9aee196f48d5dd11d8ed1f94dd2e1"}
-{"@timestamp":"2026-06-25T23:29:15.401+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2007.8ms","level":"info","span":"75bea38511a6ddd2","trace":"9af9aee196f48d5dd11d8ed1f94dd2e1"}
-{"@timestamp":"2026-06-25T23:29:17.176+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64415 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.6ms","level":"info","span":"1d7507ec5b9c1aa5","trace":"32090500f97d3619a285bf3a109010c5"}
-{"@timestamp":"2026-06-25T23:29:19.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64423 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.2ms","level":"info","span":"f05615724675c2a4","trace":"950686abac4c700299ad9f7eac4a1ac0"}
-{"@timestamp":"2026-06-25T23:29:20.449+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.6ms)","duration":"2043.6ms","level":"slow","span":"767245afe112782f","trace":"8a14d02c250edc803b63dfa0f1f923cf"}
-{"@timestamp":"2026-06-25T23:29:20.449+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.6ms","level":"info","span":"767245afe112782f","trace":"8a14d02c250edc803b63dfa0f1f923cf"}
-{"@timestamp":"2026-06-25T23:29:21.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64425 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.1ms","level":"info","span":"e764329c3745e6ad","trace":"21a4c3468a2cf160a58add45aa497b57"}
-{"@timestamp":"2026-06-25T23:29:23.170+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64432 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.5ms","level":"info","span":"8b79ebc94be569e7","trace":"14b9b5acfe044f3795ed0b1126cc591b"}
-{"@timestamp":"2026-06-25T23:29:25.469+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.4ms)","duration":"2016.4ms","level":"slow","span":"97e689f1d12fec40","trace":"39473dc432340a10f1af67d1b34bcc0e"}
-{"@timestamp":"2026-06-25T23:29:25.469+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.4ms","level":"info","span":"97e689f1d12fec40","trace":"39473dc432340a10f1af67d1b34bcc0e"}
-{"@timestamp":"2026-06-25T23:29:30.494+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2020.8ms)","duration":"2020.8ms","level":"slow","span":"0f924f0f329c923d","trace":"13564fe95544003d8676f19aa8e56680"}
-{"@timestamp":"2026-06-25T23:29:30.494+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2020.8ms","level":"info","span":"0f924f0f329c923d","trace":"13564fe95544003d8676f19aa8e56680"}
-{"@timestamp":"2026-06-25T23:29:35.521+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.2ms)","duration":"2023.2ms","level":"slow","span":"29c80637527a96cb","trace":"e67136765a75f8a305653aa838984690"}
-{"@timestamp":"2026-06-25T23:29:35.522+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.2ms","level":"info","span":"29c80637527a96cb","trace":"e67136765a75f8a305653aa838984690"}
-{"@timestamp":"2026-06-25T23:29:40.551+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.5ms)","duration":"2025.5ms","level":"slow","span":"4c3c06d19c449174","trace":"201afc497620cddeaff6dbf067e50ce2"}
-{"@timestamp":"2026-06-25T23:29:40.551+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.5ms","level":"info","span":"4c3c06d19c449174","trace":"201afc497620cddeaff6dbf067e50ce2"}
-{"@timestamp":"2026-06-25T23:29:44.180+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64455 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"7.1ms","level":"info","span":"cda189e58f7b1c4c","trace":"adf020ca0266d8c688c8fe9e42e3e801"}
-{"@timestamp":"2026-06-25T23:29:45.589+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.4ms)","duration":"2034.4ms","level":"slow","span":"e443a957ff7cf45a","trace":"f1c471533c80ebbb118e9d3f07f3306b"}
-{"@timestamp":"2026-06-25T23:29:45.591+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.4ms","level":"info","span":"e443a957ff7cf45a","trace":"f1c471533c80ebbb118e9d3f07f3306b"}
-{"@timestamp":"2026-06-25T23:29:50.613+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2018.5ms)","duration":"2018.5ms","level":"slow","span":"c3e4bc2b4e09bfae","trace":"ba4b31c041aba4239b7c427d22ea1270"}
-{"@timestamp":"2026-06-25T23:29:50.613+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2018.5ms","level":"info","span":"c3e4bc2b4e09bfae","trace":"ba4b31c041aba4239b7c427d22ea1270"}
-{"@timestamp":"2026-06-25T23:29:55.647+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.5ms)","duration":"2030.5ms","level":"slow","span":"1433d43f050d2a47","trace":"88df6667d3092624a20358f93e905137"}
-{"@timestamp":"2026-06-25T23:29:55.647+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.5ms","level":"info","span":"1433d43f050d2a47","trace":"88df6667d3092624a20358f93e905137"}
-{"@timestamp":"2026-06-25T23:29:58.074+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.9Mi, TotalAlloc=18.0Mi, Sys=19.6Mi, NumGC=12","level":"stat"}
-{"@timestamp":"2026-06-25T23:29:58.090+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 26, pass: 26, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:30:00.660+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2008.9ms)","duration":"2008.9ms","level":"slow","span":"e6229c51eab6456e","trace":"e81bb27a5cd8f51c822f5d98574f6b35"}
-{"@timestamp":"2026-06-25T23:30:00.660+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2008.9ms","level":"info","span":"e6229c51eab6456e","trace":"e81bb27a5cd8f51c822f5d98574f6b35"}
-{"@timestamp":"2026-06-25T23:30:00.706+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.4/s, drops: 0, avg time: 972.9ms, med: 2007.7ms, 90th: 2034.3ms, 99th: 2043.5ms, 99.9th: 2043.5ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:30:05.707+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.7ms)","duration":"2043.7ms","level":"slow","span":"6fdcc2552fde4355","trace":"20afc2f8985b577f984c84c0d4859e87"}
-{"@timestamp":"2026-06-25T23:30:05.707+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.7ms","level":"info","span":"6fdcc2552fde4355","trace":"20afc2f8985b577f984c84c0d4859e87"}
-{"@timestamp":"2026-06-25T23:30:10.724+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2014.2ms)","duration":"2014.2ms","level":"slow","span":"a471d8f55c2464bb","trace":"04f02d48fb7d3c0f2173b61ef8c29644"}
-{"@timestamp":"2026-06-25T23:30:10.724+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2014.2ms","level":"info","span":"a471d8f55c2464bb","trace":"04f02d48fb7d3c0f2173b61ef8c29644"}
-{"@timestamp":"2026-06-25T23:30:15.739+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2011.6ms)","duration":"2011.6ms","level":"slow","span":"e2ca77d7542133b4","trace":"5b6d6e6cd5f4a377d883d4958133f9ec"}
-{"@timestamp":"2026-06-25T23:30:15.739+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2011.6ms","level":"info","span":"e2ca77d7542133b4","trace":"5b6d6e6cd5f4a377d883d4958133f9ec"}
-{"@timestamp":"2026-06-25T23:30:20.757+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.8ms)","duration":"2013.8ms","level":"slow","span":"2baef567262aad99","trace":"0f3ae5a815de6ad67110eb3bddffe657"}
-{"@timestamp":"2026-06-25T23:30:20.757+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.8ms","level":"info","span":"2baef567262aad99","trace":"0f3ae5a815de6ad67110eb3bddffe657"}
-{"@timestamp":"2026-06-25T23:30:25.797+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.5ms)","duration":"2035.5ms","level":"slow","span":"a72eaf878a308c16","trace":"de668d7f533f05e967a06c3989c09fe7"}
-{"@timestamp":"2026-06-25T23:30:25.798+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.5ms","level":"info","span":"a72eaf878a308c16","trace":"de668d7f533f05e967a06c3989c09fe7"}
-{"@timestamp":"2026-06-25T23:30:30.833+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.8ms)","duration":"2031.8ms","level":"slow","span":"b9e7948813ed5e1d","trace":"5d1c019f2c758d6e8ef96fb6bbfd7bf1"}
-{"@timestamp":"2026-06-25T23:30:30.833+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.8ms","level":"info","span":"b9e7948813ed5e1d","trace":"5d1c019f2c758d6e8ef96fb6bbfd7bf1"}
-{"@timestamp":"2026-06-25T23:30:35.849+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2012.0ms)","duration":"2012.0ms","level":"slow","span":"e645cd0af1caa378","trace":"5e88db2b65abec990000a5a1684027a1"}
-{"@timestamp":"2026-06-25T23:30:35.849+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2012.0ms","level":"info","span":"e645cd0af1caa378","trace":"5e88db2b65abec990000a5a1684027a1"}
-{"@timestamp":"2026-06-25T23:30:40.878+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.2ms)","duration":"2026.2ms","level":"slow","span":"e20f218c2aaf4d04","trace":"480ddf8d8562d22809d89ac432102982"}
-{"@timestamp":"2026-06-25T23:30:40.879+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.2ms","level":"info","span":"e20f218c2aaf4d04","trace":"480ddf8d8562d22809d89ac432102982"}
-{"@timestamp":"2026-06-25T23:30:44.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64530 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.4ms","level":"info","span":"73e2213acfc4ef8a","trace":"7ea6be3391b8e6cad0fd32629772f414"}
-{"@timestamp":"2026-06-25T23:30:45.907+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.6ms)","duration":"2022.6ms","level":"slow","span":"2d4e4708a28af2fa","trace":"e921d02499b7defe826e2074bf85d837"}
-{"@timestamp":"2026-06-25T23:30:45.907+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.6ms","level":"info","span":"2d4e4708a28af2fa","trace":"e921d02499b7defe826e2074bf85d837"}
-{"@timestamp":"2026-06-25T23:30:50.932+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.5ms)","duration":"2021.5ms","level":"slow","span":"9a6efb58c94ee845","trace":"6d759f52584d1bd84f23a6bebacde00d"}
-{"@timestamp":"2026-06-25T23:30:50.933+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.5ms","level":"info","span":"9a6efb58c94ee845","trace":"6d759f52584d1bd84f23a6bebacde00d"}
-{"@timestamp":"2026-06-25T23:30:55.961+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.3ms)","duration":"2025.3ms","level":"slow","span":"c38577106540aa4b","trace":"4dcf912a1a006b45f498fd0b0f74a771"}
-{"@timestamp":"2026-06-25T23:30:55.961+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.3ms","level":"info","span":"c38577106540aa4b","trace":"4dcf912a1a006b45f498fd0b0f74a771"}
-{"@timestamp":"2026-06-25T23:30:58.074+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=18.9Mi, Sys=19.6Mi, NumGC=13","level":"stat"}
-{"@timestamp":"2026-06-25T23:30:58.092+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 13, pass: 13, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:31:00.706+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 1854.9ms, med: 2022.5ms, 90th: 2043.7ms, 99th: 2043.7ms, 99.9th: 2043.7ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:31:00.994+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.0ms)","duration":"2028.0ms","level":"slow","span":"c5636144a8c7127a","trace":"5f64ebe2fd9b450079f4bf54421fb9a2"}
-{"@timestamp":"2026-06-25T23:31:00.994+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.0ms","level":"info","span":"c5636144a8c7127a","trace":"5f64ebe2fd9b450079f4bf54421fb9a2"}
-{"@timestamp":"2026-06-25T23:31:06.028+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.7ms)","duration":"2026.7ms","level":"slow","span":"79577d5ebafb1a41","trace":"5dd869e957d71047c457dc8b20bc801a"}
-{"@timestamp":"2026-06-25T23:31:06.028+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.7ms","level":"info","span":"79577d5ebafb1a41","trace":"5dd869e957d71047c457dc8b20bc801a"}
-{"@timestamp":"2026-06-25T23:31:11.054+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.8ms)","duration":"2021.8ms","level":"slow","span":"a127326ac7cfb903","trace":"1f45c10590c23fc186215054aebc6602"}
-{"@timestamp":"2026-06-25T23:31:11.054+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.8ms","level":"info","span":"a127326ac7cfb903","trace":"1f45c10590c23fc186215054aebc6602"}
-{"@timestamp":"2026-06-25T23:31:16.091+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2033.2ms)","duration":"2033.2ms","level":"slow","span":"cbf931ad43b78ea8","trace":"8b7b5ac7e57394cc3c853ac311b764c7"}
-{"@timestamp":"2026-06-25T23:31:16.091+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2033.2ms","level":"info","span":"cbf931ad43b78ea8","trace":"8b7b5ac7e57394cc3c853ac311b764c7"}
-{"@timestamp":"2026-06-25T23:31:21.121+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.0ms)","duration":"2025.0ms","level":"slow","span":"6f5ab72564ad28df","trace":"58220a65133e5cdc95e038d693aa032a"}
-{"@timestamp":"2026-06-25T23:31:21.121+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.0ms","level":"info","span":"6f5ab72564ad28df","trace":"58220a65133e5cdc95e038d693aa032a"}
-{"@timestamp":"2026-06-25T23:31:26.155+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.1ms)","duration":"2031.1ms","level":"slow","span":"05dfa72f8a7904b4","trace":"542a0f801eedf516e149abfe0a51e791"}
-{"@timestamp":"2026-06-25T23:31:26.155+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.1ms","level":"info","span":"05dfa72f8a7904b4","trace":"542a0f801eedf516e149abfe0a51e791"}
-{"@timestamp":"2026-06-25T23:31:31.186+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.6ms)","duration":"2026.6ms","level":"slow","span":"e97d7d3cfcc2b551","trace":"117b75369c20aa2bc17adf5fd217cd35"}
-{"@timestamp":"2026-06-25T23:31:31.186+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.6ms","level":"info","span":"e97d7d3cfcc2b551","trace":"117b75369c20aa2bc17adf5fd217cd35"}
-{"@timestamp":"2026-06-25T23:31:36.202+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.0ms)","duration":"2013.0ms","level":"slow","span":"830b984d56abece6","trace":"0523fe942ce36f98704f4a35db956828"}
-{"@timestamp":"2026-06-25T23:31:36.202+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.0ms","level":"info","span":"830b984d56abece6","trace":"0523fe942ce36f98704f4a35db956828"}
-{"@timestamp":"2026-06-25T23:31:41.212+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2008.3ms)","duration":"2008.3ms","level":"slow","span":"f8164c3d74541f0b","trace":"8b9c4651133a378357b9654d68989b5d"}
-{"@timestamp":"2026-06-25T23:31:41.212+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2008.3ms","level":"info","span":"f8164c3d74541f0b","trace":"8b9c4651133a378357b9654d68989b5d"}
-{"@timestamp":"2026-06-25T23:31:44.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64608 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.8ms","level":"info","span":"dfd16c4b32f98e76","trace":"3913d11048e74f5f5bd4b44f8d849282"}
-{"@timestamp":"2026-06-25T23:31:46.232+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.2ms)","duration":"2016.2ms","level":"slow","span":"a5445252684bffa4","trace":"81645b286ce1b169d9f4dbbe79ed8cc0"}
-{"@timestamp":"2026-06-25T23:31:46.232+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.2ms","level":"info","span":"a5445252684bffa4","trace":"81645b286ce1b169d9f4dbbe79ed8cc0"}
-{"@timestamp":"2026-06-25T23:31:51.259+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.3ms)","duration":"2024.3ms","level":"slow","span":"16f861cf5d9c0c87","trace":"1f302bbc73782f01718f556879e2d724"}
-{"@timestamp":"2026-06-25T23:31:51.260+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.3ms","level":"info","span":"16f861cf5d9c0c87","trace":"1f302bbc73782f01718f556879e2d724"}
-{"@timestamp":"2026-06-25T23:31:54.134+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/threads-accounts/cdfe3861-7238-4874-9744-cd913b4245ee/session/import - 127.0.0.1:64624 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.9ms","level":"info","span":"40caca524b90ee6b","trace":"ff351e5843b273b8a3cf969b8e930413"}
-{"@timestamp":"2026-06-25T23:31:56.287+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.4ms)","duration":"2023.4ms","level":"slow","span":"ae6683eb21985953","trace":"afe717a958a4d3edfecd84dc8a1312c2"}
-{"@timestamp":"2026-06-25T23:31:56.287+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.4ms","level":"info","span":"ae6683eb21985953","trace":"afe717a958a4d3edfecd84dc8a1312c2"}
-{"@timestamp":"2026-06-25T23:31:58.076+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.6Mi, TotalAlloc=19.2Mi, Sys=19.6Mi, NumGC=13","level":"stat"}
-{"@timestamp":"2026-06-25T23:31:58.092+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 14, pass: 14, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:32:00.707+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 1734.7ms, med: 2024.3ms, 90th: 2033.2ms, 99th: 2033.2ms, 99.9th: 2033.2ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:32:01.315+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.7ms)","duration":"2024.7ms","level":"slow","span":"e43b06de55b273fe","trace":"322eb3370c2dd7e41a66ff5af2f7e107"}
-{"@timestamp":"2026-06-25T23:32:01.315+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.7ms","level":"info","span":"e43b06de55b273fe","trace":"322eb3370c2dd7e41a66ff5af2f7e107"}
-{"@timestamp":"2026-06-25T23:32:06.347+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.5ms)","duration":"2028.5ms","level":"slow","span":"8b86367f29e39fdf","trace":"8dbc9a2800dca63c7ca279a4a80affc6"}
-{"@timestamp":"2026-06-25T23:32:06.347+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.5ms","level":"info","span":"8b86367f29e39fdf","trace":"8dbc9a2800dca63c7ca279a4a80affc6"}
-{"@timestamp":"2026-06-25T23:32:11.362+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2010.6ms)","duration":"2010.6ms","level":"slow","span":"bfcfd3f9430ea1c7","trace":"fcc84f242d6306240b65fb47dc110c55"}
-{"@timestamp":"2026-06-25T23:32:11.362+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2010.6ms","level":"info","span":"bfcfd3f9430ea1c7","trace":"fcc84f242d6306240b65fb47dc110c55"}
-{"@timestamp":"2026-06-25T23:32:16.409+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2042.8ms)","duration":"2042.8ms","level":"slow","span":"17ab06852facad13","trace":"dcc98a15c130fb2b35b4e34e87ec5f9c"}
-{"@timestamp":"2026-06-25T23:32:16.409+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2042.8ms","level":"info","span":"17ab06852facad13","trace":"dcc98a15c130fb2b35b4e34e87ec5f9c"}
-{"@timestamp":"2026-06-25T23:32:21.426+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2014.5ms)","duration":"2014.5ms","level":"slow","span":"ff93893877a99e71","trace":"b45da5abb53ef8ffeb91bc390a441582"}
-{"@timestamp":"2026-06-25T23:32:21.427+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2014.5ms","level":"info","span":"ff93893877a99e71","trace":"b45da5abb53ef8ffeb91bc390a441582"}
-{"@timestamp":"2026-06-25T23:32:26.454+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.8ms)","duration":"2025.8ms","level":"slow","span":"cc0937dd45da2a0c","trace":"ff4e1fbe2b1361f44af69d711d3a836f"}
-{"@timestamp":"2026-06-25T23:32:26.454+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.8ms","level":"info","span":"cc0937dd45da2a0c","trace":"ff4e1fbe2b1361f44af69d711d3a836f"}
-{"@timestamp":"2026-06-25T23:32:31.473+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2014.6ms)","duration":"2014.6ms","level":"slow","span":"7dc17694006c1132","trace":"b2e6496346473e4f993ff2667fd1898d"}
-{"@timestamp":"2026-06-25T23:32:31.473+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2014.6ms","level":"info","span":"7dc17694006c1132","trace":"b2e6496346473e4f993ff2667fd1898d"}
-{"@timestamp":"2026-06-25T23:32:36.504+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.3ms)","duration":"2028.3ms","level":"slow","span":"e8f8300d84925dc6","trace":"dc7923bcc35188ee0e4bf34082b5eb8b"}
-{"@timestamp":"2026-06-25T23:32:36.504+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.3ms","level":"info","span":"e8f8300d84925dc6","trace":"dc7923bcc35188ee0e4bf34082b5eb8b"}
-{"@timestamp":"2026-06-25T23:32:41.521+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.7ms)","duration":"2013.7ms","level":"slow","span":"d1aeb30bad35475e","trace":"897611bf6c2b3d196d48129eb0aa3fbf"}
-{"@timestamp":"2026-06-25T23:32:41.521+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.7ms","level":"info","span":"d1aeb30bad35475e","trace":"897611bf6c2b3d196d48129eb0aa3fbf"}
-{"@timestamp":"2026-06-25T23:32:44.182+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64683 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"11.1ms","level":"info","span":"8d4ec5b487fb4430","trace":"08641888177b189bbe70ae34525485c8"}
-{"@timestamp":"2026-06-25T23:32:46.529+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2004.3ms)","duration":"2004.3ms","level":"slow","span":"50d23489909088bc","trace":"89ea5a2d270cdc33fa869f6bcba88c70"}
-{"@timestamp":"2026-06-25T23:32:46.529+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2004.3ms","level":"info","span":"50d23489909088bc","trace":"89ea5a2d270cdc33fa869f6bcba88c70"}
-{"@timestamp":"2026-06-25T23:32:51.552+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2018.8ms)","duration":"2018.8ms","level":"slow","span":"1f173d7c177bc74d","trace":"8fad08da8061e3cd6eca7acd03ed8351"}
-{"@timestamp":"2026-06-25T23:32:51.552+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2018.8ms","level":"info","span":"1f173d7c177bc74d","trace":"8fad08da8061e3cd6eca7acd03ed8351"}
-{"@timestamp":"2026-06-25T23:32:56.578+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.5ms)","duration":"2023.5ms","level":"slow","span":"294256bb9f86f69b","trace":"bc2bea100eb9c76b110ff546fc879d14"}
-{"@timestamp":"2026-06-25T23:32:56.579+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.5ms","level":"info","span":"294256bb9f86f69b","trace":"bc2bea100eb9c76b110ff546fc879d14"}
-{"@timestamp":"2026-06-25T23:32:58.076+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=20.0Mi, Sys=19.6Mi, NumGC=14","level":"stat"}
-{"@timestamp":"2026-06-25T23:32:58.093+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 13, pass: 13, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:33:00.465+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64705 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"93051b92fd84ae2f","trace":"4fc9be6fe1d3eb96bb131ac6b2a94c0a"}
-{"@timestamp":"2026-06-25T23:33:00.707+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 1733.1ms, med: 2018.8ms, 90th: 2042.7ms, 99th: 2042.7ms, 99.9th: 2042.7ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:33:01.059+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64707 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.3ms","level":"info","span":"3e3e4585cb639a10","trace":"be92379bd492cfc98d7d783163d0efb3"}
-{"@timestamp":"2026-06-25T23:33:01.598+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2015.5ms)","duration":"2015.5ms","level":"slow","span":"c90db6e9eb0cede9","trace":"7d88b0961fd437be391bd0a6804667ac"}
-{"@timestamp":"2026-06-25T23:33:01.599+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2015.5ms","level":"info","span":"c90db6e9eb0cede9","trace":"7d88b0961fd437be391bd0a6804667ac"}
-{"@timestamp":"2026-06-25T23:33:03.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64711 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.4ms","level":"info","span":"fc84c1ea5d654663","trace":"5f00f27a8a2ab4d4c0c1b2e4a011cb85"}
-{"@timestamp":"2026-06-25T23:33:05.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64714 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.6ms","level":"info","span":"3b7675ca462452a9","trace":"1ee1957412524bfec3959ca9834c8e23"}
-{"@timestamp":"2026-06-25T23:33:06.621+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2019.8ms)","duration":"2019.8ms","level":"slow","span":"fe67744349646cac","trace":"8b277c0216c09b9812e39bc06b04e4c0"}
-{"@timestamp":"2026-06-25T23:33:06.621+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2019.8ms","level":"info","span":"fe67744349646cac","trace":"8b277c0216c09b9812e39bc06b04e4c0"}
-{"@timestamp":"2026-06-25T23:33:07.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64720 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.7ms","level":"info","span":"684de8cf21a7bb9b","trace":"a1cbb1010c8059293515b30b34ded1f3"}
-{"@timestamp":"2026-06-25T23:33:09.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64722 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.8ms","level":"info","span":"28d147321af30f17","trace":"e2c3e5d3978b060ee15e9c1acc27e74a"}
-{"@timestamp":"2026-06-25T23:33:11.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64732 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"5107bdc76e4b13f3","trace":"dd9199e5975cef89e7f0d8d5c2c65a47"}
-{"@timestamp":"2026-06-25T23:33:11.652+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.9ms)","duration":"2026.9ms","level":"slow","span":"de7ca461ca69d981","trace":"25377db4784b37800c1bc1defa41f0a2"}
-{"@timestamp":"2026-06-25T23:33:11.652+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.9ms","level":"info","span":"de7ca461ca69d981","trace":"25377db4784b37800c1bc1defa41f0a2"}
-{"@timestamp":"2026-06-25T23:33:13.175+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64734 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"8d93f9e0cbd3990f","trace":"823dd6f30667bacaa45cb322527763a1"}
-{"@timestamp":"2026-06-25T23:33:15.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64738 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.9ms","level":"info","span":"6cbfe9b46734f28d","trace":"fb1e374c2f2f3d9b773e0e5e711a510b"}
-{"@timestamp":"2026-06-25T23:33:16.675+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.4ms)","duration":"2021.4ms","level":"slow","span":"ba122c400e89e591","trace":"ee679e10f2ff265dfad522709a2723a5"}
-{"@timestamp":"2026-06-25T23:33:16.675+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.4ms","level":"info","span":"ba122c400e89e591","trace":"ee679e10f2ff265dfad522709a2723a5"}
-{"@timestamp":"2026-06-25T23:33:17.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64741 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"04aa52f5433d4734","trace":"03439e362929495d53444fd8c65382f8"}
-{"@timestamp":"2026-06-25T23:33:19.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64747 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"568382928744fa33","trace":"26bb505584bb74f0dd7264c55aea909c"}
-{"@timestamp":"2026-06-25T23:33:21.055+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64749 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.2ms","level":"info","span":"97a440406591c1c4","trace":"1790652b55ff158a06529f3f64b3e0f9"}
-{"@timestamp":"2026-06-25T23:33:21.696+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2018.8ms)","duration":"2018.8ms","level":"slow","span":"f995707ffa03611a","trace":"dbf71f1c7a761a8f2f490d8b2f450386"}
-{"@timestamp":"2026-06-25T23:33:21.696+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2018.8ms","level":"info","span":"f995707ffa03611a","trace":"dbf71f1c7a761a8f2f490d8b2f450386"}
-{"@timestamp":"2026-06-25T23:33:23.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64763 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"a159f8e750ba6ac6","trace":"1d895a06fd5df5c2989b856cbb0ac051"}
-{"@timestamp":"2026-06-25T23:33:25.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64765 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.2ms","level":"info","span":"fbe272e149c8fece","trace":"28ffeef580aed07ebb13d10bcb8de9bb"}
-{"@timestamp":"2026-06-25T23:33:26.725+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.6ms)","duration":"2025.6ms","level":"slow","span":"a271f3b4617b3881","trace":"965da7e0966408a6758e92394ff3fcb9"}
-{"@timestamp":"2026-06-25T23:33:26.725+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.6ms","level":"info","span":"a271f3b4617b3881","trace":"965da7e0966408a6758e92394ff3fcb9"}
-{"@timestamp":"2026-06-25T23:33:27.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64773 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.3ms","level":"info","span":"927af6f0a3153d3b","trace":"f4239eea0ba4f4e1754af72251d65f64"}
-{"@timestamp":"2026-06-25T23:33:29.179+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64775 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.6ms","level":"info","span":"885017e5fcfa880b","trace":"c7b704e42a91ce3f359da357a3721e29"}
-{"@timestamp":"2026-06-25T23:33:31.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64781 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.6ms","level":"info","span":"ccffb84d6532f39f","trace":"2a9229d4bb7a54d63f83d2b13d051e21"}
-{"@timestamp":"2026-06-25T23:33:31.755+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.1ms)","duration":"2028.1ms","level":"slow","span":"20b274bc184fe78a","trace":"14660811a54f37f2e624dd53e742a60a"}
-{"@timestamp":"2026-06-25T23:33:31.756+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.1ms","level":"info","span":"20b274bc184fe78a","trace":"14660811a54f37f2e624dd53e742a60a"}
-{"@timestamp":"2026-06-25T23:33:33.171+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64783 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.5ms","level":"info","span":"e375294f3f2dbb15","trace":"6311c5dfd7191b07ef0effdab1b1610a"}
-{"@timestamp":"2026-06-25T23:33:35.172+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64790 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.1ms","level":"info","span":"d94264e5c052939f","trace":"7f09583b4edb4d972c65432112903a9b"}
-{"@timestamp":"2026-06-25T23:33:36.781+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.8ms)","duration":"2021.8ms","level":"slow","span":"e8ea08d27720bc2d","trace":"89d26d0b0b8fc4c52c9c32ea24910e1a"}
-{"@timestamp":"2026-06-25T23:33:36.781+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.8ms","level":"info","span":"e8ea08d27720bc2d","trace":"89d26d0b0b8fc4c52c9c32ea24910e1a"}
-{"@timestamp":"2026-06-25T23:33:37.173+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64793 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"0d91521c5118ef33","trace":"f7772f7b41f09d7e4575ab84bbe400af"}
-{"@timestamp":"2026-06-25T23:33:39.057+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:64800 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.6ms","level":"info","span":"30440de91448f42d","trace":"b0d459542e665c22714b1333654b17eb"}
-{"@timestamp":"2026-06-25T23:33:41.811+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.6ms)","duration":"2026.6ms","level":"slow","span":"9f90ec1f1cbadf09","trace":"1b6f4be47867d446b9f0a1673097f6d8"}
-{"@timestamp":"2026-06-25T23:33:41.811+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.6ms","level":"info","span":"9f90ec1f1cbadf09","trace":"1b6f4be47867d446b9f0a1673097f6d8"}
-{"@timestamp":"2026-06-25T23:33:46.840+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.7ms)","duration":"2025.7ms","level":"slow","span":"b8d6a76305382cac","trace":"dbebbc81fb67fd32afd8f7f72bc84437"}
-{"@timestamp":"2026-06-25T23:33:46.840+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.7ms","level":"info","span":"b8d6a76305382cac","trace":"dbebbc81fb67fd32afd8f7f72bc84437"}
-{"@timestamp":"2026-06-25T23:33:51.850+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2007.7ms)","duration":"2007.7ms","level":"slow","span":"d03ca2709a5ccd43","trace":"c0f7bf87473e4b51f2a931547359f3fc"}
-{"@timestamp":"2026-06-25T23:33:51.851+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2007.7ms","level":"info","span":"d03ca2709a5ccd43","trace":"c0f7bf87473e4b51f2a931547359f3fc"}
-{"@timestamp":"2026-06-25T23:33:56.904+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2050.1ms)","duration":"2050.1ms","level":"slow","span":"599205d47f52c3b5","trace":"70cbdab4d4145d1c8d6f807e491db8a0"}
-{"@timestamp":"2026-06-25T23:33:56.904+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2050.1ms","level":"info","span":"599205d47f52c3b5","trace":"70cbdab4d4145d1c8d6f807e491db8a0"}
-{"@timestamp":"2026-06-25T23:33:58.077+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.4Mi, TotalAlloc=21.1Mi, Sys=19.6Mi, NumGC=14","level":"stat"}
-{"@timestamp":"2026-06-25T23:33:58.094+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 33, pass: 33, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:34:00.708+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.5/s, drops: 0, avg time: 761.0ms, med: 3.8ms, 90th: 2026.9ms, 99th: 2050.0ms, 99.9th: 2050.0ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:34:01.925+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2018.3ms)","duration":"2018.3ms","level":"slow","span":"54d41a01dd90f510","trace":"6b3f4c53e871a79a70489bd44d224330"}
-{"@timestamp":"2026-06-25T23:34:01.925+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2018.3ms","level":"info","span":"54d41a01dd90f510","trace":"6b3f4c53e871a79a70489bd44d224330"}
-{"@timestamp":"2026-06-25T23:34:06.955+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.7ms)","duration":"2026.7ms","level":"slow","span":"f39203b5399232ad","trace":"7274d1d53447f877ec001d48c355ec4e"}
-{"@timestamp":"2026-06-25T23:34:06.956+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.7ms","level":"info","span":"f39203b5399232ad","trace":"7274d1d53447f877ec001d48c355ec4e"}
-{"@timestamp":"2026-06-25T23:34:11.976+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2017.8ms)","duration":"2017.8ms","level":"slow","span":"65eaf93725d3d48d","trace":"7edd63ce2e5f3aa83529f8ebdb7bbf06"}
-{"@timestamp":"2026-06-25T23:34:11.976+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2017.8ms","level":"info","span":"65eaf93725d3d48d","trace":"7edd63ce2e5f3aa83529f8ebdb7bbf06"}
-{"@timestamp":"2026-06-25T23:34:16.980+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2001.1ms)","duration":"2001.1ms","level":"slow","span":"b120856b4afb28ac","trace":"104eca024742afa5e885e09186ab23c6"}
-{"@timestamp":"2026-06-25T23:34:16.980+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2001.1ms","level":"info","span":"b120856b4afb28ac","trace":"104eca024742afa5e885e09186ab23c6"}
-{"@timestamp":"2026-06-25T23:34:22.016+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2033.2ms)","duration":"2033.2ms","level":"slow","span":"38071c58deb0851e","trace":"a6e8da01dedabcd02932f1dbd4116c4b"}
-{"@timestamp":"2026-06-25T23:34:22.016+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2033.2ms","level":"info","span":"38071c58deb0851e","trace":"a6e8da01dedabcd02932f1dbd4116c4b"}
-{"@timestamp":"2026-06-25T23:34:27.043+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.8ms)","duration":"2023.8ms","level":"slow","span":"133c07d47c573f94","trace":"391f58e6e594d59fd8800e73e39f4d02"}
-{"@timestamp":"2026-06-25T23:34:27.044+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.8ms","level":"info","span":"133c07d47c573f94","trace":"391f58e6e594d59fd8800e73e39f4d02"}
-{"@timestamp":"2026-06-25T23:34:32.069+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.8ms)","duration":"2021.8ms","level":"slow","span":"81512c3fe7a754fa","trace":"1089035399a6a80ec486f91ecfef7225"}
-{"@timestamp":"2026-06-25T23:34:32.070+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.8ms","level":"info","span":"81512c3fe7a754fa","trace":"1089035399a6a80ec486f91ecfef7225"}
-{"@timestamp":"2026-06-25T23:34:37.077+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2004.1ms)","duration":"2004.1ms","level":"slow","span":"bccdd03b48f865c7","trace":"580edb3cdadc374695cc843051dcc5c4"}
-{"@timestamp":"2026-06-25T23:34:37.077+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2004.1ms","level":"info","span":"bccdd03b48f865c7","trace":"580edb3cdadc374695cc843051dcc5c4"}
-{"@timestamp":"2026-06-25T23:34:42.123+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.2ms)","duration":"2043.2ms","level":"slow","span":"b74ebba6687d53c2","trace":"64b14a87da3b2ee4156cac294cd568c8"}
-{"@timestamp":"2026-06-25T23:34:42.124+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.2ms","level":"info","span":"b74ebba6687d53c2","trace":"64b14a87da3b2ee4156cac294cd568c8"}
-{"@timestamp":"2026-06-25T23:34:47.155+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.6ms)","duration":"2026.6ms","level":"slow","span":"6291d86709129bd6","trace":"98ca85716eba76d9e301a5c160c1d3b3"}
-{"@timestamp":"2026-06-25T23:34:47.155+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.6ms","level":"info","span":"6291d86709129bd6","trace":"98ca85716eba76d9e301a5c160c1d3b3"}
-{"@timestamp":"2026-06-25T23:34:52.189+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.9ms)","duration":"2029.9ms","level":"slow","span":"52b3403e6cc9ed8e","trace":"cce81ec3ee76127713f88cfd72cfb331"}
-{"@timestamp":"2026-06-25T23:34:52.189+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.9ms","level":"info","span":"52b3403e6cc9ed8e","trace":"cce81ec3ee76127713f88cfd72cfb331"}
-{"@timestamp":"2026-06-25T23:34:57.219+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.0ms)","duration":"2029.0ms","level":"slow","span":"1add5aa7651354b1","trace":"d0ffd765645305cfe28ead9e76ed2448"}
-{"@timestamp":"2026-06-25T23:34:57.220+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.0ms","level":"info","span":"1add5aa7651354b1","trace":"d0ffd765645305cfe28ead9e76ed2448"}
-{"@timestamp":"2026-06-25T23:34:58.077+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=22.0Mi, Sys=19.6Mi, NumGC=15","level":"stat"}
-{"@timestamp":"2026-06-25T23:34:58.094+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:35:00.708+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2022.8ms, med: 2026.5ms, 90th: 2043.1ms, 99th: 2043.1ms, 99.9th: 2043.1ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:35:02.253+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.9ms)","duration":"2030.9ms","level":"slow","span":"fec64e03d5d165b6","trace":"c85c59a792b3b07653240afa0a4abe8d"}
-{"@timestamp":"2026-06-25T23:35:02.253+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.9ms","level":"info","span":"fec64e03d5d165b6","trace":"c85c59a792b3b07653240afa0a4abe8d"}
-{"@timestamp":"2026-06-25T23:35:07.271+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.8ms)","duration":"2013.8ms","level":"slow","span":"39b590d47f382c3e","trace":"cfa2af39b839acbf34c89ae2d08cdca6"}
-{"@timestamp":"2026-06-25T23:35:07.271+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.8ms","level":"info","span":"39b590d47f382c3e","trace":"cfa2af39b839acbf34c89ae2d08cdca6"}
-{"@timestamp":"2026-06-25T23:35:12.301+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.4ms)","duration":"2027.4ms","level":"slow","span":"edb37ac898e54e2d","trace":"18c83943d6f14840fc786071f847c2c5"}
-{"@timestamp":"2026-06-25T23:35:12.302+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.4ms","level":"info","span":"edb37ac898e54e2d","trace":"18c83943d6f14840fc786071f847c2c5"}
-{"@timestamp":"2026-06-25T23:35:17.322+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2017.1ms)","duration":"2017.1ms","level":"slow","span":"2d0d22b95841241d","trace":"9078d7bab2607b54eccd1ce4dd88fe3d"}
-{"@timestamp":"2026-06-25T23:35:17.322+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2017.1ms","level":"info","span":"2d0d22b95841241d","trace":"9078d7bab2607b54eccd1ce4dd88fe3d"}
-{"@timestamp":"2026-06-25T23:35:22.334+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2009.0ms)","duration":"2009.0ms","level":"slow","span":"aedf8c7aa14fc63b","trace":"2d9bfa9c694922f3e74aee21bba30e05"}
-{"@timestamp":"2026-06-25T23:35:22.334+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2009.0ms","level":"info","span":"aedf8c7aa14fc63b","trace":"2d9bfa9c694922f3e74aee21bba30e05"}
-{"@timestamp":"2026-06-25T23:35:27.364+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.0ms)","duration":"2024.0ms","level":"slow","span":"ccdea5c09739c8bb","trace":"7beeaf229fa6f85256ef8844d56e29dd"}
-{"@timestamp":"2026-06-25T23:35:27.365+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.0ms","level":"info","span":"ccdea5c09739c8bb","trace":"7beeaf229fa6f85256ef8844d56e29dd"}
-{"@timestamp":"2026-06-25T23:35:32.330+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.2ms)","duration":"2024.2ms","level":"slow","span":"48f9cc537a32686d","trace":"7adbe3386f44e22a2cc57ab717f86315"}
-{"@timestamp":"2026-06-25T23:35:32.330+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.2ms","level":"info","span":"48f9cc537a32686d","trace":"7adbe3386f44e22a2cc57ab717f86315"}
-{"@timestamp":"2026-06-25T23:35:37.350+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.4ms)","duration":"2016.4ms","level":"slow","span":"9ac6dbbe87e22467","trace":"32e62a4c9e524fa491b95acda63c17ba"}
-{"@timestamp":"2026-06-25T23:35:37.350+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.4ms","level":"info","span":"9ac6dbbe87e22467","trace":"32e62a4c9e524fa491b95acda63c17ba"}
-{"@timestamp":"2026-06-25T23:35:42.378+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.1ms)","duration":"2024.1ms","level":"slow","span":"79b63a998ad7adeb","trace":"f5358e4ab32942e91a017a410acf3c75"}
-{"@timestamp":"2026-06-25T23:35:42.378+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.1ms","level":"info","span":"79b63a998ad7adeb","trace":"f5358e4ab32942e91a017a410acf3c75"}
-{"@timestamp":"2026-06-25T23:35:47.404+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.2ms)","duration":"2023.2ms","level":"slow","span":"9396d73c0556478f","trace":"10a6a1483a3d940fcdf3b79cd795f916"}
-{"@timestamp":"2026-06-25T23:35:47.404+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.2ms","level":"info","span":"9396d73c0556478f","trace":"10a6a1483a3d940fcdf3b79cd795f916"}
-{"@timestamp":"2026-06-25T23:35:52.432+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.0ms)","duration":"2025.0ms","level":"slow","span":"7ad96347c83974e0","trace":"5035d688d4964ff9ad91028420caab14"}
-{"@timestamp":"2026-06-25T23:35:52.433+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.0ms","level":"info","span":"7ad96347c83974e0","trace":"5035d688d4964ff9ad91028420caab14"}
-{"@timestamp":"2026-06-25T23:35:57.467+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.0ms)","duration":"2026.0ms","level":"slow","span":"6f33a4e6bae02132","trace":"0e12872f3caaa405f45f84b280ecf195"}
-{"@timestamp":"2026-06-25T23:35:57.467+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.0ms","level":"info","span":"6f33a4e6bae02132","trace":"0e12872f3caaa405f45f84b280ecf195"}
-{"@timestamp":"2026-06-25T23:35:58.012+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=22.2Mi, Sys=19.6Mi, NumGC=15","level":"stat"}
-{"@timestamp":"2026-06-25T23:35:58.029+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:36:00.643+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2021.7ms, med: 2024.0ms, 90th: 2030.8ms, 99th: 2030.8ms, 99.9th: 2030.8ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:36:02.495+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2020.9ms)","duration":"2020.9ms","level":"slow","span":"0ec118b0941d56b3","trace":"64d8eb11ad0470870a4ef7ea1082934c"}
-{"@timestamp":"2026-06-25T23:36:02.495+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2020.9ms","level":"info","span":"0ec118b0941d56b3","trace":"64d8eb11ad0470870a4ef7ea1082934c"}
-{"@timestamp":"2026-06-25T23:36:07.520+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.8ms)","duration":"2021.8ms","level":"slow","span":"337ea61c2557d0ef","trace":"524b519fdb529265e8063ab707087883"}
-{"@timestamp":"2026-06-25T23:36:07.520+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.8ms","level":"info","span":"337ea61c2557d0ef","trace":"524b519fdb529265e8063ab707087883"}
-{"@timestamp":"2026-06-25T23:36:12.549+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.9ms)","duration":"2025.9ms","level":"slow","span":"9713980b42fc6103","trace":"2f00cb4327cbcccaed93fe89fc6d5887"}
-{"@timestamp":"2026-06-25T23:36:12.549+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.9ms","level":"info","span":"9713980b42fc6103","trace":"2f00cb4327cbcccaed93fe89fc6d5887"}
-{"@timestamp":"2026-06-25T23:36:17.580+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.0ms)","duration":"2027.0ms","level":"slow","span":"280cdfe12490bc1d","trace":"7677a4a41c1a5ac7adfbf2c909393e61"}
-{"@timestamp":"2026-06-25T23:36:17.580+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.0ms","level":"info","span":"280cdfe12490bc1d","trace":"7677a4a41c1a5ac7adfbf2c909393e61"}
-{"@timestamp":"2026-06-25T23:36:22.609+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.2ms)","duration":"2026.2ms","level":"slow","span":"603096a4ad06b04f","trace":"e97b9ebbc7631a3b95579b354c1ef698"}
-{"@timestamp":"2026-06-25T23:36:22.609+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.2ms","level":"info","span":"603096a4ad06b04f","trace":"e97b9ebbc7631a3b95579b354c1ef698"}
-{"@timestamp":"2026-06-25T23:36:27.638+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.4ms)","duration":"2026.4ms","level":"slow","span":"8a20e2a8acb73b58","trace":"829b8ccae1a22e05363728a0a448ee39"}
-{"@timestamp":"2026-06-25T23:36:27.638+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.4ms","level":"info","span":"8a20e2a8acb73b58","trace":"829b8ccae1a22e05363728a0a448ee39"}
-{"@timestamp":"2026-06-25T23:36:32.665+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.1ms)","duration":"2024.1ms","level":"slow","span":"90fe97098e1fff58","trace":"c40ed3d3e24f85b49d31906878a3f0e1"}
-{"@timestamp":"2026-06-25T23:36:32.665+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.1ms","level":"info","span":"90fe97098e1fff58","trace":"c40ed3d3e24f85b49d31906878a3f0e1"}
-{"@timestamp":"2026-06-25T23:36:37.696+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.2ms)","duration":"2024.2ms","level":"slow","span":"5cdec7882cb60c77","trace":"6b694ef8bd3469a9cd1c26352e9ee5c6"}
-{"@timestamp":"2026-06-25T23:36:37.696+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.2ms","level":"info","span":"5cdec7882cb60c77","trace":"6b694ef8bd3469a9cd1c26352e9ee5c6"}
-{"@timestamp":"2026-06-25T23:36:42.722+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.5ms)","duration":"2022.5ms","level":"slow","span":"c98edc521d79a291","trace":"a8c89301ba6f40ea887de6b39970ec29"}
-{"@timestamp":"2026-06-25T23:36:42.722+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.5ms","level":"info","span":"c98edc521d79a291","trace":"a8c89301ba6f40ea887de6b39970ec29"}
-{"@timestamp":"2026-06-25T23:36:47.760+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.3ms)","duration":"2034.3ms","level":"slow","span":"242e27088cf354ca","trace":"6b36e4c1b0528c9d90ebad62b78b9fe1"}
-{"@timestamp":"2026-06-25T23:36:47.760+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.3ms","level":"info","span":"242e27088cf354ca","trace":"6b36e4c1b0528c9d90ebad62b78b9fe1"}
-{"@timestamp":"2026-06-25T23:36:52.776+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.6ms)","duration":"2013.6ms","level":"slow","span":"26edec9f0c804bc6","trace":"734230c8cffb302a22519bf29553faff"}
-{"@timestamp":"2026-06-25T23:36:52.776+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.6ms","level":"info","span":"26edec9f0c804bc6","trace":"734230c8cffb302a22519bf29553faff"}
-{"@timestamp":"2026-06-25T23:36:57.789+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2010.7ms)","duration":"2010.7ms","level":"slow","span":"7d9cb7cdc65507cb","trace":"98e495a6a9a4afb283d65f6a887c2aeb"}
-{"@timestamp":"2026-06-25T23:36:57.789+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2010.7ms","level":"info","span":"7d9cb7cdc65507cb","trace":"98e495a6a9a4afb283d65f6a887c2aeb"}
-{"@timestamp":"2026-06-25T23:36:58.011+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=22.7Mi, Sys=19.6Mi, NumGC=16","level":"stat"}
-{"@timestamp":"2026-06-25T23:36:58.028+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:37:00.643+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2023.0ms, med: 2024.2ms, 90th: 2034.2ms, 99th: 2034.2ms, 99.9th: 2034.2ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:37:02.822+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.4ms)","duration":"2030.4ms","level":"slow","span":"f705c867c7ae074e","trace":"efe8add21dfa341667829b3ed52d39b8"}
-{"@timestamp":"2026-06-25T23:37:02.822+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.4ms","level":"info","span":"f705c867c7ae074e","trace":"efe8add21dfa341667829b3ed52d39b8"}
-{"@timestamp":"2026-06-25T23:37:07.856+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.4ms)","duration":"2029.4ms","level":"slow","span":"fd5a4a966c266aa2","trace":"24885d605e8144f8538cefa28f0216bb"}
-{"@timestamp":"2026-06-25T23:37:07.857+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.4ms","level":"info","span":"fd5a4a966c266aa2","trace":"24885d605e8144f8538cefa28f0216bb"}
-{"@timestamp":"2026-06-25T23:37:12.885+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.7ms)","duration":"2023.7ms","level":"slow","span":"e143c7c5bc753712","trace":"d76bf7a5c5901b3931057794fe161456"}
-{"@timestamp":"2026-06-25T23:37:12.885+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.7ms","level":"info","span":"e143c7c5bc753712","trace":"d76bf7a5c5901b3931057794fe161456"}
-{"@timestamp":"2026-06-25T23:37:17.920+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.8ms)","duration":"2031.8ms","level":"slow","span":"e1cc5fb2a88ed4a5","trace":"aa4c8d4dda65bde21baf3a4649ab1181"}
-{"@timestamp":"2026-06-25T23:37:17.920+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.8ms","level":"info","span":"e1cc5fb2a88ed4a5","trace":"aa4c8d4dda65bde21baf3a4649ab1181"}
-{"@timestamp":"2026-06-25T23:37:22.934+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2008.6ms)","duration":"2008.6ms","level":"slow","span":"c333ba87a218549b","trace":"f75823387e38292c611b5d0020d51a24"}
-{"@timestamp":"2026-06-25T23:37:22.934+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2008.6ms","level":"info","span":"c333ba87a218549b","trace":"f75823387e38292c611b5d0020d51a24"}
-{"@timestamp":"2026-06-25T23:37:27.963+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.5ms)","duration":"2022.5ms","level":"slow","span":"bcb8d063fd24d51b","trace":"70f2a08ae0eac80df23abd1348a45e4f"}
-{"@timestamp":"2026-06-25T23:37:27.963+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.5ms","level":"info","span":"bcb8d063fd24d51b","trace":"70f2a08ae0eac80df23abd1348a45e4f"}
-{"@timestamp":"2026-06-25T23:37:33.007+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.4ms)","duration":"2034.4ms","level":"slow","span":"11d9775a148f5a12","trace":"f9fc6eff86b32e9f48ad4194b1c7c1e2"}
-{"@timestamp":"2026-06-25T23:37:33.007+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.4ms","level":"info","span":"11d9775a148f5a12","trace":"f9fc6eff86b32e9f48ad4194b1c7c1e2"}
-{"@timestamp":"2026-06-25T23:37:38.038+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.0ms)","duration":"2029.0ms","level":"slow","span":"8f609432bc9faf61","trace":"b054a7996fcc7d85d0c503411b82d776"}
-{"@timestamp":"2026-06-25T23:37:38.038+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.0ms","level":"info","span":"8f609432bc9faf61","trace":"b054a7996fcc7d85d0c503411b82d776"}
-{"@timestamp":"2026-06-25T23:37:43.071+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.5ms)","duration":"2029.5ms","level":"slow","span":"cf9efd0d2d96eb53","trace":"ca25cd7376f8d5a0408e82833635204c"}
-{"@timestamp":"2026-06-25T23:37:43.071+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.5ms","level":"info","span":"cf9efd0d2d96eb53","trace":"ca25cd7376f8d5a0408e82833635204c"}
-{"@timestamp":"2026-06-25T23:37:48.097+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.3ms)","duration":"2023.3ms","level":"slow","span":"a103c555d5d85e8f","trace":"2c8e15f9b480764199b3e5baa0634aba"}
-{"@timestamp":"2026-06-25T23:37:48.098+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.3ms","level":"info","span":"a103c555d5d85e8f","trace":"2c8e15f9b480764199b3e5baa0634aba"}
-{"@timestamp":"2026-06-25T23:37:53.118+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.2ms)","duration":"2016.2ms","level":"slow","span":"2420bedb4586f390","trace":"1613ce7f5aa9a595ac154bf5fb8f231a"}
-{"@timestamp":"2026-06-25T23:37:53.118+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.2ms","level":"info","span":"2420bedb4586f390","trace":"1613ce7f5aa9a595ac154bf5fb8f231a"}
-{"@timestamp":"2026-06-25T23:37:58.012+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=22.9Mi, Sys=19.6Mi, NumGC=16","level":"stat"}
-{"@timestamp":"2026-06-25T23:37:58.028+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 11, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:37:58.138+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.5ms)","duration":"2016.5ms","level":"slow","span":"51979cf48a99030a","trace":"6855684717d52a026c2598d009d165c7"}
-{"@timestamp":"2026-06-25T23:37:58.138+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.5ms","level":"info","span":"51979cf48a99030a","trace":"6855684717d52a026c2598d009d165c7"}
-{"@timestamp":"2026-06-25T23:38:00.642+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2024.5ms, med: 2028.9ms, 90th: 2034.3ms, 99th: 2034.3ms, 99.9th: 2034.3ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:38:03.165+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.2ms)","duration":"2022.2ms","level":"slow","span":"feeba565dde2db97","trace":"3835f66964fce786eb6a84f5a38b4f59"}
-{"@timestamp":"2026-06-25T23:38:03.165+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.2ms","level":"info","span":"feeba565dde2db97","trace":"3835f66964fce786eb6a84f5a38b4f59"}
-{"@timestamp":"2026-06-25T23:38:08.193+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.9ms)","duration":"2025.9ms","level":"slow","span":"2a8b6b99d669dd5b","trace":"7a1f8052cb4dd218fbd73b04a6bb863d"}
-{"@timestamp":"2026-06-25T23:38:08.194+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.9ms","level":"info","span":"2a8b6b99d669dd5b","trace":"7a1f8052cb4dd218fbd73b04a6bb863d"}
-{"@timestamp":"2026-06-25T23:38:13.219+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.5ms)","duration":"2023.5ms","level":"slow","span":"0147ca8ea31491b2","trace":"bd4eb31a74852588b2ffd8b69e66b848"}
-{"@timestamp":"2026-06-25T23:38:13.219+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.5ms","level":"info","span":"0147ca8ea31491b2","trace":"bd4eb31a74852588b2ffd8b69e66b848"}
-{"@timestamp":"2026-06-25T23:38:18.250+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.1ms)","duration":"2026.1ms","level":"slow","span":"838e6e473492d1f5","trace":"8f76cc6ce22e0fb696816df31737ef62"}
-{"@timestamp":"2026-06-25T23:38:18.250+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.1ms","level":"info","span":"838e6e473492d1f5","trace":"8f76cc6ce22e0fb696816df31737ef62"}
-{"@timestamp":"2026-06-25T23:38:23.276+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.1ms)","duration":"2023.1ms","level":"slow","span":"2df28dbdd7c49f50","trace":"23447716d609f78700c5079dfdd24a28"}
-{"@timestamp":"2026-06-25T23:38:23.276+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.1ms","level":"info","span":"2df28dbdd7c49f50","trace":"23447716d609f78700c5079dfdd24a28"}
-{"@timestamp":"2026-06-25T23:38:28.306+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.5ms)","duration":"2025.5ms","level":"slow","span":"452839e7ce8b3bc3","trace":"4b39195474fc7c5d9a11484319b5b7e6"}
-{"@timestamp":"2026-06-25T23:38:28.307+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.5ms","level":"info","span":"452839e7ce8b3bc3","trace":"4b39195474fc7c5d9a11484319b5b7e6"}
-{"@timestamp":"2026-06-25T23:38:33.329+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2020.7ms)","duration":"2020.7ms","level":"slow","span":"4c87051248197308","trace":"200270696e2afddc8b5c092067d37d3b"}
-{"@timestamp":"2026-06-25T23:38:33.329+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2020.7ms","level":"info","span":"4c87051248197308","trace":"200270696e2afddc8b5c092067d37d3b"}
-{"@timestamp":"2026-06-25T23:38:38.357+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2019.0ms)","duration":"2019.0ms","level":"slow","span":"1f221d3bcef8803d","trace":"4f6dd10cf6a279174a1eba2509676103"}
-{"@timestamp":"2026-06-25T23:38:38.358+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2019.0ms","level":"info","span":"1f221d3bcef8803d","trace":"4f6dd10cf6a279174a1eba2509676103"}
-{"@timestamp":"2026-06-25T23:38:43.391+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.0ms)","duration":"2029.0ms","level":"slow","span":"789e2b13f0a374a1","trace":"5fe7d6bc7d4c0082a25338bdd8b1e1c8"}
-{"@timestamp":"2026-06-25T23:38:43.391+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.0ms","level":"info","span":"789e2b13f0a374a1","trace":"5fe7d6bc7d4c0082a25338bdd8b1e1c8"}
-{"@timestamp":"2026-06-25T23:38:48.427+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.0ms)","duration":"2031.0ms","level":"slow","span":"d8a972458cbdedd0","trace":"1ff9d55fff83d21623f41d804c07d232"}
-{"@timestamp":"2026-06-25T23:38:48.427+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.0ms","level":"info","span":"d8a972458cbdedd0","trace":"1ff9d55fff83d21623f41d804c07d232"}
-{"@timestamp":"2026-06-25T23:38:53.452+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.9ms)","duration":"2021.9ms","level":"slow","span":"547a404d920567c9","trace":"b1a5f0d6a5de17c64de3afdc73540486"}
-{"@timestamp":"2026-06-25T23:38:53.452+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.9ms","level":"info","span":"547a404d920567c9","trace":"b1a5f0d6a5de17c64de3afdc73540486"}
-{"@timestamp":"2026-06-25T23:38:58.011+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=23.5Mi, Sys=19.6Mi, NumGC=17","level":"stat"}
-{"@timestamp":"2026-06-25T23:38:58.028+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:38:58.482+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.8ms)","duration":"2025.8ms","level":"slow","span":"4f967b8530befd01","trace":"ca3d70f3838e614d351fac72e3c2566c"}
-{"@timestamp":"2026-06-25T23:38:58.482+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.8ms","level":"info","span":"4f967b8530befd01","trace":"ca3d70f3838e614d351fac72e3c2566c"}
-{"@timestamp":"2026-06-25T23:39:00.642+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2024.3ms, med: 2025.4ms, 90th: 2030.8ms, 99th: 2030.8ms, 99.9th: 2030.8ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:39:03.489+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2003.2ms)","duration":"2003.2ms","level":"slow","span":"429a25bc6b65db60","trace":"95fd3bfd99f5fbccef9316e0903daf32"}
-{"@timestamp":"2026-06-25T23:39:03.489+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2003.2ms","level":"info","span":"429a25bc6b65db60","trace":"95fd3bfd99f5fbccef9316e0903daf32"}
-{"@timestamp":"2026-06-25T23:39:08.529+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.5ms)","duration":"2035.5ms","level":"slow","span":"73130fc2b7b01e7a","trace":"edc46bc86ac7a1c9955ce419adfc7473"}
-{"@timestamp":"2026-06-25T23:39:08.529+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.5ms","level":"info","span":"73130fc2b7b01e7a","trace":"edc46bc86ac7a1c9955ce419adfc7473"}
-{"@timestamp":"2026-06-25T23:39:13.553+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.0ms)","duration":"2022.0ms","level":"slow","span":"63abe5e2c464e8b9","trace":"02c8942269f2019b101b28b09fbdd052"}
-{"@timestamp":"2026-06-25T23:39:13.553+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.0ms","level":"info","span":"63abe5e2c464e8b9","trace":"02c8942269f2019b101b28b09fbdd052"}
-{"@timestamp":"2026-06-25T23:39:18.597+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2039.4ms)","duration":"2039.4ms","level":"slow","span":"ede9c0951e9b31dc","trace":"b0d48a395f0fd58fe1ad2f86ebb151f7"}
-{"@timestamp":"2026-06-25T23:39:18.597+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2039.4ms","level":"info","span":"ede9c0951e9b31dc","trace":"b0d48a395f0fd58fe1ad2f86ebb151f7"}
-{"@timestamp":"2026-06-25T23:39:23.631+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.8ms)","duration":"2029.8ms","level":"slow","span":"e5d0e9cd7f95e7c5","trace":"634d689d30866c45281de9d8584b18f2"}
-{"@timestamp":"2026-06-25T23:39:23.631+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.8ms","level":"info","span":"e5d0e9cd7f95e7c5","trace":"634d689d30866c45281de9d8584b18f2"}
-{"@timestamp":"2026-06-25T23:39:28.647+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2012.2ms)","duration":"2012.2ms","level":"slow","span":"1fbca200948aaf9b","trace":"69bce246a4836048e84277a60dd5c272"}
-{"@timestamp":"2026-06-25T23:39:28.647+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2012.2ms","level":"info","span":"1fbca200948aaf9b","trace":"69bce246a4836048e84277a60dd5c272"}
-{"@timestamp":"2026-06-25T23:39:33.661+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2011.7ms)","duration":"2011.7ms","level":"slow","span":"fd280423cd0d3680","trace":"fea7c5e952f83504228b16128029b42b"}
-{"@timestamp":"2026-06-25T23:39:33.661+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2011.7ms","level":"info","span":"fd280423cd0d3680","trace":"fea7c5e952f83504228b16128029b42b"}
-{"@timestamp":"2026-06-25T23:39:38.680+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2017.1ms)","duration":"2017.1ms","level":"slow","span":"4115dc3e55d4f165","trace":"c85ab5630ef5412af20c003f4c5233fd"}
-{"@timestamp":"2026-06-25T23:39:38.680+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2017.1ms","level":"info","span":"4115dc3e55d4f165","trace":"c85ab5630ef5412af20c003f4c5233fd"}
-{"@timestamp":"2026-06-25T23:39:43.713+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.6ms)","duration":"2029.6ms","level":"slow","span":"55fb0fb4801d62c7","trace":"85b93646208b4c5358bebde5b9655816"}
-{"@timestamp":"2026-06-25T23:39:43.713+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.6ms","level":"info","span":"55fb0fb4801d62c7","trace":"85b93646208b4c5358bebde5b9655816"}
-{"@timestamp":"2026-06-25T23:39:48.740+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.9ms)","duration":"2024.9ms","level":"slow","span":"204245d95a6cb5a5","trace":"507b75780baefdec3f2da79e254a3873"}
-{"@timestamp":"2026-06-25T23:39:48.741+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.9ms","level":"info","span":"204245d95a6cb5a5","trace":"507b75780baefdec3f2da79e254a3873"}
-{"@timestamp":"2026-06-25T23:39:53.757+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2014.2ms)","duration":"2014.2ms","level":"slow","span":"1f7462f70df0f4dc","trace":"b4877c2bd39867344a82d5c5a10ed1fe"}
-{"@timestamp":"2026-06-25T23:39:53.757+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2014.2ms","level":"info","span":"1f7462f70df0f4dc","trace":"b4877c2bd39867344a82d5c5a10ed1fe"}
-{"@timestamp":"2026-06-25T23:39:58.011+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=23.6Mi, Sys=19.6Mi, NumGC=17","level":"stat"}
-{"@timestamp":"2026-06-25T23:39:58.028+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:39:58.805+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.2ms)","duration":"2045.2ms","level":"slow","span":"9a3325996b8e4fe7","trace":"c1a90c04b746eb88fd8b774217a5b497"}
-{"@timestamp":"2026-06-25T23:39:58.806+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.2ms","level":"info","span":"9a3325996b8e4fe7","trace":"c1a90c04b746eb88fd8b774217a5b497"}
-{"@timestamp":"2026-06-25T23:40:00.642+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2023.2ms, med: 2024.8ms, 90th: 2039.9ms, 99th: 2039.9ms, 99.9th: 2039.9ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:40:03.833+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.3ms)","duration":"2023.3ms","level":"slow","span":"bd34807c3bd49d3c","trace":"864484ea963b209b073b5bca6d4d8fe8"}
-{"@timestamp":"2026-06-25T23:40:03.833+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.3ms","level":"info","span":"bd34807c3bd49d3c","trace":"864484ea963b209b073b5bca6d4d8fe8"}
-{"@timestamp":"2026-06-25T23:40:08.860+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2020.4ms)","duration":"2020.4ms","level":"slow","span":"d3bf22cc0d2646e4","trace":"24bf12ef41a6da7bb72ce19923618580"}
-{"@timestamp":"2026-06-25T23:40:08.861+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2020.4ms","level":"info","span":"d3bf22cc0d2646e4","trace":"24bf12ef41a6da7bb72ce19923618580"}
-{"@timestamp":"2026-06-25T23:40:13.896+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.7ms)","duration":"2032.7ms","level":"slow","span":"b3237e95681113db","trace":"83ad3165b22b163ff85dc847fef4e823"}
-{"@timestamp":"2026-06-25T23:40:13.896+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.7ms","level":"info","span":"b3237e95681113db","trace":"83ad3165b22b163ff85dc847fef4e823"}
-{"@timestamp":"2026-06-25T23:40:18.921+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.3ms)","duration":"2021.3ms","level":"slow","span":"9cfb3917a6fb1ce2","trace":"83d1bc974a6f10f87150e6d29dd90ae7"}
-{"@timestamp":"2026-06-25T23:40:18.922+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.3ms","level":"info","span":"9cfb3917a6fb1ce2","trace":"83d1bc974a6f10f87150e6d29dd90ae7"}
-{"@timestamp":"2026-06-25T23:40:23.982+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2055.9ms)","duration":"2055.9ms","level":"slow","span":"d8829b69b3886ea4","trace":"a787d4a53e9d92a7e4c9fafaad1cd07a"}
-{"@timestamp":"2026-06-25T23:40:23.982+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2055.9ms","level":"info","span":"d8829b69b3886ea4","trace":"a787d4a53e9d92a7e4c9fafaad1cd07a"}
-{"@timestamp":"2026-06-25T23:40:29.002+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2017.4ms)","duration":"2017.4ms","level":"slow","span":"70f1628009908592","trace":"176b6af8b918fd85972e37fe2765613a"}
-{"@timestamp":"2026-06-25T23:40:29.003+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2017.4ms","level":"info","span":"70f1628009908592","trace":"176b6af8b918fd85972e37fe2765613a"}
-{"@timestamp":"2026-06-25T23:40:34.015+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2009.5ms)","duration":"2009.5ms","level":"slow","span":"b8a89e93e9b10e8d","trace":"ddab89b8c8cc8670c315f4cfa1394608"}
-{"@timestamp":"2026-06-25T23:40:34.015+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2009.5ms","level":"info","span":"b8a89e93e9b10e8d","trace":"ddab89b8c8cc8670c315f4cfa1394608"}
-{"@timestamp":"2026-06-25T23:40:39.036+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2014.5ms)","duration":"2014.5ms","level":"slow","span":"23e5e4ae68a3da48","trace":"802aaa4162f5d4f767d9e3016df21365"}
-{"@timestamp":"2026-06-25T23:40:39.036+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2014.5ms","level":"info","span":"23e5e4ae68a3da48","trace":"802aaa4162f5d4f767d9e3016df21365"}
-{"@timestamp":"2026-06-25T23:40:44.060+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.7ms)","duration":"2021.7ms","level":"slow","span":"02d3071610235a6d","trace":"d1d615ac8fa0f6b258f9f270cd43d33d"}
-{"@timestamp":"2026-06-25T23:40:44.060+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.7ms","level":"info","span":"02d3071610235a6d","trace":"d1d615ac8fa0f6b258f9f270cd43d33d"}
-{"@timestamp":"2026-06-25T23:40:49.096+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.7ms)","duration":"2031.7ms","level":"slow","span":"ec052c1d73ec301f","trace":"3e8eaed9542ce4f1dbe3106c05dec3b5"}
-{"@timestamp":"2026-06-25T23:40:49.096+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.7ms","level":"info","span":"ec052c1d73ec301f","trace":"3e8eaed9542ce4f1dbe3106c05dec3b5"}
-{"@timestamp":"2026-06-25T23:40:54.125+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.8ms)","duration":"2021.8ms","level":"slow","span":"dde568b17c425be2","trace":"a52503fef844743ff39043e8f7bdb0f2"}
-{"@timestamp":"2026-06-25T23:40:54.125+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.8ms","level":"info","span":"dde568b17c425be2","trace":"a52503fef844743ff39043e8f7bdb0f2"}
-{"@timestamp":"2026-06-25T23:40:58.010+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=24.2Mi, Sys=19.6Mi, NumGC=18","level":"stat"}
-{"@timestamp":"2026-06-25T23:40:58.028+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:40:59.148+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2019.6ms)","duration":"2019.6ms","level":"slow","span":"636918dbe3676070","trace":"0455f403193202efeaad5e0a8c6d38de"}
-{"@timestamp":"2026-06-25T23:40:59.148+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2019.6ms","level":"info","span":"636918dbe3676070","trace":"0455f403193202efeaad5e0a8c6d38de"}
-{"@timestamp":"2026-06-25T23:41:00.642+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2024.0ms, med: 2021.6ms, 90th: 2055.8ms, 99th: 2055.8ms, 99.9th: 2055.8ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:41:04.196+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.5ms)","duration":"2043.5ms","level":"slow","span":"dbafc7b8d05e7c52","trace":"0e54e30fd55deb8ce5e235878c779119"}
-{"@timestamp":"2026-06-25T23:41:04.197+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.5ms","level":"info","span":"dbafc7b8d05e7c52","trace":"0e54e30fd55deb8ce5e235878c779119"}
-{"@timestamp":"2026-06-25T23:41:09.222+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.9ms)","duration":"2023.9ms","level":"slow","span":"c65f29588a679ade","trace":"174dffa79e1ded1d5be0b00685c14c3d"}
-{"@timestamp":"2026-06-25T23:41:09.223+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.9ms","level":"info","span":"c65f29588a679ade","trace":"174dffa79e1ded1d5be0b00685c14c3d"}
-{"@timestamp":"2026-06-25T23:41:14.257+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.5ms)","duration":"2032.5ms","level":"slow","span":"0bfaa6b3456e1cee","trace":"4ac6562354bb2a09b37701f009bbdebf"}
-{"@timestamp":"2026-06-25T23:41:14.257+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.5ms","level":"info","span":"0bfaa6b3456e1cee","trace":"4ac6562354bb2a09b37701f009bbdebf"}
-{"@timestamp":"2026-06-25T23:41:19.291+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.4ms)","duration":"2030.4ms","level":"slow","span":"bee20c5d5db72b19","trace":"16cab850f614050df693d55af67c9694"}
-{"@timestamp":"2026-06-25T23:41:19.291+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.4ms","level":"info","span":"bee20c5d5db72b19","trace":"16cab850f614050df693d55af67c9694"}
-{"@timestamp":"2026-06-25T23:41:24.326+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.4ms)","duration":"2032.4ms","level":"slow","span":"f97d56462103f33b","trace":"9aea5a71fdff9115ec8c52d42a18bfb1"}
-{"@timestamp":"2026-06-25T23:41:24.326+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.4ms","level":"info","span":"f97d56462103f33b","trace":"9aea5a71fdff9115ec8c52d42a18bfb1"}
-{"@timestamp":"2026-06-25T23:41:29.338+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2007.6ms)","duration":"2007.6ms","level":"slow","span":"ca948191fc231df1","trace":"453fbb0fa369a8f0eea98597e394249f"}
-{"@timestamp":"2026-06-25T23:41:29.338+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2007.6ms","level":"info","span":"ca948191fc231df1","trace":"453fbb0fa369a8f0eea98597e394249f"}
-{"@timestamp":"2026-06-25T23:41:34.389+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2048.0ms)","duration":"2048.0ms","level":"slow","span":"85b7655318276771","trace":"ca3a5c450ed829d8f90c03f59e56e28b"}
-{"@timestamp":"2026-06-25T23:41:34.389+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2048.0ms","level":"info","span":"85b7655318276771","trace":"ca3a5c450ed829d8f90c03f59e56e28b"}
-{"@timestamp":"2026-06-25T23:41:39.427+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.3ms)","duration":"2034.3ms","level":"slow","span":"21771c9a9f0c136e","trace":"0ce0a77bed1dc550f6fc2c254e6441d9"}
-{"@timestamp":"2026-06-25T23:41:39.427+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.3ms","level":"info","span":"21771c9a9f0c136e","trace":"0ce0a77bed1dc550f6fc2c254e6441d9"}
-{"@timestamp":"2026-06-25T23:41:44.493+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2062.0ms)","duration":"2062.0ms","level":"slow","span":"7125acfde88dd2ac","trace":"c9940eda76e39bda478a66b47257b0f3"}
-{"@timestamp":"2026-06-25T23:41:44.493+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2062.0ms","level":"info","span":"7125acfde88dd2ac","trace":"c9940eda76e39bda478a66b47257b0f3"}
-{"@timestamp":"2026-06-25T23:41:49.522+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.6ms)","duration":"2025.6ms","level":"slow","span":"882a9aeb638eb5be","trace":"f72fe9563889f3a9b729821fb7d88832"}
-{"@timestamp":"2026-06-25T23:41:49.522+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.6ms","level":"info","span":"882a9aeb638eb5be","trace":"f72fe9563889f3a9b729821fb7d88832"}
-{"@timestamp":"2026-06-25T23:41:54.559+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2033.8ms)","duration":"2033.8ms","level":"slow","span":"d0be1ee5fcb299d2","trace":"1d8215bc8d4cf2de142053752bf9760b"}
-{"@timestamp":"2026-06-25T23:41:54.559+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2033.8ms","level":"info","span":"d0be1ee5fcb299d2","trace":"1d8215bc8d4cf2de142053752bf9760b"}
-{"@timestamp":"2026-06-25T23:41:58.011+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=24.3Mi, Sys=19.6Mi, NumGC=18","level":"stat"}
-{"@timestamp":"2026-06-25T23:41:58.027+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:41:59.593+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.6ms)","duration":"2029.6ms","level":"slow","span":"b6851655136ed851","trace":"7b309d9335fd427c44b7a664524ae986"}
-{"@timestamp":"2026-06-25T23:41:59.593+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.6ms","level":"info","span":"b6851655136ed851","trace":"7b309d9335fd427c44b7a664524ae986"}
-{"@timestamp":"2026-06-25T23:42:00.641+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2033.5ms, med: 2032.4ms, 90th: 2061.9ms, 99th: 2061.9ms, 99.9th: 2061.9ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:42:04.618+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.3ms)","duration":"2021.3ms","level":"slow","span":"fd46092898ca8242","trace":"322869f8674039b8ef7d2a09207a8747"}
-{"@timestamp":"2026-06-25T23:42:04.618+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.3ms","level":"info","span":"fd46092898ca8242","trace":"322869f8674039b8ef7d2a09207a8747"}
-{"@timestamp":"2026-06-25T23:42:09.631+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2010.7ms)","duration":"2010.7ms","level":"slow","span":"5bf3cabe5314c7be","trace":"309c9a24587f21cd1b2b3789d470a1e1"}
-{"@timestamp":"2026-06-25T23:42:09.632+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2010.7ms","level":"info","span":"5bf3cabe5314c7be","trace":"309c9a24587f21cd1b2b3789d470a1e1"}
-{"@timestamp":"2026-06-25T23:42:14.646+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2008.4ms)","duration":"2008.4ms","level":"slow","span":"765414a589a11e28","trace":"0261294e6ebe8a409dd2b8b4daa00579"}
-{"@timestamp":"2026-06-25T23:42:14.646+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2008.4ms","level":"info","span":"765414a589a11e28","trace":"0261294e6ebe8a409dd2b8b4daa00579"}
-{"@timestamp":"2026-06-25T23:42:19.673+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.3ms)","duration":"2023.3ms","level":"slow","span":"18da86b9fade2dac","trace":"857be7a2868dda67a58bb463d38c4c02"}
-{"@timestamp":"2026-06-25T23:42:19.673+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.3ms","level":"info","span":"18da86b9fade2dac","trace":"857be7a2868dda67a58bb463d38c4c02"}
-{"@timestamp":"2026-06-25T23:42:24.696+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2020.6ms)","duration":"2020.6ms","level":"slow","span":"11868ce394938cc1","trace":"756b067c2279f564724c272817b5de41"}
-{"@timestamp":"2026-06-25T23:42:24.697+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2020.6ms","level":"info","span":"11868ce394938cc1","trace":"756b067c2279f564724c272817b5de41"}
-{"@timestamp":"2026-06-25T23:42:29.736+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2036.5ms)","duration":"2036.5ms","level":"slow","span":"37a3295e7cdf74ca","trace":"4e658df85ffc7c6b5d600503f5897239"}
-{"@timestamp":"2026-06-25T23:42:29.736+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2036.5ms","level":"info","span":"37a3295e7cdf74ca","trace":"4e658df85ffc7c6b5d600503f5897239"}
-{"@timestamp":"2026-06-25T23:42:34.763+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.7ms)","duration":"2023.7ms","level":"slow","span":"824a14f9d5f20eb3","trace":"ddbccf518aedbf1efcfec30ab796ca20"}
-{"@timestamp":"2026-06-25T23:42:34.763+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.7ms","level":"info","span":"824a14f9d5f20eb3","trace":"ddbccf518aedbf1efcfec30ab796ca20"}
-{"@timestamp":"2026-06-25T23:42:39.793+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.1ms)","duration":"2026.1ms","level":"slow","span":"0f00458144759434","trace":"0e0d92e753c49f955746ea12d960c2b5"}
-{"@timestamp":"2026-06-25T23:42:39.793+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.1ms","level":"info","span":"0f00458144759434","trace":"0e0d92e753c49f955746ea12d960c2b5"}
-{"@timestamp":"2026-06-25T23:42:44.823+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.9ms)","duration":"2027.9ms","level":"slow","span":"8037c277a47c1c2d","trace":"95df05c3fcef2404de9adff1b5ad02be"}
-{"@timestamp":"2026-06-25T23:42:44.823+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.9ms","level":"info","span":"8037c277a47c1c2d","trace":"95df05c3fcef2404de9adff1b5ad02be"}
-{"@timestamp":"2026-06-25T23:42:49.854+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.8ms)","duration":"2027.8ms","level":"slow","span":"873a20ea20fca831","trace":"755a66ca0011de8afd3e69a917a475ce"}
-{"@timestamp":"2026-06-25T23:42:49.854+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.8ms","level":"info","span":"873a20ea20fca831","trace":"755a66ca0011de8afd3e69a917a475ce"}
-{"@timestamp":"2026-06-25T23:42:54.882+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.2ms)","duration":"2023.2ms","level":"slow","span":"bc4816f50fb7eb11","trace":"e9027a29b65305d422b97ef81a5ac60c"}
-{"@timestamp":"2026-06-25T23:42:54.882+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.2ms","level":"info","span":"bc4816f50fb7eb11","trace":"e9027a29b65305d422b97ef81a5ac60c"}
-{"@timestamp":"2026-06-25T23:42:58.011+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=24.9Mi, Sys=19.6Mi, NumGC=19","level":"stat"}
-{"@timestamp":"2026-06-25T23:42:58.027+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:42:59.913+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.3ms)","duration":"2026.3ms","level":"slow","span":"6881863f3b6e5cc8","trace":"b64e3778a51356cb83b765f9253e50e2"}
-{"@timestamp":"2026-06-25T23:42:59.913+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.3ms","level":"info","span":"6881863f3b6e5cc8","trace":"b64e3778a51356cb83b765f9253e50e2"}
-{"@timestamp":"2026-06-25T23:43:00.642+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2022.8ms, med: 2023.6ms, 90th: 2036.5ms, 99th: 2036.5ms, 99.9th: 2036.5ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:43:04.926+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2009.0ms)","duration":"2009.0ms","level":"slow","span":"f3b778d70a552fe3","trace":"0bab6f31c60b77cb0608b598428d4f69"}
-{"@timestamp":"2026-06-25T23:43:04.926+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2009.0ms","level":"info","span":"f3b778d70a552fe3","trace":"0bab6f31c60b77cb0608b598428d4f69"}
-{"@timestamp":"2026-06-25T23:43:09.947+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2019.3ms)","duration":"2019.3ms","level":"slow","span":"f67a0292d70fe429","trace":"d0060732c841798f094a4919f1c41863"}
-{"@timestamp":"2026-06-25T23:43:09.947+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2019.3ms","level":"info","span":"f67a0292d70fe429","trace":"d0060732c841798f094a4919f1c41863"}
-{"@timestamp":"2026-06-25T23:43:15.004+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2053.7ms)","duration":"2053.7ms","level":"slow","span":"5a08e051bd7c962c","trace":"48ed8479421c571e6d2603fed5ba56e7"}
-{"@timestamp":"2026-06-25T23:43:15.004+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2053.7ms","level":"info","span":"5a08e051bd7c962c","trace":"48ed8479421c571e6d2603fed5ba56e7"}
-{"@timestamp":"2026-06-25T23:43:20.030+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.4ms)","duration":"2023.4ms","level":"slow","span":"697a47877a31b52b","trace":"3a84bd09ca4a724978c788d15775b7cf"}
-{"@timestamp":"2026-06-25T23:43:20.030+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.4ms","level":"info","span":"697a47877a31b52b","trace":"3a84bd09ca4a724978c788d15775b7cf"}
-{"@timestamp":"2026-06-25T23:43:25.060+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.7ms)","duration":"2023.7ms","level":"slow","span":"b8c6d1d1deb64cde","trace":"f62ec178fe718b9bb7e52ab83dd4a194"}
-{"@timestamp":"2026-06-25T23:43:25.060+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.7ms","level":"info","span":"b8c6d1d1deb64cde","trace":"f62ec178fe718b9bb7e52ab83dd4a194"}
-{"@timestamp":"2026-06-25T23:43:30.096+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.3ms)","duration":"2032.3ms","level":"slow","span":"7a5d161704d3b414","trace":"ef968487b03899032daa76ac6714504c"}
-{"@timestamp":"2026-06-25T23:43:30.096+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.3ms","level":"info","span":"7a5d161704d3b414","trace":"ef968487b03899032daa76ac6714504c"}
-{"@timestamp":"2026-06-25T23:43:35.121+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.2ms)","duration":"2022.2ms","level":"slow","span":"f90889cbde7e7773","trace":"4b1a9954878bb80cb8bd9d3fe2e2388d"}
-{"@timestamp":"2026-06-25T23:43:35.121+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.2ms","level":"info","span":"f90889cbde7e7773","trace":"4b1a9954878bb80cb8bd9d3fe2e2388d"}
-{"@timestamp":"2026-06-25T23:43:40.126+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2001.8ms)","duration":"2001.8ms","level":"slow","span":"f647401953fe644f","trace":"61cddf27ce8238997407f16c14c298d0"}
-{"@timestamp":"2026-06-25T23:43:40.126+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2001.8ms","level":"info","span":"f647401953fe644f","trace":"61cddf27ce8238997407f16c14c298d0"}
-{"@timestamp":"2026-06-25T23:43:45.164+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.5ms)","duration":"2034.5ms","level":"slow","span":"f920e6d591a938fe","trace":"c8e80d675853b8624abc00c5ba16c2cb"}
-{"@timestamp":"2026-06-25T23:43:45.164+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.5ms","level":"info","span":"f920e6d591a938fe","trace":"c8e80d675853b8624abc00c5ba16c2cb"}
-{"@timestamp":"2026-06-25T23:43:50.209+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.1ms)","duration":"2044.1ms","level":"slow","span":"0ebd0926d5c30044","trace":"d74e077be51357435bc38a2c7aacc190"}
-{"@timestamp":"2026-06-25T23:43:50.209+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.1ms","level":"info","span":"0ebd0926d5c30044","trace":"d74e077be51357435bc38a2c7aacc190"}
-{"@timestamp":"2026-06-25T23:43:55.235+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.1ms)","duration":"2024.1ms","level":"slow","span":"6606a557c477de35","trace":"7fd46c68006c2df28a8b1d990158ce2a"}
-{"@timestamp":"2026-06-25T23:43:55.235+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.1ms","level":"info","span":"6606a557c477de35","trace":"7fd46c68006c2df28a8b1d990158ce2a"}
-{"@timestamp":"2026-06-25T23:43:58.011+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=25.0Mi, Sys=19.6Mi, NumGC=19","level":"stat"}
-{"@timestamp":"2026-06-25T23:43:58.027+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:44:00.258+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.2ms)","duration":"2021.2ms","level":"slow","span":"5bc45fb0a2f0fb1a","trace":"f0598b0ab1a143218ee6d3f8e282625e"}
-{"@timestamp":"2026-06-25T23:44:00.258+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.2ms","level":"info","span":"5bc45fb0a2f0fb1a","trace":"f0598b0ab1a143218ee6d3f8e282625e"}
-{"@timestamp":"2026-06-25T23:44:00.641+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2025.7ms, med: 2023.6ms, 90th: 2053.6ms, 99th: 2053.6ms, 99.9th: 2053.6ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:44:05.293+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.5ms)","duration":"2031.5ms","level":"slow","span":"b96b8412980cc580","trace":"1fe87b7a9c00985700f70cdddb6552d5"}
-{"@timestamp":"2026-06-25T23:44:05.293+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.5ms","level":"info","span":"b96b8412980cc580","trace":"1fe87b7a9c00985700f70cdddb6552d5"}
-{"@timestamp":"2026-06-25T23:44:10.324+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.0ms)","duration":"2028.0ms","level":"slow","span":"26de593ed9924c71","trace":"e215365209728d1895664436fb68c5fc"}
-{"@timestamp":"2026-06-25T23:44:10.325+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.0ms","level":"info","span":"26de593ed9924c71","trace":"e215365209728d1895664436fb68c5fc"}
-{"@timestamp":"2026-06-25T23:44:15.344+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.1ms)","duration":"2016.1ms","level":"slow","span":"e6ac7817fc07ffd4","trace":"8c96bec047e0cf07f058a4bf8d656397"}
-{"@timestamp":"2026-06-25T23:44:15.344+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.1ms","level":"info","span":"e6ac7817fc07ffd4","trace":"8c96bec047e0cf07f058a4bf8d656397"}
-{"@timestamp":"2026-06-25T23:44:20.373+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.9ms)","duration":"2026.9ms","level":"slow","span":"5bb6f1f334d262f6","trace":"7914bc90b87397398b55ad9bff817997"}
-{"@timestamp":"2026-06-25T23:44:20.373+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.9ms","level":"info","span":"5bb6f1f334d262f6","trace":"7914bc90b87397398b55ad9bff817997"}
-{"@timestamp":"2026-06-25T23:44:25.407+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.6ms)","duration":"2030.6ms","level":"slow","span":"b698044cceccec85","trace":"df87a634ca4c2f3f376e5d77cbc6e994"}
-{"@timestamp":"2026-06-25T23:44:25.407+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.6ms","level":"info","span":"b698044cceccec85","trace":"df87a634ca4c2f3f376e5d77cbc6e994"}
-{"@timestamp":"2026-06-25T23:44:30.442+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.8ms)","duration":"2032.8ms","level":"slow","span":"70c696a6e619eacb","trace":"9a7156e7b45df4d8861c0f3b26c727b2"}
-{"@timestamp":"2026-06-25T23:44:30.442+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.8ms","level":"info","span":"70c696a6e619eacb","trace":"9a7156e7b45df4d8861c0f3b26c727b2"}
-{"@timestamp":"2026-06-25T23:44:35.459+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2012.1ms)","duration":"2012.1ms","level":"slow","span":"d7471984205dfbfa","trace":"208408cda06b01747f19b0fad58015fb"}
-{"@timestamp":"2026-06-25T23:44:35.459+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2012.1ms","level":"info","span":"d7471984205dfbfa","trace":"208408cda06b01747f19b0fad58015fb"}
-{"@timestamp":"2026-06-25T23:44:40.490+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.7ms)","duration":"2027.7ms","level":"slow","span":"f59baeb64e50d2ea","trace":"9b193564f9d1a8030176e866587df7c4"}
-{"@timestamp":"2026-06-25T23:44:40.490+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.7ms","level":"info","span":"f59baeb64e50d2ea","trace":"9b193564f9d1a8030176e866587df7c4"}
-{"@timestamp":"2026-06-25T23:44:45.524+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.7ms)","duration":"2030.7ms","level":"slow","span":"44964a2d1d2fb507","trace":"0ea425cf47c3d12ded16f5324b557952"}
-{"@timestamp":"2026-06-25T23:44:45.524+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.7ms","level":"info","span":"44964a2d1d2fb507","trace":"0ea425cf47c3d12ded16f5324b557952"}
-{"@timestamp":"2026-06-25T23:44:50.538+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2011.0ms)","duration":"2011.0ms","level":"slow","span":"61f3e43ed87018a1","trace":"5f3b3ac413caef9d430f696c056cefc1"}
-{"@timestamp":"2026-06-25T23:44:50.538+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2011.0ms","level":"info","span":"61f3e43ed87018a1","trace":"5f3b3ac413caef9d430f696c056cefc1"}
-{"@timestamp":"2026-06-25T23:44:55.560+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2018.5ms)","duration":"2018.5ms","level":"slow","span":"387bd3651a314ef0","trace":"6b879eb60f11edd434c95c28f11bc6a1"}
-{"@timestamp":"2026-06-25T23:44:55.560+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2018.5ms","level":"info","span":"387bd3651a314ef0","trace":"6b879eb60f11edd434c95c28f11bc6a1"}
-{"@timestamp":"2026-06-25T23:44:58.010+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=25.6Mi, Sys=19.6Mi, NumGC=20","level":"stat"}
-{"@timestamp":"2026-06-25T23:44:58.027+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:45:00.606+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.4ms)","duration":"2043.4ms","level":"slow","span":"4c5b52dce74c3a74","trace":"d49f21cb6efb8f5a7389ad33ecc3ed05"}
-{"@timestamp":"2026-06-25T23:45:00.607+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.4ms","level":"info","span":"4c5b52dce74c3a74","trace":"d49f21cb6efb8f5a7389ad33ecc3ed05"}
-{"@timestamp":"2026-06-25T23:45:00.641+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2025.7ms, med: 2027.9ms, 90th: 2043.4ms, 99th: 2043.4ms, 99.9th: 2043.4ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:45:05.638+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.1ms)","duration":"2028.1ms","level":"slow","span":"ffa4daaa7ba64039","trace":"c3955998fb4a04d42e09605169485b4a"}
-{"@timestamp":"2026-06-25T23:45:05.639+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.1ms","level":"info","span":"ffa4daaa7ba64039","trace":"c3955998fb4a04d42e09605169485b4a"}
-{"@timestamp":"2026-06-25T23:45:10.661+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2019.9ms)","duration":"2019.9ms","level":"slow","span":"572f39422341eb2c","trace":"f1440c69d9e4494c4c444ccad317d52c"}
-{"@timestamp":"2026-06-25T23:45:10.661+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2019.9ms","level":"info","span":"572f39422341eb2c","trace":"f1440c69d9e4494c4c444ccad317d52c"}
-{"@timestamp":"2026-06-25T23:45:15.689+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.5ms)","duration":"2025.5ms","level":"slow","span":"09e8b7fc27a9c3f7","trace":"24e6e9f1e83e6c47b33ac62b68faf4ae"}
-{"@timestamp":"2026-06-25T23:45:15.689+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.5ms","level":"info","span":"09e8b7fc27a9c3f7","trace":"24e6e9f1e83e6c47b33ac62b68faf4ae"}
-{"@timestamp":"2026-06-25T23:45:20.693+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2001.7ms)","duration":"2001.7ms","level":"slow","span":"69d6500785772b41","trace":"316e83ae121742172209cd8a328b8174"}
-{"@timestamp":"2026-06-25T23:45:20.693+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2001.7ms","level":"info","span":"69d6500785772b41","trace":"316e83ae121742172209cd8a328b8174"}
-{"@timestamp":"2026-06-25T23:45:25.748+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2050.6ms)","duration":"2050.6ms","level":"slow","span":"68a268366118f14d","trace":"c5370cf86f0ddcd99276098d1592dba2"}
-{"@timestamp":"2026-06-25T23:45:25.748+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2050.6ms","level":"info","span":"68a268366118f14d","trace":"c5370cf86f0ddcd99276098d1592dba2"}
-{"@timestamp":"2026-06-25T23:45:30.777+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.8ms)","duration":"2025.8ms","level":"slow","span":"336e981c7318325f","trace":"00cf66966a187a16f80b857e246da23a"}
-{"@timestamp":"2026-06-25T23:45:30.778+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.8ms","level":"info","span":"336e981c7318325f","trace":"00cf66966a187a16f80b857e246da23a"}
-{"@timestamp":"2026-06-25T23:45:35.793+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.0ms)","duration":"2013.0ms","level":"slow","span":"c958ecb39be5df92","trace":"158c569710c7d7dc684f988545e97eb6"}
-{"@timestamp":"2026-06-25T23:45:35.793+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.0ms","level":"info","span":"c958ecb39be5df92","trace":"158c569710c7d7dc684f988545e97eb6"}
-{"@timestamp":"2026-06-25T23:45:40.827+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.6ms)","duration":"2030.6ms","level":"slow","span":"bc0dac4d01a90236","trace":"e535e8f481c3978cafe8cd425caeacd4"}
-{"@timestamp":"2026-06-25T23:45:40.828+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.6ms","level":"info","span":"bc0dac4d01a90236","trace":"e535e8f481c3978cafe8cd425caeacd4"}
-{"@timestamp":"2026-06-25T23:45:45.847+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.5ms)","duration":"2016.5ms","level":"slow","span":"8df916307b29c5da","trace":"f21e44a9c5c6ad5a35e42c1a9a2db572"}
-{"@timestamp":"2026-06-25T23:45:45.847+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.5ms","level":"info","span":"8df916307b29c5da","trace":"f21e44a9c5c6ad5a35e42c1a9a2db572"}
-{"@timestamp":"2026-06-25T23:45:50.865+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.7ms)","duration":"2013.7ms","level":"slow","span":"38e4f49d7b922b99","trace":"8c6d04072a9c7f4f470948571cea228f"}
-{"@timestamp":"2026-06-25T23:45:50.865+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.7ms","level":"info","span":"38e4f49d7b922b99","trace":"8c6d04072a9c7f4f470948571cea228f"}
-{"@timestamp":"2026-06-25T23:45:55.896+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.9ms)","duration":"2024.9ms","level":"slow","span":"26aa509c8bec0aa9","trace":"d7b75da89751b4467954ad3f32eca634"}
-{"@timestamp":"2026-06-25T23:45:55.896+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.9ms","level":"info","span":"26aa509c8bec0aa9","trace":"d7b75da89751b4467954ad3f32eca634"}
-{"@timestamp":"2026-06-25T23:45:58.010+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=25.8Mi, Sys=19.6Mi, NumGC=20","level":"stat"}
-{"@timestamp":"2026-06-25T23:45:58.027+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:46:00.641+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2022.6ms, med: 2025.4ms, 90th: 2050.5ms, 99th: 2050.5ms, 99.9th: 2050.5ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:46:00.927+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.2ms)","duration":"2025.2ms","level":"slow","span":"6291df86410ccc1e","trace":"fbebb1d5c15fb0b555d2a413e22d6401"}
-{"@timestamp":"2026-06-25T23:46:00.927+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.2ms","level":"info","span":"6291df86410ccc1e","trace":"fbebb1d5c15fb0b555d2a413e22d6401"}
-{"@timestamp":"2026-06-25T23:46:05.945+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.6ms)","duration":"2013.6ms","level":"slow","span":"68ac545a6d4c54d5","trace":"b9666109e237c61d8e23e94f3c680cd0"}
-{"@timestamp":"2026-06-25T23:46:05.945+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.6ms","level":"info","span":"68ac545a6d4c54d5","trace":"b9666109e237c61d8e23e94f3c680cd0"}
-{"@timestamp":"2026-06-25T23:46:10.979+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.5ms)","duration":"2030.5ms","level":"slow","span":"b258ef6d8a3389d0","trace":"2cc76fcc44a3460b535e14ae5062454b"}
-{"@timestamp":"2026-06-25T23:46:10.979+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.5ms","level":"info","span":"b258ef6d8a3389d0","trace":"2cc76fcc44a3460b535e14ae5062454b"}
-{"@timestamp":"2026-06-25T23:46:16.000+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.9ms)","duration":"2016.9ms","level":"slow","span":"0df4a1537a0d8e8c","trace":"bad4a4b7c488beccf291a0453f9950b8"}
-{"@timestamp":"2026-06-25T23:46:16.001+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.9ms","level":"info","span":"0df4a1537a0d8e8c","trace":"bad4a4b7c488beccf291a0453f9950b8"}
-{"@timestamp":"2026-06-25T23:46:21.028+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.4ms)","duration":"2021.4ms","level":"slow","span":"3f33cbe175aab89e","trace":"1d5f3f3ecdbd27f6e99c0008b9917750"}
-{"@timestamp":"2026-06-25T23:46:21.028+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.4ms","level":"info","span":"3f33cbe175aab89e","trace":"1d5f3f3ecdbd27f6e99c0008b9917750"}
-{"@timestamp":"2026-06-25T23:46:26.065+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.5ms)","duration":"2034.5ms","level":"slow","span":"3c73e07ff14651ac","trace":"21490d35686ed6f09322971cdecdcb34"}
-{"@timestamp":"2026-06-25T23:46:26.065+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.5ms","level":"info","span":"3c73e07ff14651ac","trace":"21490d35686ed6f09322971cdecdcb34"}
-{"@timestamp":"2026-06-25T23:46:31.112+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.3ms)","duration":"2043.3ms","level":"slow","span":"abb7a14863ad589c","trace":"d9c57fbbbe4ac56b40096ff71539f142"}
-{"@timestamp":"2026-06-25T23:46:31.112+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.3ms","level":"info","span":"abb7a14863ad589c","trace":"d9c57fbbbe4ac56b40096ff71539f142"}
-{"@timestamp":"2026-06-25T23:46:36.145+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.5ms)","duration":"2030.5ms","level":"slow","span":"9d75519f160d30b9","trace":"2b7791530fdd7e799d970cc0fb28bb93"}
-{"@timestamp":"2026-06-25T23:46:36.146+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.5ms","level":"info","span":"9d75519f160d30b9","trace":"2b7791530fdd7e799d970cc0fb28bb93"}
-{"@timestamp":"2026-06-25T23:46:41.174+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.3ms)","duration":"2026.3ms","level":"slow","span":"8facbbde6d3a8e17","trace":"275906fd784c23a14106f807a91b0f6d"}
-{"@timestamp":"2026-06-25T23:46:41.174+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.3ms","level":"info","span":"8facbbde6d3a8e17","trace":"275906fd784c23a14106f807a91b0f6d"}
-{"@timestamp":"2026-06-25T23:46:46.200+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.8ms)","duration":"2022.8ms","level":"slow","span":"62f16be507a5e590","trace":"528001eb010734f5b48c97fcb2987e14"}
-{"@timestamp":"2026-06-25T23:46:46.200+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.8ms","level":"info","span":"62f16be507a5e590","trace":"528001eb010734f5b48c97fcb2987e14"}
-{"@timestamp":"2026-06-25T23:46:51.229+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.8ms)","duration":"2025.8ms","level":"slow","span":"f4826c6ff3848a0b","trace":"aebe2abd461dddc24909bde6fb08abde"}
-{"@timestamp":"2026-06-25T23:46:51.229+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.8ms","level":"info","span":"f4826c6ff3848a0b","trace":"aebe2abd461dddc24909bde6fb08abde"}
-{"@timestamp":"2026-06-25T23:46:56.242+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2008.6ms)","duration":"2008.6ms","level":"slow","span":"724a2b444fdccb0b","trace":"514b85d4e8ef8a6dee22f21b346552e7"}
-{"@timestamp":"2026-06-25T23:46:56.242+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2008.6ms","level":"info","span":"724a2b444fdccb0b","trace":"514b85d4e8ef8a6dee22f21b346552e7"}
-{"@timestamp":"2026-06-25T23:46:58.010+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=26.3Mi, Sys=19.6Mi, NumGC=21","level":"stat"}
-{"@timestamp":"2026-06-25T23:46:58.026+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:47:00.641+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2024.8ms, med: 2025.7ms, 90th: 2043.2ms, 99th: 2043.2ms, 99.9th: 2043.2ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:47:01.251+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2006.5ms)","duration":"2006.5ms","level":"slow","span":"2c8eb86adda030ca","trace":"60d43a3beafce6c35834ecc86bb709cb"}
-{"@timestamp":"2026-06-25T23:47:01.251+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2006.5ms","level":"info","span":"2c8eb86adda030ca","trace":"60d43a3beafce6c35834ecc86bb709cb"}
-{"@timestamp":"2026-06-25T23:47:06.308+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2053.9ms)","duration":"2053.9ms","level":"slow","span":"76d6b7a3cd64ba89","trace":"5af619e568ffa9151e13a8c1e6dfbe68"}
-{"@timestamp":"2026-06-25T23:47:06.308+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2053.9ms","level":"info","span":"76d6b7a3cd64ba89","trace":"5af619e568ffa9151e13a8c1e6dfbe68"}
-{"@timestamp":"2026-06-25T23:47:11.350+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2041.2ms)","duration":"2041.2ms","level":"slow","span":"a48e621bc2caad0a","trace":"bc3a41c69b8a63b94eb6c458684ce65f"}
-{"@timestamp":"2026-06-25T23:47:11.351+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2041.2ms","level":"info","span":"a48e621bc2caad0a","trace":"bc3a41c69b8a63b94eb6c458684ce65f"}
-{"@timestamp":"2026-06-25T23:47:16.376+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.4ms)","duration":"2021.4ms","level":"slow","span":"c700c5e4875c3b62","trace":"4a1a8bf28ecac32c987c064e77697710"}
-{"@timestamp":"2026-06-25T23:47:16.376+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.4ms","level":"info","span":"c700c5e4875c3b62","trace":"4a1a8bf28ecac32c987c064e77697710"}
-{"@timestamp":"2026-06-25T23:47:21.409+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.0ms)","duration":"2030.0ms","level":"slow","span":"5c0f42ff1ff1b6b9","trace":"0dbff98c0e8ddb3d7414f8752e0a8ed4"}
-{"@timestamp":"2026-06-25T23:47:21.409+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.0ms","level":"info","span":"5c0f42ff1ff1b6b9","trace":"0dbff98c0e8ddb3d7414f8752e0a8ed4"}
-{"@timestamp":"2026-06-25T23:47:26.428+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.8ms)","duration":"2016.8ms","level":"slow","span":"87467e956c49839c","trace":"130a00336f283d185d0d68af62be201f"}
-{"@timestamp":"2026-06-25T23:47:26.428+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.8ms","level":"info","span":"87467e956c49839c","trace":"130a00336f283d185d0d68af62be201f"}
-{"@timestamp":"2026-06-25T23:47:31.449+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2018.7ms)","duration":"2018.7ms","level":"slow","span":"77a457f12bc5f95d","trace":"a82ef55fe4c012e2c8c4b8f858eb2965"}
-{"@timestamp":"2026-06-25T23:47:31.449+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2018.7ms","level":"info","span":"77a457f12bc5f95d","trace":"a82ef55fe4c012e2c8c4b8f858eb2965"}
-{"@timestamp":"2026-06-25T23:47:36.477+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.4ms)","duration":"2025.4ms","level":"slow","span":"0f96750ce7636a23","trace":"57da459e9e4757e68bde3f0f7caad501"}
-{"@timestamp":"2026-06-25T23:47:36.477+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.4ms","level":"info","span":"0f96750ce7636a23","trace":"57da459e9e4757e68bde3f0f7caad501"}
-{"@timestamp":"2026-06-25T23:47:41.503+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.9ms)","duration":"2023.9ms","level":"slow","span":"0fdff2473f3455e4","trace":"2e87097b63b98c33e8d3eeb4732bfdf7"}
-{"@timestamp":"2026-06-25T23:47:41.503+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.9ms","level":"info","span":"0fdff2473f3455e4","trace":"2e87097b63b98c33e8d3eeb4732bfdf7"}
-{"@timestamp":"2026-06-25T23:47:46.542+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.5ms)","duration":"2034.5ms","level":"slow","span":"b19121950e913398","trace":"b12aab2a619dbdcf913d03c781cd2877"}
-{"@timestamp":"2026-06-25T23:47:46.542+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.5ms","level":"info","span":"b19121950e913398","trace":"b12aab2a619dbdcf913d03c781cd2877"}
-{"@timestamp":"2026-06-25T23:47:51.573+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.8ms)","duration":"2026.8ms","level":"slow","span":"efde736ba027033c","trace":"1b77be89a577ce949e5ef52dbf3d5168"}
-{"@timestamp":"2026-06-25T23:47:51.573+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.8ms","level":"info","span":"efde736ba027033c","trace":"1b77be89a577ce949e5ef52dbf3d5168"}
-{"@timestamp":"2026-06-25T23:47:56.579+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2002.5ms)","duration":"2002.5ms","level":"slow","span":"d4d8026ad11d5cdd","trace":"83155612cc830f43ec8dd822c10331bd"}
-{"@timestamp":"2026-06-25T23:47:56.579+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2002.5ms","level":"info","span":"d4d8026ad11d5cdd","trace":"83155612cc830f43ec8dd822c10331bd"}
-{"@timestamp":"2026-06-25T23:47:58.010+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=26.5Mi, Sys=19.6Mi, NumGC=21","level":"stat"}
-{"@timestamp":"2026-06-25T23:47:58.027+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:48:00.641+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2025.0ms, med: 2025.4ms, 90th: 2053.8ms, 99th: 2053.8ms, 99.9th: 2053.8ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:48:01.590+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2007.3ms)","duration":"2007.3ms","level":"slow","span":"0b2771b64df747df","trace":"1e00ec1f51520f13b94be02f69691eb8"}
-{"@timestamp":"2026-06-25T23:48:01.591+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2007.3ms","level":"info","span":"0b2771b64df747df","trace":"1e00ec1f51520f13b94be02f69691eb8"}
-{"@timestamp":"2026-06-25T23:48:06.622+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.6ms)","duration":"2027.6ms","level":"slow","span":"499e85ccb8e71daa","trace":"2cb6e5eff796ca1485455520dea4eda6"}
-{"@timestamp":"2026-06-25T23:48:06.622+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.6ms","level":"info","span":"499e85ccb8e71daa","trace":"2cb6e5eff796ca1485455520dea4eda6"}
-{"@timestamp":"2026-06-25T23:48:11.692+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2067.6ms)","duration":"2067.6ms","level":"slow","span":"54443017aaa1f8b5","trace":"bf0e6a50a02f2e8f5aae2d2f0e4282d9"}
-{"@timestamp":"2026-06-25T23:48:11.692+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2067.6ms","level":"info","span":"54443017aaa1f8b5","trace":"bf0e6a50a02f2e8f5aae2d2f0e4282d9"}
-{"@timestamp":"2026-06-25T23:48:16.714+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2019.0ms)","duration":"2019.0ms","level":"slow","span":"7b81be510385a389","trace":"3982fef6913067d24e92b85b48790147"}
-{"@timestamp":"2026-06-25T23:48:16.714+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2019.0ms","level":"info","span":"7b81be510385a389","trace":"3982fef6913067d24e92b85b48790147"}
-{"@timestamp":"2026-06-25T23:48:21.731+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2013.4ms)","duration":"2013.4ms","level":"slow","span":"431c6bc9ffecf236","trace":"9c249d7ddf82c9bff5610f4a91ce52ca"}
-{"@timestamp":"2026-06-25T23:48:21.731+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2013.4ms","level":"info","span":"431c6bc9ffecf236","trace":"9c249d7ddf82c9bff5610f4a91ce52ca"}
-{"@timestamp":"2026-06-25T23:48:26.748+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2012.6ms)","duration":"2012.6ms","level":"slow","span":"1b1a9b457208f1ab","trace":"be1689ccabde59ce471d49fcc037f80e"}
-{"@timestamp":"2026-06-25T23:48:26.748+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2012.6ms","level":"info","span":"1b1a9b457208f1ab","trace":"be1689ccabde59ce471d49fcc037f80e"}
-{"@timestamp":"2026-06-25T23:48:31.776+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.3ms)","duration":"2025.3ms","level":"slow","span":"5bb54c796c0e99f4","trace":"579d8e02bd009a5c7153dc05f5e55514"}
-{"@timestamp":"2026-06-25T23:48:31.776+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.3ms","level":"info","span":"5bb54c796c0e99f4","trace":"579d8e02bd009a5c7153dc05f5e55514"}
-{"@timestamp":"2026-06-25T23:48:36.788+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2008.2ms)","duration":"2008.2ms","level":"slow","span":"67556add6048b470","trace":"292e60e87a7273cdf1cdbdf4aac59ac0"}
-{"@timestamp":"2026-06-25T23:48:36.788+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2008.2ms","level":"info","span":"67556add6048b470","trace":"292e60e87a7273cdf1cdbdf4aac59ac0"}
-{"@timestamp":"2026-06-25T23:48:41.829+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2037.3ms)","duration":"2037.3ms","level":"slow","span":"253a350ab0d314bd","trace":"63675c12d230c401d708ff44c24bab25"}
-{"@timestamp":"2026-06-25T23:48:41.829+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2037.3ms","level":"info","span":"253a350ab0d314bd","trace":"63675c12d230c401d708ff44c24bab25"}
-{"@timestamp":"2026-06-25T23:48:46.853+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2020.4ms)","duration":"2020.4ms","level":"slow","span":"3cf3b65d53a11fdf","trace":"c19ad4fa2e5c5c5a6396971849cc5fe8"}
-{"@timestamp":"2026-06-25T23:48:46.853+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2020.4ms","level":"info","span":"3cf3b65d53a11fdf","trace":"c19ad4fa2e5c5c5a6396971849cc5fe8"}
-{"@timestamp":"2026-06-25T23:48:51.883+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.1ms)","duration":"2026.1ms","level":"slow","span":"917d2289bf67d192","trace":"e0a2c820c48953028e3b07efe814e045"}
-{"@timestamp":"2026-06-25T23:48:51.883+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.1ms","level":"info","span":"917d2289bf67d192","trace":"e0a2c820c48953028e3b07efe814e045"}
-{"@timestamp":"2026-06-25T23:48:56.912+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.4ms)","duration":"2024.4ms","level":"slow","span":"cada348a4214fad7","trace":"e32a29ac753ac06ecc00a6a6e9e17d71"}
-{"@timestamp":"2026-06-25T23:48:56.912+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.4ms","level":"info","span":"cada348a4214fad7","trace":"e32a29ac753ac06ecc00a6a6e9e17d71"}
-{"@timestamp":"2026-06-25T23:48:58.010+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=27.0Mi, Sys=19.6Mi, NumGC=22","level":"stat"}
-{"@timestamp":"2026-06-25T23:48:58.026+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:49:00.641+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2024.0ms, med: 2024.3ms, 90th: 2067.5ms, 99th: 2067.5ms, 99.9th: 2067.5ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:49:01.930+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2014.0ms)","duration":"2014.0ms","level":"slow","span":"726b661cb11a3747","trace":"383241375b7b615282bce2f2ca092834"}
-{"@timestamp":"2026-06-25T23:49:01.930+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2014.0ms","level":"info","span":"726b661cb11a3747","trace":"383241375b7b615282bce2f2ca092834"}
-{"@timestamp":"2026-06-25T23:49:06.957+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.7ms)","duration":"2024.7ms","level":"slow","span":"2b3f19e467c76686","trace":"da3776e1b72584c02b75697524136d72"}
-{"@timestamp":"2026-06-25T23:49:06.957+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.7ms","level":"info","span":"2b3f19e467c76686","trace":"da3776e1b72584c02b75697524136d72"}
-{"@timestamp":"2026-06-25T23:49:11.986+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.7ms)","duration":"2024.7ms","level":"slow","span":"070859a6459ecf81","trace":"0cacebcf8b5014f47352906dbb49dc49"}
-{"@timestamp":"2026-06-25T23:49:11.986+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.7ms","level":"info","span":"070859a6459ecf81","trace":"0cacebcf8b5014f47352906dbb49dc49"}
-{"@timestamp":"2026-06-25T23:49:17.029+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.4ms)","duration":"2035.4ms","level":"slow","span":"0fa5235b981ced96","trace":"1dfd290f7db0b457eaec79e465f1525c"}
-{"@timestamp":"2026-06-25T23:49:17.030+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.4ms","level":"info","span":"0fa5235b981ced96","trace":"1dfd290f7db0b457eaec79e465f1525c"}
-{"@timestamp":"2026-06-25T23:58:48.863+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2036.0ms)","duration":"2036.0ms","level":"slow","span":"058614292f0cac32","trace":"dafa9bdcb35b5cb80e09073e94632408"}
-{"@timestamp":"2026-06-25T23:58:48.863+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2036.0ms","level":"info","span":"058614292f0cac32","trace":"dafa9bdcb35b5cb80e09073e94632408"}
-{"@timestamp":"2026-06-25T23:58:53.903+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.9ms)","duration":"2028.9ms","level":"slow","span":"1f2398d14c268363","trace":"94380091eadc9ab77d574429ad6d093b"}
-{"@timestamp":"2026-06-25T23:58:53.903+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.9ms","level":"info","span":"1f2398d14c268363","trace":"94380091eadc9ab77d574429ad6d093b"}
-{"@timestamp":"2026-06-25T23:58:58.943+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.4ms)","duration":"2032.4ms","level":"slow","span":"cb317771fa6d5176","trace":"06ce5a622b500ab61e3656cc25f50652"}
-{"@timestamp":"2026-06-25T23:58:58.943+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.4ms","level":"info","span":"cb317771fa6d5176","trace":"06ce5a622b500ab61e3656cc25f50652"}
-{"@timestamp":"2026-06-25T23:59:03.975+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.9ms)","duration":"2023.9ms","level":"slow","span":"029a097e7b5fdd1e","trace":"7b16c3a0cb34c73221faf6b4784cd389"}
-{"@timestamp":"2026-06-25T23:59:03.975+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.9ms","level":"info","span":"029a097e7b5fdd1e","trace":"7b16c3a0cb34c73221faf6b4784cd389"}
-{"@timestamp":"2026-06-25T23:59:09.009+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.5ms)","duration":"2027.5ms","level":"slow","span":"dcc1e4064ae394f4","trace":"8e32e244013e597bf89937c86bcf97bf"}
-{"@timestamp":"2026-06-25T23:59:09.009+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.5ms","level":"info","span":"dcc1e4064ae394f4","trace":"8e32e244013e597bf89937c86bcf97bf"}
-{"@timestamp":"2026-06-25T23:59:14.044+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.5ms)","duration":"2027.5ms","level":"slow","span":"4e210f23e9dea25d","trace":"9685971af3b3bcf8865a66b8f3a5d0f5"}
-{"@timestamp":"2026-06-25T23:59:14.044+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.5ms","level":"info","span":"4e210f23e9dea25d","trace":"9685971af3b3bcf8865a66b8f3a5d0f5"}
-{"@timestamp":"2026-06-25T23:59:19.078+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.3ms)","duration":"2027.3ms","level":"slow","span":"7b8340911f2cf0d2","trace":"fb11764a6cab16a7f155b6e109ee7306"}
-{"@timestamp":"2026-06-25T23:59:19.078+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.3ms","level":"info","span":"7b8340911f2cf0d2","trace":"fb11764a6cab16a7f155b6e109ee7306"}
-{"@timestamp":"2026-06-25T23:59:24.108+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.3ms)","duration":"2024.3ms","level":"slow","span":"2bf6be5a1ff281a4","trace":"dcd2a084f40e2107c81419585f67b916"}
-{"@timestamp":"2026-06-25T23:59:24.108+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.3ms","level":"info","span":"2bf6be5a1ff281a4","trace":"dcd2a084f40e2107c81419585f67b916"}
-{"@timestamp":"2026-06-25T23:59:24.838+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=27.1Mi, Sys=19.6Mi, NumGC=22","level":"stat"}
-{"@timestamp":"2026-06-25T23:59:24.855+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-25T23:59:27.469+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2027.1ms, med: 2027.4ms, 90th: 2035.9ms, 99th: 2035.9ms, 99.9th: 2035.9ms","level":"stat"}
-{"@timestamp":"2026-06-25T23:59:29.145+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.9ms)","duration":"2032.9ms","level":"slow","span":"f4ae2c793a238106","trace":"05a19991b88dc1b1ba4300dba2ad72b8"}
-{"@timestamp":"2026-06-25T23:59:29.145+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.9ms","level":"info","span":"f4ae2c793a238106","trace":"05a19991b88dc1b1ba4300dba2ad72b8"}
-{"@timestamp":"2026-06-26T00:00:15.737+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(43586.3ms)","duration":"43586.3ms","level":"slow","span":"d4b194d2f1f14451","trace":"13bb30158e54f5331bbe9ffa9a2a67ee"}
-{"@timestamp":"2026-06-26T00:00:15.737+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"43586.3ms","level":"info","span":"d4b194d2f1f14451","trace":"13bb30158e54f5331bbe9ffa9a2a67ee"}
-{"@timestamp":"2026-06-26T00:00:20.805+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2063.2ms)","duration":"2063.2ms","level":"slow","span":"1f473414065a5b7e","trace":"c7a34762b1b9959c50fe61f2f46fb859"}
-{"@timestamp":"2026-06-26T00:00:20.806+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2063.2ms","level":"info","span":"1f473414065a5b7e","trace":"c7a34762b1b9959c50fe61f2f46fb859"}
-2026/06/26 00:00:32 job worker claim error: read tcp 127.0.0.1:63284->127.0.0.1:6379: i/o timeout
-{"@timestamp":"2026-06-26T00:00:35.813+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(12003.5ms)","duration":"12003.5ms","level":"slow","span":"aaac067343d0d23c","trace":"f9182e9205d733f25bdfba67c24ce28a"}
-{"@timestamp":"2026-06-26T00:00:35.814+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"12003.5ms","level":"error","span":"aaac067343d0d23c","trace":"f9182e9205d733f25bdfba67c24ce28a"}
-{"@timestamp":"2026-06-26T00:00:41.851+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3025.1ms)","duration":"3025.1ms","level":"slow","span":"fd556025a73295b2","trace":"c36b47f8fa0715d9726cdecdb57c472c"}
-{"@timestamp":"2026-06-26T00:00:41.851+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3025.1ms","level":"error","span":"fd556025a73295b2","trace":"c36b47f8fa0715d9726cdecdb57c472c"}
-2026/06/26 00:00:45 job worker claim error: read tcp 127.0.0.1:63274->127.0.0.1:6379: i/o timeout
-{"@timestamp":"2026-06-26T00:00:47.879+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.9ms)","duration":"3021.9ms","level":"slow","span":"08a241c1d1de2580","trace":"2933abe562601d49c5778fec76de3aef"}
-{"@timestamp":"2026-06-26T00:00:47.879+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.9ms","level":"error","span":"08a241c1d1de2580","trace":"2933abe562601d49c5778fec76de3aef"}
-2026/06/26 00:00:49 job worker claim error: i/o timeout
-2026/06/26 00:00:53 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:00:53.910+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.6ms)","duration":"3024.6ms","level":"slow","span":"49e51e3b05b8c938","trace":"2a806208f774ba50a624274f050b13bc"}
-{"@timestamp":"2026-06-26T00:00:53.910+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.6ms","level":"error","span":"49e51e3b05b8c938","trace":"2a806208f774ba50a624274f050b13bc"}
-2026/06/26 00:00:57 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:00:59.931+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.4ms)","duration":"3013.4ms","level":"slow","span":"701ea188de4877d5","trace":"762e3fa3a00868fa15724b7c14b6cae9"}
-{"@timestamp":"2026-06-26T00:00:59.931+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.4ms","level":"error","span":"701ea188de4877d5","trace":"762e3fa3a00868fa15724b7c14b6cae9"}
-2026/06/26 00:01:01 job worker claim error: i/o timeout
-2026/06/26 00:16:28 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:16:28.161+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(925224.9ms)","duration":"925224.9ms","level":"slow","span":"fcf5530048609174","trace":"75242eb90a37f5aabd0d42a4741ce5ca"}
-{"@timestamp":"2026-06-26T00:16:28.162+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"925224.9ms","level":"error","span":"fcf5530048609174","trace":"75242eb90a37f5aabd0d42a4741ce5ca"}
-{"@timestamp":"2026-06-26T00:16:28.618+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.8Mi, TotalAlloc=28.3Mi, Sys=19.6Mi, NumGC=23","level":"stat"}
-{"@timestamp":"2026-06-26T00:16:28.633+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 9, pass: 9, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T00:16:31.846+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 110777.1ms, med: 3024.9ms, 90th: 925224.8ms, 99th: 925224.8ms, 99.9th: 925224.8ms","level":"stat"}
-2026/06/26 00:16:32 job scheduler tick error: i/o timeout
-2026/06/26 00:16:32 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:16:34.786+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.3ms)","duration":"3022.3ms","level":"slow","span":"aba585ad5848f8b6","trace":"d71aef62958e7f5e7078721cbf456dd3"}
-{"@timestamp":"2026-06-26T00:16:34.787+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.3ms","level":"error","span":"aba585ad5848f8b6","trace":"d71aef62958e7f5e7078721cbf456dd3"}
-2026/06/26 00:16:36 job worker claim error: i/o timeout
-2026/06/26 00:16:40 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:16:40.825+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3028.8ms)","duration":"3028.8ms","level":"slow","span":"e3cc815f90642546","trace":"98a80ee77a72019765648cd07c5df31f"}
-{"@timestamp":"2026-06-26T00:16:40.825+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3028.8ms","level":"error","span":"e3cc815f90642546","trace":"98a80ee77a72019765648cd07c5df31f"}
-2026/06/26 00:16:41 job reaper error: connection(127.0.0.1:27017[-6]) incomplete read of message header: read tcp 127.0.0.1:63288->127.0.0.1:27017: use of closed network connection
-2026/06/26 00:16:44 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:16:46.861+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3029.2ms)","duration":"3029.2ms","level":"slow","span":"97ef0e5f90bc905b","trace":"0cf70adf043fea7812601e36bf06ddf1"}
-{"@timestamp":"2026-06-26T00:16:46.861+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3029.2ms","level":"error","span":"97ef0e5f90bc905b","trace":"0cf70adf043fea7812601e36bf06ddf1"}
-2026/06/26 00:16:48 job worker claim error: i/o timeout
-2026/06/26 00:16:52 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:16:52.885+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.0ms)","duration":"3017.0ms","level":"slow","span":"0f3e486257713b99","trace":"45dd9bcac5a0622881df87335c5e6438"}
-{"@timestamp":"2026-06-26T00:16:52.885+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.0ms","level":"error","span":"0f3e486257713b99","trace":"45dd9bcac5a0622881df87335c5e6438"}
-2026/06/26 00:16:56 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:16:58.911+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.7ms)","duration":"3018.7ms","level":"slow","span":"d27ef3933f196943","trace":"71d3ee6f6b06af1060b247c196f6bed8"}
-{"@timestamp":"2026-06-26T00:16:58.911+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.7ms","level":"error","span":"d27ef3933f196943","trace":"71d3ee6f6b06af1060b247c196f6bed8"}
-2026/06/26 00:17:00 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:17:04.936+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.4ms)","duration":"3019.4ms","level":"slow","span":"1d69fd3610ae7a2f","trace":"39f8b6c482ef158a012cfe814f36e916"}
-{"@timestamp":"2026-06-26T00:17:04.937+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.4ms","level":"error","span":"1d69fd3610ae7a2f","trace":"39f8b6c482ef158a012cfe814f36e916"}
-2026/06/26 00:17:04 job worker claim error: i/o timeout
-2026/06/26 00:17:08 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:17:10.966+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.6ms)","duration":"3022.6ms","level":"slow","span":"53bf84db7a55bb3c","trace":"693296624af79d064d4fbb48fd9f438a"}
-{"@timestamp":"2026-06-26T00:17:10.966+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.6ms","level":"error","span":"53bf84db7a55bb3c","trace":"693296624af79d064d4fbb48fd9f438a"}
-2026/06/26 00:17:11 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-21]) incomplete read of message header: read tcp 127.0.0.1:49822->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-21]) incomplete read of message header: read tcp 127.0.0.1:49822->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 00:17:12 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:17:16.992+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.4ms)","duration":"3019.4ms","level":"slow","span":"85b6cddfcc5f4cec","trace":"ceac752b51da147b4f4ce8deb84a8efe"}
-{"@timestamp":"2026-06-26T00:17:16.992+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.4ms","level":"error","span":"85b6cddfcc5f4cec","trace":"ceac752b51da147b4f4ce8deb84a8efe"}
-2026/06/26 00:17:17 job worker claim error: i/o timeout
-2026/06/26 00:34:22 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:34:24.673+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.2ms)","duration":"3027.2ms","level":"slow","span":"0297a2da521c5450","trace":"6f609c994d3687ad3f184a4364e3a544"}
-{"@timestamp":"2026-06-26T00:34:24.673+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.2ms","level":"error","span":"0297a2da521c5450","trace":"6f609c994d3687ad3f184a4364e3a544"}
-2026/06/26 00:50:04 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:50:08.343+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.9ms)","duration":"3027.9ms","level":"slow","span":"b06745ec630a4ed6","trace":"861134996da931a2dd6cbcbe24b21158"}
-{"@timestamp":"2026-06-26T00:50:08.343+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.9ms","level":"error","span":"b06745ec630a4ed6","trace":"861134996da931a2dd6cbcbe24b21158"}
-2026/06/26 00:50:08 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:50:08.521+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.7Mi, TotalAlloc=30.6Mi, Sys=19.6Mi, NumGC=24","level":"stat"}
-{"@timestamp":"2026-06-26T00:50:08.536+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T00:50:11.151+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3023.1ms, med: 3022.4ms, 90th: 3029.1ms, 99th: 3029.1ms, 99.9th: 3029.1ms","level":"stat"}
-2026/06/26 00:50:11 job scheduler tick error: i/o timeout
-2026/06/26 00:50:12 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:50:14.361+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.4ms)","duration":"3012.4ms","level":"slow","span":"8146f995031c4ec8","trace":"5ac85ed5dd76f3c4186451c893057c74"}
-{"@timestamp":"2026-06-26T00:50:14.361+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.4ms","level":"error","span":"8146f995031c4ec8","trace":"5ac85ed5dd76f3c4186451c893057c74"}
-2026/06/26 00:50:16 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:50:20.392+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.6ms)","duration":"3022.6ms","level":"slow","span":"957f85e8ea10547a","trace":"5321761c8cbd017d85742d833fd0c7bd"}
-{"@timestamp":"2026-06-26T00:50:20.392+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.6ms","level":"error","span":"957f85e8ea10547a","trace":"5321761c8cbd017d85742d833fd0c7bd"}
-2026/06/26 00:50:20 job worker claim error: i/o timeout
-2026/06/26 00:50:20 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-27]) incomplete read of message header: read tcp 127.0.0.1:49881->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-27]) incomplete read of message header: read tcp 127.0.0.1:49881->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 00:50:24 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:50:26.417+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.9ms)","duration":"3018.9ms","level":"slow","span":"308a0591b20fa253","trace":"cff835ffee04efae575e81db10c9b51b"}
-{"@timestamp":"2026-06-26T00:50:26.417+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.9ms","level":"error","span":"308a0591b20fa253","trace":"cff835ffee04efae575e81db10c9b51b"}
-2026/06/26 00:50:35 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:50:37.451+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.2ms)","duration":"3021.2ms","level":"slow","span":"9ee1f6b0a633f6ba","trace":"0f3cb381e9f2268c45a0c4be7fc05a5d"}
-{"@timestamp":"2026-06-26T00:50:37.452+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.2ms","level":"error","span":"9ee1f6b0a633f6ba","trace":"0f3cb381e9f2268c45a0c4be7fc05a5d"}
-2026/06/26 00:50:40 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:50:43.485+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3029.0ms)","duration":"3029.0ms","level":"slow","span":"2981095dadae3d8c","trace":"88eab3c55a53304ee00b89174c53fa47"}
-{"@timestamp":"2026-06-26T00:50:43.485+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3029.0ms","level":"error","span":"2981095dadae3d8c","trace":"88eab3c55a53304ee00b89174c53fa47"}
-2026/06/26 00:50:44 job worker claim error: i/o timeout
-2026/06/26 00:50:48 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:50:49.509+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.0ms)","duration":"3017.0ms","level":"slow","span":"8ded3b36d3984fca","trace":"aba4f629314605850f820cfc0b2a58d9"}
-{"@timestamp":"2026-06-26T00:50:49.509+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.0ms","level":"error","span":"8ded3b36d3984fca","trace":"aba4f629314605850f820cfc0b2a58d9"}
-2026/06/26 00:50:52 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:50:55.532+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.3ms)","duration":"3016.3ms","level":"slow","span":"504863caca22a5cc","trace":"88b3d9a2dfb508268583e59b5e3af911"}
-{"@timestamp":"2026-06-26T00:50:55.532+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.3ms","level":"error","span":"504863caca22a5cc","trace":"88b3d9a2dfb508268583e59b5e3af911"}
-2026/06/26 00:50:56 job worker claim error: i/o timeout
-2026/06/26 00:50:58 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-33]) incomplete read of message header: read tcp 127.0.0.1:49965->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-33]) incomplete read of message header: read tcp 127.0.0.1:49965->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 00:51:00 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:51:01.567+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3029.2ms)","duration":"3029.2ms","level":"slow","span":"b019cbca4e7af5bc","trace":"b17cd747a520725c9af937d018523430"}
-{"@timestamp":"2026-06-26T00:51:01.567+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3029.2ms","level":"error","span":"b019cbca4e7af5bc","trace":"b17cd747a520725c9af937d018523430"}
-2026/06/26 00:51:04 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:51:07.593+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.6ms)","duration":"3019.6ms","level":"slow","span":"85a4ad522795b184","trace":"ea0878e5735fa19266deed58f9273744"}
-{"@timestamp":"2026-06-26T00:51:07.593+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.6ms","level":"error","span":"85a4ad522795b184","trace":"ea0878e5735fa19266deed58f9273744"}
-2026/06/26 00:51:08 job worker claim error: i/o timeout
-2026/06/26 00:51:12 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:51:13.616+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.9ms)","duration":"3016.9ms","level":"slow","span":"0156823d21c1e98f","trace":"03c73fcbad3388eaacf4fa423db4fdb3"}
-{"@timestamp":"2026-06-26T00:51:13.616+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.9ms","level":"error","span":"0156823d21c1e98f","trace":"03c73fcbad3388eaacf4fa423db4fdb3"}
-{"@timestamp":"2026-06-26T00:51:16.051+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=5.4Mi, TotalAlloc=32.4Mi, Sys=19.6Mi, NumGC=24","level":"stat"}
-{"@timestamp":"2026-06-26T00:51:16.069+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-2026/06/26 00:51:16 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T00:51:18.682+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3020.1ms, med: 3019.3ms, 90th: 3029.1ms, 99th: 3029.1ms, 99.9th: 3029.1ms","level":"stat"}
-2026/06/26 00:51:19 job scheduler tick error: i/o timeout
-{"@timestamp":"2026-06-26T00:51:19.632+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3009.9ms)","duration":"3009.9ms","level":"slow","span":"e49367bece3beff6","trace":"f6dc6e41c6243f0b7fe7f57e6ae05c64"}
-{"@timestamp":"2026-06-26T00:51:19.632+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3009.9ms","level":"error","span":"e49367bece3beff6","trace":"f6dc6e41c6243f0b7fe7f57e6ae05c64"}
-2026/06/26 00:51:20 job worker claim error: i/o timeout
-2026/06/26 01:08:08 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:08:10.384+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.5ms)","duration":"3016.5ms","level":"slow","span":"032d47955fe1dc64","trace":"6a127de95d99870e84352cfe1562cef7"}
-{"@timestamp":"2026-06-26T01:08:10.384+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.5ms","level":"error","span":"032d47955fe1dc64","trace":"6a127de95d99870e84352cfe1562cef7"}
-2026/06/26 01:08:17 job worker claim error: i/o timeout
-2026/06/26 01:08:17 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-39]) incomplete read of message header: read tcp 127.0.0.1:50002->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-39]) incomplete read of message header: read tcp 127.0.0.1:50002->127.0.0.1:27017: i/o timeout }, ] }
-{"@timestamp":"2026-06-26T01:08:20.629+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.2ms)","duration":"3022.2ms","level":"slow","span":"a653d986b5858eb4","trace":"a8d9cd47e55ab59cebe94f71a6ab0c46"}
-{"@timestamp":"2026-06-26T01:08:20.629+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.2ms","level":"error","span":"a653d986b5858eb4","trace":"a8d9cd47e55ab59cebe94f71a6ab0c46"}
-2026/06/26 01:08:21 job worker claim error: i/o timeout
-2026/06/26 01:08:25 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:08:26.652+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.6ms)","duration":"3018.6ms","level":"slow","span":"808529a0130ca481","trace":"61bb26b0adbeaa07278b9c8c01c8aedc"}
-{"@timestamp":"2026-06-26T01:08:26.652+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.6ms","level":"error","span":"808529a0130ca481","trace":"61bb26b0adbeaa07278b9c8c01c8aedc"}
-2026/06/26 01:08:29 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:08:32.674+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.9ms)","duration":"3016.9ms","level":"slow","span":"8ec163e197d1a456","trace":"b451a5a4af23a593aec07f051d5da692"}
-{"@timestamp":"2026-06-26T01:08:32.674+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.9ms","level":"error","span":"8ec163e197d1a456","trace":"b451a5a4af23a593aec07f051d5da692"}
-2026/06/26 01:08:33 job worker claim error: i/o timeout
-2026/06/26 01:08:37 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:08:38.700+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.7ms)","duration":"3021.7ms","level":"slow","span":"2ea2a36aee9bafce","trace":"684ce346cca9479adfdad70e292d5c0a"}
-{"@timestamp":"2026-06-26T01:08:38.700+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.7ms","level":"error","span":"2ea2a36aee9bafce","trace":"684ce346cca9479adfdad70e292d5c0a"}
-2026/06/26 01:08:41 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:08:44.728+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.0ms)","duration":"3022.0ms","level":"slow","span":"9c69c93857fd5fc7","trace":"281b65bfeb1b06df0c9d2bb6113447a4"}
-{"@timestamp":"2026-06-26T01:08:44.728+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.0ms","level":"error","span":"9c69c93857fd5fc7","trace":"281b65bfeb1b06df0c9d2bb6113447a4"}
-2026/06/26 01:08:45 job worker claim error: i/o timeout
-2026/06/26 01:08:47 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-45]) incomplete read of message header: read tcp 127.0.0.1:50090->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-45]) incomplete read of message header: read tcp 127.0.0.1:50090->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 01:08:49 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:08:50.752+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.5ms)","duration":"3017.5ms","level":"slow","span":"910e7c32fd790525","trace":"f8bd566cabebd564bb7de14d615705da"}
-{"@timestamp":"2026-06-26T01:08:50.752+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.5ms","level":"error","span":"910e7c32fd790525","trace":"f8bd566cabebd564bb7de14d615705da"}
-2026/06/26 01:08:53 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:08:56.774+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.1ms)","duration":"3015.1ms","level":"slow","span":"28034a2d92f9fd11","trace":"d52693aa3aae634b2977015f3a69e3c8"}
-{"@timestamp":"2026-06-26T01:08:56.774+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.1ms","level":"error","span":"28034a2d92f9fd11","trace":"d52693aa3aae634b2977015f3a69e3c8"}
-2026/06/26 01:08:57 job worker claim error: i/o timeout
-2026/06/26 01:09:01 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:09:02.807+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.0ms)","duration":"3027.0ms","level":"slow","span":"9fbe459c9a563e3d","trace":"ff923b8d4fc74ca23fa20d6b4a566f76"}
-{"@timestamp":"2026-06-26T01:09:02.807+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.0ms","level":"error","span":"9fbe459c9a563e3d","trace":"ff923b8d4fc74ca23fa20d6b4a566f76"}
-{"@timestamp":"2026-06-26T01:25:41.903+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.8Mi, TotalAlloc=34.5Mi, Sys=19.6Mi, NumGC=25","level":"stat"}
-{"@timestamp":"2026-06-26T01:25:41.920+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-2026/06/26 01:25:42 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:25:44.534+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3018.6ms, med: 3018.5ms, 90th: 3026.9ms, 99th: 3026.9ms, 99.9th: 3026.9ms","level":"stat"}
-2026/06/26 01:25:44 job scheduler tick error: i/o timeout
-{"@timestamp":"2026-06-26T01:25:45.743+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.0ms)","duration":"3026.0ms","level":"slow","span":"3f4c25b233f22c7c","trace":"e857f9147d2f901ad3ebb05e50496edf"}
-{"@timestamp":"2026-06-26T01:25:45.743+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.0ms","level":"error","span":"3f4c25b233f22c7c","trace":"e857f9147d2f901ad3ebb05e50496edf"}
-2026/06/26 01:25:46 job worker claim error: i/o timeout
-2026/06/26 01:25:50 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:25:51.776+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.4ms)","duration":"3027.4ms","level":"slow","span":"bb4d7207197c0e28","trace":"c9180a19857d978c89473b775af5eacf"}
-{"@timestamp":"2026-06-26T01:25:51.776+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.4ms","level":"error","span":"bb4d7207197c0e28","trace":"c9180a19857d978c89473b775af5eacf"}
-2026/06/26 01:25:54 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-51]) incomplete read of message header: read tcp 127.0.0.1:50144->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-51]) incomplete read of message header: read tcp 127.0.0.1:50144->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 01:25:54 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:25:57.803+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.2ms)","duration":"3020.2ms","level":"slow","span":"c249df6358abbbca","trace":"95e66983a574b7db08b603c2caeef70f"}
-{"@timestamp":"2026-06-26T01:25:57.803+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.2ms","level":"error","span":"c249df6358abbbca","trace":"95e66983a574b7db08b603c2caeef70f"}
-2026/06/26 01:25:58 job worker claim error: i/o timeout
-2026/06/26 01:26:02 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:26:03.832+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.3ms)","duration":"3024.3ms","level":"slow","span":"14fe7214037a1163","trace":"461cced2c5282bf4040fc32b4263bc32"}
-{"@timestamp":"2026-06-26T01:26:03.832+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.3ms","level":"error","span":"14fe7214037a1163","trace":"461cced2c5282bf4040fc32b4263bc32"}
-2026/06/26 01:26:06 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:26:09.866+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3028.6ms)","duration":"3028.6ms","level":"slow","span":"0713e45c42738146","trace":"c411150ac4516b45614e386c5c181332"}
-{"@timestamp":"2026-06-26T01:26:09.866+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3028.6ms","level":"error","span":"0713e45c42738146","trace":"c411150ac4516b45614e386c5c181332"}
-2026/06/26 01:26:10 job worker claim error: i/o timeout
-2026/06/26 01:26:14 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:26:15.889+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.6ms)","duration":"3016.6ms","level":"slow","span":"7c6e5fa94c3c1a85","trace":"059d6e5c8eafa94454064e8d7da41e5e"}
-{"@timestamp":"2026-06-26T01:26:15.889+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.6ms","level":"error","span":"7c6e5fa94c3c1a85","trace":"059d6e5c8eafa94454064e8d7da41e5e"}
-2026/06/26 01:26:18 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:26:21.912+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.9ms)","duration":"3018.9ms","level":"slow","span":"a7905c161094774c","trace":"dc72ff56d341f7d723edc279d69d4c13"}
-{"@timestamp":"2026-06-26T01:26:21.912+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.9ms","level":"error","span":"a7905c161094774c","trace":"dc72ff56d341f7d723edc279d69d4c13"}
-2026/06/26 01:26:22 job worker claim error: i/o timeout
-2026/06/26 01:26:24 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-57]) incomplete read of message header: read tcp 127.0.0.1:50224->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-57]) incomplete read of message header: read tcp 127.0.0.1:50224->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 01:26:26 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:42:26.574+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(961654.8ms)","duration":"961654.8ms","level":"slow","span":"4ccd8cb4862f9273","trace":"b85955a4498055a05b51afd8aa3e48f9"}
-{"@timestamp":"2026-06-26T01:42:26.574+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"961654.8ms","level":"error","span":"4ccd8cb4862f9273","trace":"b85955a4498055a05b51afd8aa3e48f9"}
-2026/06/26 01:42:29 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:50:14.241+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(464665.5ms)","duration":"464665.5ms","level":"slow","span":"326da01d4f93ee92","trace":"ddf312e331e2d768ca5a416c76e85e82"}
-{"@timestamp":"2026-06-26T01:50:14.241+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"464665.5ms","level":"error","span":"326da01d4f93ee92","trace":"ddf312e331e2d768ca5a416c76e85e82"}
-2026/06/26 01:50:14 job worker claim error: i/o timeout
-2026/06/26 01:50:18 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:50:20.259+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.2ms)","duration":"3017.2ms","level":"slow","span":"5c63de540b027d45","trace":"8efa3dc22cf7c65eaa521a48a15677e4"}
-{"@timestamp":"2026-06-26T01:50:20.259+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.2ms","level":"error","span":"5c63de540b027d45","trace":"8efa3dc22cf7c65eaa521a48a15677e4"}
-{"@timestamp":"2026-06-26T01:50:22.117+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.3Mi, TotalAlloc=36.8Mi, Sys=19.6Mi, NumGC=26","level":"stat"}
-{"@timestamp":"2026-06-26T01:50:22.133+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-2026/06/26 01:50:22 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:50:24.747+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 145049.8ms, med: 3025.9ms, 90th: 961654.6ms, 99th: 961654.6ms, 99.9th: 961654.6ms","level":"stat"}
-2026/06/26 01:50:25 job scheduler tick error: i/o timeout
-{"@timestamp":"2026-06-26T01:50:26.277+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.5ms)","duration":"3015.5ms","level":"slow","span":"25e349a556307dfa","trace":"c61884edf77f2e5cd65f439ccf6e4dfa"}
-{"@timestamp":"2026-06-26T01:50:26.277+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.5ms","level":"error","span":"25e349a556307dfa","trace":"c61884edf77f2e5cd65f439ccf6e4dfa"}
-2026/06/26 01:50:26 job worker claim error: i/o timeout
-2026/06/26 01:50:30 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:50:32.299+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.3ms)","duration":"3017.3ms","level":"slow","span":"cc2b6e8815d77b73","trace":"a01c46c1885aebe3e97f0e149e52a96f"}
-{"@timestamp":"2026-06-26T01:50:32.299+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.3ms","level":"error","span":"cc2b6e8815d77b73","trace":"a01c46c1885aebe3e97f0e149e52a96f"}
-2026/06/26 01:50:34 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-63]) incomplete read of message header: read tcp 127.0.0.1:50312->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-63]) incomplete read of message header: read tcp 127.0.0.1:50312->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 01:50:35 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:50:38.324+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.1ms)","duration":"3020.1ms","level":"slow","span":"f9283706ba20bd97","trace":"a4a8cb5ef179856fde5d4f5e0a78f8f0"}
-{"@timestamp":"2026-06-26T01:50:38.324+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.1ms","level":"error","span":"f9283706ba20bd97","trace":"a4a8cb5ef179856fde5d4f5e0a78f8f0"}
-2026/06/26 01:50:39 job worker claim error: i/o timeout
-2026/06/26 01:50:43 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:50:44.357+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.1ms)","duration":"3027.1ms","level":"slow","span":"56a85365c08addc7","trace":"6fed5c39154ff045e314d35546d50d66"}
-{"@timestamp":"2026-06-26T01:50:44.357+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.1ms","level":"error","span":"56a85365c08addc7","trace":"6fed5c39154ff045e314d35546d50d66"}
-2026/06/26 01:50:47 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:50:50.389+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3025.5ms)","duration":"3025.5ms","level":"slow","span":"74009f81b623698f","trace":"bdcb213731376cf3bb13a20ed07fea4e"}
-{"@timestamp":"2026-06-26T01:50:50.389+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3025.5ms","level":"error","span":"74009f81b623698f","trace":"bdcb213731376cf3bb13a20ed07fea4e"}
-2026/06/26 01:50:51 job worker claim error: i/o timeout
-2026/06/26 01:50:55 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T01:50:56.416+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.0ms)","duration":"3023.0ms","level":"slow","span":"2bab0ddc3790604e","trace":"c47ae7f58371f33b32cc295e0dbca1db"}
-{"@timestamp":"2026-06-26T01:50:56.417+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.0ms","level":"error","span":"2bab0ddc3790604e","trace":"c47ae7f58371f33b32cc295e0dbca1db"}
-2026/06/26 01:50:59 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:02:48.978+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(709556.5ms)","duration":"709556.5ms","level":"slow","span":"730c62962b6a7f6e","trace":"55299e367c3c6bd2c5429778bc812757"}
-{"@timestamp":"2026-06-26T02:02:48.978+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"709556.5ms","level":"error","span":"730c62962b6a7f6e","trace":"55299e367c3c6bd2c5429778bc812757"}
-2026/06/26 02:02:49 job worker claim error: i/o timeout
-2026/06/26 02:02:50 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-69]) incomplete read of message header: read tcp 127.0.0.1:50348->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-69]) incomplete read of message header: read tcp 127.0.0.1:50348->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 02:02:53 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:02:54.991+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3011.3ms)","duration":"3011.3ms","level":"slow","span":"b405645a8ed7047a","trace":"f094b7e594f150725bbb3823bde9edfd"}
-{"@timestamp":"2026-06-26T02:02:54.992+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3011.3ms","level":"error","span":"b405645a8ed7047a","trace":"f094b7e594f150725bbb3823bde9edfd"}
-2026/06/26 02:02:57 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:03:01.019+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.3ms)","duration":"3023.3ms","level":"slow","span":"a60483c5c8991daa","trace":"2217caeaa8a4ed342783582f2a354f74"}
-{"@timestamp":"2026-06-26T02:03:01.019+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.3ms","level":"error","span":"a60483c5c8991daa","trace":"2217caeaa8a4ed342783582f2a354f74"}
-2026/06/26 02:03:01 job worker claim error: i/o timeout
-2026/06/26 02:03:05 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:03:07.049+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.2ms)","duration":"3023.2ms","level":"slow","span":"6abc2b12ada2a6ec","trace":"95b373922c191575df3752056c6603c8"}
-{"@timestamp":"2026-06-26T02:03:07.049+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.2ms","level":"error","span":"6abc2b12ada2a6ec","trace":"95b373922c191575df3752056c6603c8"}
-{"@timestamp":"2026-06-26T02:03:08.639+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.0Mi, TotalAlloc=39.0Mi, Sys=19.6Mi, NumGC=27","level":"stat"}
-{"@timestamp":"2026-06-26T02:03:08.657+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-2026/06/26 02:03:09 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:03:11.271+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 73674.1ms, med: 3023.1ms, 90th: 709556.4ms, 99th: 709556.4ms, 99.9th: 709556.4ms","level":"stat"}
-2026/06/26 02:03:11 job scheduler tick error: i/o timeout
-{"@timestamp":"2026-06-26T02:03:13.075+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.4ms)","duration":"3019.4ms","level":"slow","span":"a5703d014fd7e53b","trace":"cedc1a5b6842ed55fd757ee41ec7ea21"}
-{"@timestamp":"2026-06-26T02:03:13.076+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.4ms","level":"error","span":"a5703d014fd7e53b","trace":"cedc1a5b6842ed55fd757ee41ec7ea21"}
-2026/06/26 02:03:13 job worker claim error: i/o timeout
-2026/06/26 02:03:17 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:03:19.108+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.8ms)","duration":"3026.8ms","level":"slow","span":"393cf02974c2df5a","trace":"4693f7c23305f6595b6327c834f12ba7"}
-{"@timestamp":"2026-06-26T02:03:19.109+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.8ms","level":"error","span":"393cf02974c2df5a","trace":"4693f7c23305f6595b6327c834f12ba7"}
-2026/06/26 02:03:20 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-75]) incomplete read of message header: read tcp 127.0.0.1:50395->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-75]) incomplete read of message header: read tcp 127.0.0.1:50395->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 02:03:21 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:03:25.136+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.4ms)","duration":"3021.4ms","level":"slow","span":"842bfef61740588e","trace":"f8cccd7dcf427d2b120d9658db8e8562"}
-{"@timestamp":"2026-06-26T02:03:25.136+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.4ms","level":"error","span":"842bfef61740588e","trace":"f8cccd7dcf427d2b120d9658db8e8562"}
-2026/06/26 02:03:25 job worker claim error: i/o timeout
-2026/06/26 02:03:29 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:03:31.159+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.0ms)","duration":"3018.0ms","level":"slow","span":"a8386b16dc265660","trace":"35848dc98b94b1ed5c314267094711fa"}
-{"@timestamp":"2026-06-26T02:03:31.160+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.0ms","level":"error","span":"a8386b16dc265660","trace":"35848dc98b94b1ed5c314267094711fa"}
-2026/06/26 02:03:33 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:03:45.052+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(10886.4ms)","duration":"10886.4ms","level":"slow","span":"a1cc0335b1cd915c","trace":"71d55f15be54d964db010c9846dcd5c4"}
-{"@timestamp":"2026-06-26T02:03:45.052+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"10886.4ms","level":"error","span":"a1cc0335b1cd915c","trace":"71d55f15be54d964db010c9846dcd5c4"}
-2026/06/26 02:03:45 job worker claim error: i/o timeout
-2026/06/26 02:03:49 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:03:51.073+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.5ms)","duration":"3015.5ms","level":"slow","span":"a38ca2cdef450c72","trace":"9bdcf04b0374f2abaaf81fa4a3b6f607"}
-{"@timestamp":"2026-06-26T02:03:51.073+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.5ms","level":"error","span":"a38ca2cdef450c72","trace":"9bdcf04b0374f2abaaf81fa4a3b6f607"}
-2026/06/26 02:03:53 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:03:57.103+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.8ms)","duration":"3024.8ms","level":"slow","span":"07ec05a938bca568","trace":"231743f30dc2dd11ea1e3737a38cd1f3"}
-{"@timestamp":"2026-06-26T02:03:57.104+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.8ms","level":"error","span":"07ec05a938bca568","trace":"231743f30dc2dd11ea1e3737a38cd1f3"}
-2026/06/26 02:03:57 job worker claim error: i/o timeout
-2026/06/26 02:03:58 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-81]) incomplete read of message header: read tcp 127.0.0.1:50415->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-81]) incomplete read of message header: read tcp 127.0.0.1:50415->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 02:04:01 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:04:03.125+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.0ms)","duration":"3016.0ms","level":"slow","span":"682b7f7c50d09126","trace":"84d8c5fec06fe687307b82b2d79ed6b6"}
-{"@timestamp":"2026-06-26T02:04:03.125+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.0ms","level":"error","span":"682b7f7c50d09126","trace":"84d8c5fec06fe687307b82b2d79ed6b6"}
-2026/06/26 02:04:05 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:04:09.143+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.6ms)","duration":"3012.6ms","level":"slow","span":"e72d3d8798d43eb2","trace":"6ee43498bbd29daecc0f6e5380ffb5fc"}
-{"@timestamp":"2026-06-26T02:04:09.143+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.6ms","level":"error","span":"e72d3d8798d43eb2","trace":"6ee43498bbd29daecc0f6e5380ffb5fc"}
-2026/06/26 02:04:10 job worker claim error: i/o timeout
-2026/06/26 02:04:14 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:04:15.177+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3028.4ms)","duration":"3028.4ms","level":"slow","span":"2a54c720d6000387","trace":"db8718ce072178e6aa59adf4022e058b"}
-{"@timestamp":"2026-06-26T02:04:15.177+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3028.4ms","level":"error","span":"2a54c720d6000387","trace":"db8718ce072178e6aa59adf4022e058b"}
-{"@timestamp":"2026-06-26T02:04:16.507+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=5.8Mi, TotalAlloc=40.8Mi, Sys=19.6Mi, NumGC=27","level":"stat"}
-{"@timestamp":"2026-06-26T02:04:16.523+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-2026/06/26 02:04:18 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:04:19.136+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3806.8ms, med: 3021.3ms, 90th: 10886.2ms, 99th: 10886.2ms, 99.9th: 10886.2ms","level":"stat"}
-2026/06/26 02:20:42 job scheduler tick error: i/o timeout
-{"@timestamp":"2026-06-26T02:20:44.355+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(986172.9ms)","duration":"986172.9ms","level":"slow","span":"8a4e39a42bf85bd4","trace":"576eca181786a42d2e0491c6d1f2f344"}
-{"@timestamp":"2026-06-26T02:20:44.355+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"986172.9ms","level":"error","span":"8a4e39a42bf85bd4","trace":"576eca181786a42d2e0491c6d1f2f344"}
-2026/06/26 02:20:45 job worker claim error: i/o timeout
-2026/06/26 02:20:49 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:20:50.375+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3014.6ms)","duration":"3014.6ms","level":"slow","span":"1605bf446984b666","trace":"c29ec19e59b1a7f2943e897b255cf8ae"}
-{"@timestamp":"2026-06-26T02:20:50.376+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3014.6ms","level":"error","span":"1605bf446984b666","trace":"c29ec19e59b1a7f2943e897b255cf8ae"}
-2026/06/26 02:20:51 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-87]) incomplete read of message header: read tcp 127.0.0.1:50434->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-87]) incomplete read of message header: read tcp 127.0.0.1:50434->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 02:20:53 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:20:56.398+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.5ms)","duration":"3016.5ms","level":"slow","span":"38623bddb3e61fa9","trace":"3f91316598f179fcc27e3b4823725827"}
-{"@timestamp":"2026-06-26T02:20:56.398+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.5ms","level":"error","span":"38623bddb3e61fa9","trace":"3f91316598f179fcc27e3b4823725827"}
-2026/06/26 02:20:57 job worker claim error: i/o timeout
-2026/06/26 02:21:01 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:21:02.427+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.9ms)","duration":"3021.9ms","level":"slow","span":"f80b8b9190e0259a","trace":"e75104eb8678c32f8fc7a030fcb5ed72"}
-{"@timestamp":"2026-06-26T02:21:02.427+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.9ms","level":"error","span":"f80b8b9190e0259a","trace":"e75104eb8678c32f8fc7a030fcb5ed72"}
-2026/06/26 02:21:05 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:38:37.960+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1052527.2ms)","duration":"1052527.2ms","level":"slow","span":"62194537ad676d4a","trace":"455bf365beff4f24308e65228079e09c"}
-{"@timestamp":"2026-06-26T02:38:37.961+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1052527.2ms","level":"error","span":"62194537ad676d4a","trace":"455bf365beff4f24308e65228079e09c"}
-2026/06/26 02:38:38 job worker claim error: i/o timeout
-2026/06/26 02:47:55 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:47:56.796+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.8ms)","duration":"3019.8ms","level":"slow","span":"c7bc1e227be83514","trace":"4ff0b6806604525d4763fe4c427c8e3a"}
-{"@timestamp":"2026-06-26T02:47:56.796+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.8ms","level":"error","span":"c7bc1e227be83514","trace":"4ff0b6806604525d4763fe4c427c8e3a"}
-2026/06/26 02:47:59 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:48:02.827+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.5ms)","duration":"3026.5ms","level":"slow","span":"d1acd982cf559f15","trace":"363cf4335c76f2aa8af82701f7c0b3b2"}
-{"@timestamp":"2026-06-26T02:48:02.828+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.5ms","level":"error","span":"d1acd982cf559f15","trace":"363cf4335c76f2aa8af82701f7c0b3b2"}
-2026/06/26 02:48:03 job worker claim error: i/o timeout
-2026/06/26 02:48:04 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-93]) incomplete read of message header: read tcp 127.0.0.1:50478->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-93]) incomplete read of message header: read tcp 127.0.0.1:50478->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 02:48:07 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:48:08.857+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.7ms)","duration":"3024.7ms","level":"slow","span":"111cd25807d51fee","trace":"8b0203c714a697069ade124dd8334b79"}
-{"@timestamp":"2026-06-26T02:48:08.857+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.7ms","level":"error","span":"111cd25807d51fee","trace":"8b0203c714a697069ade124dd8334b79"}
-2026/06/26 02:48:11 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:48:14.886+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.7ms)","duration":"3023.7ms","level":"slow","span":"eb97c18845b5b304","trace":"cb72989b21be13137205201604f1c13c"}
-{"@timestamp":"2026-06-26T02:48:14.886+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.7ms","level":"error","span":"eb97c18845b5b304","trace":"cb72989b21be13137205201604f1c13c"}
-2026/06/26 02:48:15 job worker claim error: i/o timeout
-2026/06/26 02:48:19 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:48:20.916+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.9ms)","duration":"3023.9ms","level":"slow","span":"c612a9a00c0a959a","trace":"c47f7618c8eae9165e881b85e0622a8d"}
-{"@timestamp":"2026-06-26T02:48:20.916+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.9ms","level":"error","span":"c612a9a00c0a959a","trace":"c47f7618c8eae9165e881b85e0622a8d"}
-{"@timestamp":"2026-06-26T02:48:21.968+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=5.0Mi, TotalAlloc=42.9Mi, Sys=19.6Mi, NumGC=28","level":"stat"}
-{"@timestamp":"2026-06-26T02:48:21.984+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-2026/06/26 02:48:23 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:48:24.600+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 206287.0ms, med: 3023.7ms, 90th: 1052527.1ms, 99th: 1052527.1ms, 99.9th: 1052527.1ms","level":"stat"}
-2026/06/26 02:48:25 job scheduler tick error: i/o timeout
-{"@timestamp":"2026-06-26T02:48:26.933+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.3ms)","duration":"3012.3ms","level":"slow","span":"d321bc5279253f70","trace":"c1108848bef19f1e9f7684a111397d8d"}
-{"@timestamp":"2026-06-26T02:48:26.933+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.3ms","level":"error","span":"d321bc5279253f70","trace":"c1108848bef19f1e9f7684a111397d8d"}
-2026/06/26 02:48:27 job worker claim error: i/o timeout
-2026/06/26 02:48:31 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:48:32.954+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.2ms)","duration":"3015.2ms","level":"slow","span":"c600d4ac39f2e6b4","trace":"021e5db869ff04e78d41998d98acff89"}
-{"@timestamp":"2026-06-26T02:48:32.954+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.2ms","level":"error","span":"c600d4ac39f2e6b4","trace":"021e5db869ff04e78d41998d98acff89"}
-2026/06/26 02:48:34 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-99]) incomplete read of message header: read tcp 127.0.0.1:50531->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-99]) incomplete read of message header: read tcp 127.0.0.1:50531->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 02:48:35 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:48:38.979+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.0ms)","duration":"3018.0ms","level":"slow","span":"29fee7c70ea90a28","trace":"901b2a38be9ceb172ed8899856979bb0"}
-{"@timestamp":"2026-06-26T02:48:38.979+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.0ms","level":"error","span":"29fee7c70ea90a28","trace":"901b2a38be9ceb172ed8899856979bb0"}
-2026/06/26 02:48:39 job worker claim error: i/o timeout
-2026/06/26 02:51:16 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:51:17.300+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.8ms)","duration":"3019.8ms","level":"slow","span":"602bd90effcbbcde","trace":"50941a5e68209958ff239230637bf880"}
-{"@timestamp":"2026-06-26T02:51:17.300+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.8ms","level":"error","span":"602bd90effcbbcde","trace":"50941a5e68209958ff239230637bf880"}
-2026/06/26 02:51:20 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:51:23.334+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3029.2ms)","duration":"3029.2ms","level":"slow","span":"4da2ebc5c668ed39","trace":"21eb035c713a0acc79229e7a59a7ad64"}
-{"@timestamp":"2026-06-26T02:51:23.334+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3029.2ms","level":"error","span":"4da2ebc5c668ed39","trace":"21eb035c713a0acc79229e7a59a7ad64"}
-2026/06/26 02:51:24 job worker claim error: i/o timeout
-2026/06/26 02:51:28 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:51:29.361+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.1ms)","duration":"3020.1ms","level":"slow","span":"ac213a7705a6eae3","trace":"2ae12edfcab5ea8796fdf75fe76f522f"}
-{"@timestamp":"2026-06-26T02:51:29.361+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.1ms","level":"error","span":"ac213a7705a6eae3","trace":"2ae12edfcab5ea8796fdf75fe76f522f"}
-2026/06/26 02:51:32 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:51:35.391+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.0ms)","duration":"3026.0ms","level":"slow","span":"827ae27d2eb52fd4","trace":"9f14685e80c17ce309e74c8542f90c77"}
-{"@timestamp":"2026-06-26T02:51:35.391+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.0ms","level":"error","span":"827ae27d2eb52fd4","trace":"9f14685e80c17ce309e74c8542f90c77"}
-2026/06/26 02:51:36 job worker claim error: i/o timeout
-2026/06/26 02:51:36 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-105]) incomplete read of message header: read tcp 127.0.0.1:50557->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-105]) incomplete read of message header: read tcp 127.0.0.1:50557->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 02:51:40 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:51:41.420+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.9ms)","duration":"3024.9ms","level":"slow","span":"7050292decfc8da2","trace":"d09cba0b8735353f4991872ce0e0ed54"}
-{"@timestamp":"2026-06-26T02:51:41.420+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.9ms","level":"error","span":"7050292decfc8da2","trace":"d09cba0b8735353f4991872ce0e0ed54"}
-2026/06/26 02:51:44 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:51:47.444+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.0ms)","duration":"3019.0ms","level":"slow","span":"5d655c3395718a32","trace":"339ae1a1ae29e6ed89fee8e34b6c5fe8"}
-{"@timestamp":"2026-06-26T02:51:47.444+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.0ms","level":"error","span":"5d655c3395718a32","trace":"339ae1a1ae29e6ed89fee8e34b6c5fe8"}
-2026/06/26 02:51:48 job worker claim error: i/o timeout
-2026/06/26 02:51:52 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:51:53.466+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.8ms)","duration":"3018.8ms","level":"slow","span":"3a71ada98755517e","trace":"d81f60446e6fb8bc144fff0b6b480ba2"}
-{"@timestamp":"2026-06-26T02:51:53.467+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.8ms","level":"error","span":"3a71ada98755517e","trace":"d81f60446e6fb8bc144fff0b6b480ba2"}
-{"@timestamp":"2026-06-26T02:51:54.266+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.6Mi, TotalAlloc=45.2Mi, Sys=19.6Mi, NumGC=29","level":"stat"}
-{"@timestamp":"2026-06-26T02:51:54.283+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-2026/06/26 02:51:56 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T02:51:56.897+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3020.2ms, med: 3019.7ms, 90th: 3029.1ms, 99th: 3029.1ms, 99.9th: 3029.1ms","level":"stat"}
-2026/06/26 02:51:57 job scheduler tick error: i/o timeout
-{"@timestamp":"2026-06-26T02:51:59.492+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.5ms)","duration":"3019.5ms","level":"slow","span":"5e1ee7bff3450cfa","trace":"2b8129512dc1974b3bddf3425bd1175b"}
-{"@timestamp":"2026-06-26T02:51:59.492+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.5ms","level":"error","span":"5e1ee7bff3450cfa","trace":"2b8129512dc1974b3bddf3425bd1175b"}
-2026/06/26 02:52:00 job worker claim error: i/o timeout
-2026/06/26 03:04:32 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:04:33.204+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.5ms)","duration":"3018.5ms","level":"slow","span":"b58a16c7b68ef52e","trace":"9821b08120f00f08d15ff96f56dbf30e"}
-{"@timestamp":"2026-06-26T03:04:33.204+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.5ms","level":"error","span":"b58a16c7b68ef52e","trace":"9821b08120f00f08d15ff96f56dbf30e"}
-2026/06/26 03:04:34 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-111]) incomplete read of message header: read tcp 127.0.0.1:50580->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-111]) incomplete read of message header: read tcp 127.0.0.1:50580->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 03:04:36 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:04:39.237+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.3ms)","duration":"3023.3ms","level":"slow","span":"ab2305e68a82242a","trace":"8d939025d1dd02757a0c9ef965a8ffba"}
-{"@timestamp":"2026-06-26T03:04:39.237+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.3ms","level":"error","span":"ab2305e68a82242a","trace":"8d939025d1dd02757a0c9ef965a8ffba"}
-2026/06/26 03:04:40 job worker claim error: i/o timeout
-2026/06/26 03:04:44 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:04:45.262+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.9ms)","duration":"3015.9ms","level":"slow","span":"e0aab781fbbec74b","trace":"117136d6871a8c14126b1bd56956f337"}
-{"@timestamp":"2026-06-26T03:04:45.263+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.9ms","level":"error","span":"e0aab781fbbec74b","trace":"117136d6871a8c14126b1bd56956f337"}
-2026/06/26 03:04:48 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:04:51.298+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.9ms)","duration":"3026.9ms","level":"slow","span":"da1f46092f51f85d","trace":"c4fd0fce6e64ec2849774ba5128f8189"}
-{"@timestamp":"2026-06-26T03:04:51.298+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.9ms","level":"error","span":"da1f46092f51f85d","trace":"c4fd0fce6e64ec2849774ba5128f8189"}
-2026/06/26 03:04:52 job worker claim error: i/o timeout
-2026/06/26 03:04:56 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:04:57.325+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.5ms)","duration":"3019.5ms","level":"slow","span":"9b891ac2cea85b7c","trace":"f4092dbf67af229434043fc546bdbcb8"}
-{"@timestamp":"2026-06-26T03:04:57.325+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.5ms","level":"error","span":"9b891ac2cea85b7c","trace":"f4092dbf67af229434043fc546bdbcb8"}
-2026/06/26 03:05:00 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:05:03.356+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.6ms)","duration":"3024.6ms","level":"slow","span":"0f6ca6536cec8a33","trace":"328c4e04d20bcccc4e8d25c46eac01e7"}
-{"@timestamp":"2026-06-26T03:05:03.357+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.6ms","level":"error","span":"0f6ca6536cec8a33","trace":"328c4e04d20bcccc4e8d25c46eac01e7"}
-2026/06/26 03:05:04 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-117]) incomplete read of message header: read tcp 127.0.0.1:50623->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-117]) incomplete read of message header: read tcp 127.0.0.1:50623->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 03:05:04 job worker claim error: i/o timeout
-2026/06/26 03:05:08 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:05:09.384+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.6ms)","duration":"3020.6ms","level":"slow","span":"47f7a12d87442ad6","trace":"7630cc3dc4d01addd43a06d05d21a7c6"}
-{"@timestamp":"2026-06-26T03:05:09.384+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.6ms","level":"error","span":"47f7a12d87442ad6","trace":"7630cc3dc4d01addd43a06d05d21a7c6"}
-2026/06/26 03:05:12 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:05:15.407+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.6ms)","duration":"3016.6ms","level":"slow","span":"e19d13b530acd392","trace":"4484c721ed6f8f74e01aa1dbedabc53d"}
-{"@timestamp":"2026-06-26T03:05:15.407+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.6ms","level":"error","span":"e19d13b530acd392","trace":"4484c721ed6f8f74e01aa1dbedabc53d"}
-2026/06/26 03:21:55 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:21:57.665+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.3ms)","duration":"3023.3ms","level":"slow","span":"5c179caf48d17b65","trace":"97b2ede52a10ca3b03fdd18ec28baafc"}
-{"@timestamp":"2026-06-26T03:21:57.665+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.3ms","level":"error","span":"5c179caf48d17b65","trace":"97b2ede52a10ca3b03fdd18ec28baafc"}
-2026/06/26 03:21:59 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:38:30.400+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.4Mi, TotalAlloc=47.4Mi, Sys=19.6Mi, NumGC=30","level":"stat"}
-{"@timestamp":"2026-06-26T03:38:30.417+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 10, drop: 0","level":"stat"}
-2026/06/26 03:38:32 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:38:33.031+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3020.7ms, med: 3020.5ms, 90th: 3026.8ms, 99th: 3026.8ms, 99.9th: 3026.8ms","level":"stat"}
-{"@timestamp":"2026-06-26T03:38:33.349+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.4ms)","duration":"3015.4ms","level":"slow","span":"6df079a2d405503a","trace":"42415f188c2cad1a4a264079c8b5dca3"}
-{"@timestamp":"2026-06-26T03:38:33.349+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.4ms","level":"error","span":"6df079a2d405503a","trace":"42415f188c2cad1a4a264079c8b5dca3"}
-2026/06/26 03:38:33 job scheduler tick error: i/o timeout
-2026/06/26 03:52:15 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:52:18.057+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.8ms)","duration":"3020.8ms","level":"slow","span":"862f0878d0cd2ccf","trace":"89789dd8244f494e8b3da68850365d07"}
-{"@timestamp":"2026-06-26T03:52:18.058+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.8ms","level":"error","span":"862f0878d0cd2ccf","trace":"89789dd8244f494e8b3da68850365d07"}
-2026/06/26 03:52:19 job worker claim error: i/o timeout
-2026/06/26 03:52:21 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-123]) incomplete read of message header: read tcp 127.0.0.1:50644->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-123]) incomplete read of message header: read tcp 127.0.0.1:50644->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 03:52:23 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:52:24.087+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.9ms)","duration":"3023.9ms","level":"slow","span":"0caac9290c7a8aaa","trace":"d1e2a8f0f0839e68237a98a37bb615a8"}
-{"@timestamp":"2026-06-26T03:52:24.087+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.9ms","level":"error","span":"0caac9290c7a8aaa","trace":"d1e2a8f0f0839e68237a98a37bb615a8"}
-2026/06/26 03:52:27 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:52:30.119+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.7ms)","duration":"3026.7ms","level":"slow","span":"b5ade64029c2f718","trace":"ef8ca68d27b3b05644406da7f8b0d14a"}
-{"@timestamp":"2026-06-26T03:52:30.119+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.7ms","level":"error","span":"b5ade64029c2f718","trace":"ef8ca68d27b3b05644406da7f8b0d14a"}
-2026/06/26 03:52:31 job worker claim error: i/o timeout
-2026/06/26 03:52:35 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:52:36.146+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.8ms)","duration":"3021.8ms","level":"slow","span":"3505e73a6cda4810","trace":"0a616653cedf5a80c4642038dbb7386d"}
-{"@timestamp":"2026-06-26T03:52:36.146+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.8ms","level":"error","span":"3505e73a6cda4810","trace":"0a616653cedf5a80c4642038dbb7386d"}
-2026/06/26 03:52:39 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:52:42.172+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.6ms)","duration":"3019.6ms","level":"slow","span":"de3fa9840dcd9d63","trace":"c68bd077925845bbdfc6c1f031512257"}
-{"@timestamp":"2026-06-26T03:52:42.172+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.6ms","level":"error","span":"de3fa9840dcd9d63","trace":"c68bd077925845bbdfc6c1f031512257"}
-2026/06/26 03:52:43 job worker claim error: i/o timeout
-2026/06/26 03:52:47 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:52:48.195+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.6ms)","duration":"3018.6ms","level":"slow","span":"db320c05d1144d96","trace":"b8d5f5e944aab39369f1b09e1b884e14"}
-{"@timestamp":"2026-06-26T03:52:48.195+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.6ms","level":"error","span":"db320c05d1144d96","trace":"b8d5f5e944aab39369f1b09e1b884e14"}
-2026/06/26 03:52:51 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-129]) incomplete read of message header: read tcp 127.0.0.1:50733->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-129]) incomplete read of message header: read tcp 127.0.0.1:50733->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 03:52:51 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:52:54.216+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.5ms)","duration":"3015.5ms","level":"slow","span":"de3150f871399f2b","trace":"1457b894ccd0304871cbc15a57d1223d"}
-{"@timestamp":"2026-06-26T03:52:54.216+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.5ms","level":"error","span":"de3150f871399f2b","trace":"1457b894ccd0304871cbc15a57d1223d"}
-2026/06/26 03:52:55 job worker claim error: i/o timeout
-2026/06/26 03:52:59 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T03:53:00.249+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.0ms)","duration":"3026.0ms","level":"slow","span":"c366a90772617cf9","trace":"5fcf6e2f7a40b612bc42b96bcbacedc8"}
-{"@timestamp":"2026-06-26T03:53:00.249+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.0ms","level":"error","span":"c366a90772617cf9","trace":"5fcf6e2f7a40b612bc42b96bcbacedc8"}
-2026/06/26 04:10:04 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:10:11.023+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(7255.2ms)","duration":"7255.2ms","level":"slow","span":"7455ee3e3c3b137e","trace":"6587d9fb744494d15d7296c58077f559"}
-{"@timestamp":"2026-06-26T04:10:11.023+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"7255.2ms","level":"error","span":"7455ee3e3c3b137e","trace":"6587d9fb744494d15d7296c58077f559"}
-2026/06/26 04:10:12 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:10:13.780+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.7Mi, TotalAlloc=49.6Mi, Sys=19.6Mi, NumGC=31","level":"stat"}
-{"@timestamp":"2026-06-26T04:10:13.795+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 9, pass: 10, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T04:10:16.413+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3444.2ms, med: 3021.7ms, 90th: 7255.1ms, 99th: 7255.1ms, 99.9th: 7255.1ms","level":"stat"}
-2026/06/26 04:10:16 job worker claim error: i/o timeout
-2026/06/26 04:10:16 job scheduler tick error: i/o timeout
-{"@timestamp":"2026-06-26T04:10:17.052+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.6ms)","duration":"3021.6ms","level":"slow","span":"a9d89f4f8dbce0e6","trace":"bc6f92267115d87bcdf2137cd7ee78e4"}
-{"@timestamp":"2026-06-26T04:10:17.052+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.6ms","level":"error","span":"a9d89f4f8dbce0e6","trace":"bc6f92267115d87bcdf2137cd7ee78e4"}
-2026/06/26 04:10:20 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:10:23.077+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.6ms)","duration":"3018.6ms","level":"slow","span":"6e84e0e9a68369d8","trace":"2b0c86bc969843336c7e03bd58a72b92"}
-{"@timestamp":"2026-06-26T04:10:23.077+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.6ms","level":"error","span":"6e84e0e9a68369d8","trace":"2b0c86bc969843336c7e03bd58a72b92"}
-2026/06/26 04:10:24 job worker claim error: i/o timeout
-2026/06/26 04:10:26 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-135]) incomplete read of message header: read tcp 127.0.0.1:50756->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-135]) incomplete read of message header: read tcp 127.0.0.1:50756->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 04:10:28 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:10:29.099+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.1ms)","duration":"3015.1ms","level":"slow","span":"6d96764465912111","trace":"6feae2fc92c1a39a2c60a2c4c0b47b89"}
-{"@timestamp":"2026-06-26T04:10:29.099+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.1ms","level":"error","span":"6d96764465912111","trace":"6feae2fc92c1a39a2c60a2c4c0b47b89"}
-2026/06/26 04:10:32 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:10:35.124+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.2ms)","duration":"3018.2ms","level":"slow","span":"4c7decc8a7c5f0a9","trace":"56bd100a954f4881f9a8b9c3067a01ca"}
-{"@timestamp":"2026-06-26T04:10:35.125+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.2ms","level":"error","span":"4c7decc8a7c5f0a9","trace":"56bd100a954f4881f9a8b9c3067a01ca"}
-2026/06/26 04:10:36 job worker claim error: i/o timeout
-2026/06/26 04:10:40 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:10:41.148+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.1ms)","duration":"3019.1ms","level":"slow","span":"11d9e17a6096eab1","trace":"bb1b1f52c19f6437c564577f38fdd35f"}
-{"@timestamp":"2026-06-26T04:10:41.149+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.1ms","level":"error","span":"11d9e17a6096eab1","trace":"bb1b1f52c19f6437c564577f38fdd35f"}
-2026/06/26 04:10:44 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:10:47.176+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.9ms)","duration":"3020.9ms","level":"slow","span":"6da9553649230ec2","trace":"c5e5b24efca3cdffcf9081e3cefa23a1"}
-{"@timestamp":"2026-06-26T04:10:47.176+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.9ms","level":"error","span":"6da9553649230ec2","trace":"c5e5b24efca3cdffcf9081e3cefa23a1"}
-2026/06/26 04:10:48 job worker claim error: i/o timeout
-2026/06/26 04:10:52 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:10:53.201+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.5ms)","duration":"3021.5ms","level":"slow","span":"7fb85f8f2aaed5d3","trace":"a48af14bf85a6e8c070d48c0696533e8"}
-{"@timestamp":"2026-06-26T04:10:53.201+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.5ms","level":"error","span":"7fb85f8f2aaed5d3","trace":"a48af14bf85a6e8c070d48c0696533e8"}
-2026/06/26 04:10:56 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-141]) incomplete read of message header: read tcp 127.0.0.1:50798->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-141]) incomplete read of message header: read tcp 127.0.0.1:50798->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 04:10:56 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:25:59.986+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(903778.9ms)","duration":"903778.9ms","level":"slow","span":"c7cb250e147e7c77","trace":"1e4dd4ff0d15eb7ee3b0fb59a2f6409b"}
-{"@timestamp":"2026-06-26T04:25:59.986+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"903778.9ms","level":"error","span":"c7cb250e147e7c77","trace":"1e4dd4ff0d15eb7ee3b0fb59a2f6409b"}
-2026/06/26 04:26:01 job worker claim error: i/o timeout
-2026/06/26 04:26:05 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:26:06.017+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.1ms)","duration":"3026.1ms","level":"slow","span":"9887e8a1a334ef79","trace":"67d6fe5b3452c3b85dbdb63f2de9d5ec"}
-{"@timestamp":"2026-06-26T04:26:06.018+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.1ms","level":"error","span":"9887e8a1a334ef79","trace":"67d6fe5b3452c3b85dbdb63f2de9d5ec"}
-2026/06/26 04:26:09 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:26:12.042+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.4ms)","duration":"3018.4ms","level":"slow","span":"a2f8b957da0bc113","trace":"16d100a3ee6ac1a39274a5faa7a78543"}
-{"@timestamp":"2026-06-26T04:26:12.042+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.4ms","level":"error","span":"a2f8b957da0bc113","trace":"16d100a3ee6ac1a39274a5faa7a78543"}
-2026/06/26 04:26:13 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:26:14.545+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=5.4Mi, TotalAlloc=51.3Mi, Sys=19.6Mi, NumGC=31","level":"stat"}
-{"@timestamp":"2026-06-26T04:26:14.561+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T04:26:17.174+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 93095.7ms, med: 3020.8ms, 90th: 903778.8ms, 99th: 903778.8ms, 99.9th: 903778.8ms","level":"stat"}
-2026/06/26 04:26:17 job scheduler tick error: i/o timeout
-2026/06/26 04:26:17 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:26:18.067+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.2ms)","duration":"3020.2ms","level":"slow","span":"456148bebfbacd74","trace":"24a2a35cb247c4889871f67fbc00939e"}
-{"@timestamp":"2026-06-26T04:26:18.067+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.2ms","level":"error","span":"456148bebfbacd74","trace":"24a2a35cb247c4889871f67fbc00939e"}
-2026/06/26 04:26:21 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:26:24.086+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.1ms)","duration":"3013.1ms","level":"slow","span":"95736b5d71df84cc","trace":"745f5d44e6d30e28952542dfa776228a"}
-{"@timestamp":"2026-06-26T04:26:24.086+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.1ms","level":"error","span":"95736b5d71df84cc","trace":"745f5d44e6d30e28952542dfa776228a"}
-2026/06/26 04:26:25 job worker claim error: i/o timeout
-2026/06/26 04:26:26 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-147]) incomplete read of message header: read tcp 127.0.0.1:50839->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-147]) incomplete read of message header: read tcp 127.0.0.1:50839->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 04:26:29 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:26:30.106+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.7ms)","duration":"3015.7ms","level":"slow","span":"9037a91630b06b30","trace":"ad34304e9d0a270460016d2536ee55c7"}
-{"@timestamp":"2026-06-26T04:26:30.107+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.7ms","level":"error","span":"9037a91630b06b30","trace":"ad34304e9d0a270460016d2536ee55c7"}
-2026/06/26 04:26:33 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:26:36.132+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.4ms)","duration":"3019.4ms","level":"slow","span":"b47dac2c88cc5d52","trace":"5088634e5b2b40f0d7058b0c0727f59f"}
-{"@timestamp":"2026-06-26T04:26:36.132+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.4ms","level":"error","span":"b47dac2c88cc5d52","trace":"5088634e5b2b40f0d7058b0c0727f59f"}
-2026/06/26 04:26:37 job worker claim error: i/o timeout
-2026/06/26 04:26:41 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:26:42.151+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.3ms)","duration":"3013.3ms","level":"slow","span":"d7a1ca26cc1c5d86","trace":"9c383bc3ae7d4a8129dc68c7f1a5b3a7"}
-{"@timestamp":"2026-06-26T04:26:42.151+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.3ms","level":"error","span":"d7a1ca26cc1c5d86","trace":"9c383bc3ae7d4a8129dc68c7f1a5b3a7"}
-2026/06/26 04:26:45 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:43:18.147+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(992990.2ms)","duration":"992990.2ms","level":"slow","span":"29c9afbff492bd03","trace":"656319d43b2f25544bc8f8e4c15f6e5b"}
-{"@timestamp":"2026-06-26T04:43:18.147+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"992990.2ms","level":"error","span":"29c9afbff492bd03","trace":"656319d43b2f25544bc8f8e4c15f6e5b"}
-2026/06/26 04:43:19 job worker claim error: i/o timeout
-2026/06/26 04:49:54 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:49:55.148+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(393994.5ms)","duration":"393994.5ms","level":"slow","span":"2e11b2f5cd56495e","trace":"80a8afbe92ced63e1d0cfeddd6d3fa64"}
-{"@timestamp":"2026-06-26T04:49:55.148+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"393994.5ms","level":"error","span":"2e11b2f5cd56495e","trace":"80a8afbe92ced63e1d0cfeddd6d3fa64"}
-2026/06/26 04:49:57 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-153]) incomplete read of message header: read tcp 127.0.0.1:50864->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-153]) incomplete read of message header: read tcp 127.0.0.1:50864->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 04:49:58 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:50:01.171+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3014.6ms)","duration":"3014.6ms","level":"slow","span":"66a0dc481252928f","trace":"87a1f7eee70dc0f927c298b3492ca092"}
-{"@timestamp":"2026-06-26T04:50:01.171+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3014.6ms","level":"error","span":"66a0dc481252928f","trace":"87a1f7eee70dc0f927c298b3492ca092"}
-2026/06/26 04:50:02 job worker claim error: i/o timeout
-2026/06/26 04:50:06 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:50:07.196+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.0ms)","duration":"3021.0ms","level":"slow","span":"8976a6219542f1d5","trace":"35ced97aef881eb2bce72697ea0a7db1"}
-{"@timestamp":"2026-06-26T04:50:07.196+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.0ms","level":"error","span":"8976a6219542f1d5","trace":"35ced97aef881eb2bce72697ea0a7db1"}
-2026/06/26 04:50:11 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:50:13.216+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3014.2ms)","duration":"3014.2ms","level":"slow","span":"4daa97481cba48e1","trace":"ebf1fffefe0e3611a957a9b841af316c"}
-{"@timestamp":"2026-06-26T04:50:13.216+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3014.2ms","level":"error","span":"4daa97481cba48e1","trace":"ebf1fffefe0e3611a957a9b841af316c"}
-2026/06/26 04:50:15 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:50:15.491+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.6Mi, TotalAlloc=53.6Mi, Sys=19.6Mi, NumGC=32","level":"stat"}
-{"@timestamp":"2026-06-26T04:50:15.508+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T04:50:18.123+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 141111.4ms, med: 3019.3ms, 90th: 992990.1ms, 99th: 992990.1ms, 99.9th: 992990.1ms","level":"stat"}
-2026/06/26 04:50:18 job scheduler tick error: i/o timeout
-2026/06/26 04:50:19 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:50:19.245+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.9ms)","duration":"3021.9ms","level":"slow","span":"1ea85267e12cf0b5","trace":"083f8881440e5ad6989f77fb435f2805"}
-{"@timestamp":"2026-06-26T04:50:19.246+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.9ms","level":"error","span":"1ea85267e12cf0b5","trace":"083f8881440e5ad6989f77fb435f2805"}
-2026/06/26 04:50:23 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:50:25.271+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.4ms)","duration":"3018.4ms","level":"slow","span":"78340b46d4f2bb32","trace":"9ec77aff5b8ba79249fcabfe26b8dbb0"}
-{"@timestamp":"2026-06-26T04:50:25.271+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.4ms","level":"error","span":"78340b46d4f2bb32","trace":"9ec77aff5b8ba79249fcabfe26b8dbb0"}
-2026/06/26 04:53:14 job worker claim error: i/o timeout
-2026/06/26 04:53:15 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-159]) incomplete read of message header: read tcp 127.0.0.1:50922->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-159]) incomplete read of message header: read tcp 127.0.0.1:50922->127.0.0.1:27017: i/o timeout }, ] }
-{"@timestamp":"2026-06-26T04:53:16.549+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.2ms)","duration":"3020.2ms","level":"slow","span":"380f9204bc7a796c","trace":"337c257a443d9340348a64cece0dfab4"}
-{"@timestamp":"2026-06-26T04:53:16.549+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.2ms","level":"error","span":"380f9204bc7a796c","trace":"337c257a443d9340348a64cece0dfab4"}
-2026/06/26 04:53:18 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:53:22.574+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.3ms)","duration":"3019.3ms","level":"slow","span":"842e2bd38ae6004a","trace":"e58d2aea8cd2ee147546f660d01c610e"}
-{"@timestamp":"2026-06-26T04:53:22.574+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.3ms","level":"error","span":"842e2bd38ae6004a","trace":"e58d2aea8cd2ee147546f660d01c610e"}
-2026/06/26 04:53:22 job worker claim error: i/o timeout
-2026/06/26 04:53:26 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:53:28.595+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3014.8ms)","duration":"3014.8ms","level":"slow","span":"cb26294f5368e6ea","trace":"9fbf6d0fbeb70c9bf6881eb72b22203c"}
-{"@timestamp":"2026-06-26T04:53:28.595+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3014.8ms","level":"error","span":"cb26294f5368e6ea","trace":"9fbf6d0fbeb70c9bf6881eb72b22203c"}
-2026/06/26 04:53:30 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:53:34.623+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.3ms)","duration":"3021.3ms","level":"slow","span":"5edaa95cbc5c1917","trace":"6a4ba71af1094a029ca1feceac3e1c21"}
-{"@timestamp":"2026-06-26T04:53:34.623+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.3ms","level":"error","span":"5edaa95cbc5c1917","trace":"6a4ba71af1094a029ca1feceac3e1c21"}
-2026/06/26 04:53:34 job worker claim error: i/o timeout
-2026/06/26 04:53:39 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:53:40.653+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.8ms)","duration":"3022.8ms","level":"slow","span":"d7629d363ab41fb4","trace":"ef703e3c3de6a13877a29f9a6195a7e1"}
-{"@timestamp":"2026-06-26T04:53:40.653+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.8ms","level":"error","span":"d7629d363ab41fb4","trace":"ef703e3c3de6a13877a29f9a6195a7e1"}
-2026/06/26 04:53:43 job worker claim error: i/o timeout
-2026/06/26 04:53:45 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-165]) incomplete read of message header: read tcp 127.0.0.1:50947->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-165]) incomplete read of message header: read tcp 127.0.0.1:50947->127.0.0.1:27017: i/o timeout }, ] }
-{"@timestamp":"2026-06-26T04:53:46.679+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.5ms)","duration":"3020.5ms","level":"slow","span":"aa6760f943044257","trace":"57649a4e3dfcda337f01f29a9700a3dd"}
-{"@timestamp":"2026-06-26T04:53:46.679+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.5ms","level":"error","span":"aa6760f943044257","trace":"57649a4e3dfcda337f01f29a9700a3dd"}
-2026/06/26 04:53:47 job worker claim error: i/o timeout
-2026/06/26 04:53:51 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:53:52.707+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.1ms)","duration":"3022.1ms","level":"slow","span":"20033fe8f782af6d","trace":"3c80883032c8c0c9a5c4cbc0a1c6c5a4"}
-{"@timestamp":"2026-06-26T04:53:52.707+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.1ms","level":"error","span":"20033fe8f782af6d","trace":"3c80883032c8c0c9a5c4cbc0a1c6c5a4"}
-2026/06/26 04:53:55 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T04:53:58.740+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.6ms)","duration":"3026.6ms","level":"slow","span":"f17c4cd427f5fae2","trace":"4fa4856d5105cf9eb1e09e449f58854d"}
-{"@timestamp":"2026-06-26T04:53:58.740+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.6ms","level":"error","span":"f17c4cd427f5fae2","trace":"4fa4856d5105cf9eb1e09e449f58854d"}
-2026/06/26 04:53:59 job worker claim error: i/o timeout
-2026/06/26 05:09:09 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:09:09.122+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.1Mi, TotalAlloc=55.9Mi, Sys=19.6Mi, NumGC=33","level":"stat"}
-{"@timestamp":"2026-06-26T05:09:09.137+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 10, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T05:09:10.616+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.2ms)","duration":"3015.2ms","level":"slow","span":"71eb45a074e9dee5","trace":"062da34884313f8233870a0cbee144f1"}
-{"@timestamp":"2026-06-26T05:09:10.616+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.2ms","level":"error","span":"71eb45a074e9dee5","trace":"062da34884313f8233870a0cbee144f1"}
-{"@timestamp":"2026-06-26T05:09:17.097+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3020.1ms, med: 3021.2ms, 90th: 3026.5ms, 99th: 3026.5ms, 99.9th: 3026.5ms","level":"stat"}
-2026/06/26 05:09:17 job scheduler tick error: i/o timeout
-2026/06/26 05:09:18 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:09:21.927+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.4ms)","duration":"3024.4ms","level":"slow","span":"b440c8fa86cc07de","trace":"6e7813bac51497c3a9de4c66de13bf4a"}
-{"@timestamp":"2026-06-26T05:09:21.927+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.4ms","level":"error","span":"b440c8fa86cc07de","trace":"6e7813bac51497c3a9de4c66de13bf4a"}
-2026/06/26 05:09:22 job worker claim error: i/o timeout
-2026/06/26 05:09:26 job worker claim error: i/o timeout
-2026/06/26 05:09:26 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-171]) incomplete read of message header: read tcp 127.0.0.1:50967->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-171]) incomplete read of message header: read tcp 127.0.0.1:50967->127.0.0.1:27017: i/o timeout }, ] }
-{"@timestamp":"2026-06-26T05:09:27.955+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.5ms)","duration":"3021.5ms","level":"slow","span":"4fba5744ff8e8e8d","trace":"18f62c16c96f5f6bef562b0309c4453f"}
-{"@timestamp":"2026-06-26T05:09:27.955+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.5ms","level":"error","span":"4fba5744ff8e8e8d","trace":"18f62c16c96f5f6bef562b0309c4453f"}
-2026/06/26 05:09:30 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:09:33.979+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.6ms)","duration":"3018.6ms","level":"slow","span":"3ed6eb75f8a6c895","trace":"fb9c5d232c7af9524d892984e83c402f"}
-{"@timestamp":"2026-06-26T05:09:33.979+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.6ms","level":"error","span":"3ed6eb75f8a6c895","trace":"fb9c5d232c7af9524d892984e83c402f"}
-2026/06/26 05:09:34 job worker claim error: i/o timeout
-2026/06/26 05:09:38 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:09:40.004+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.5ms)","duration":"3020.5ms","level":"slow","span":"3c95da658fb47658","trace":"8bc8d287a2704f058652eb7525faa881"}
-{"@timestamp":"2026-06-26T05:09:40.004+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.5ms","level":"error","span":"3c95da658fb47658","trace":"8bc8d287a2704f058652eb7525faa881"}
-2026/06/26 05:09:42 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:09:46.025+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.8ms)","duration":"3013.8ms","level":"slow","span":"270af4d2a6656e25","trace":"036abe2ea985d266b93a4af69383db9a"}
-{"@timestamp":"2026-06-26T05:09:46.025+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.8ms","level":"error","span":"270af4d2a6656e25","trace":"036abe2ea985d266b93a4af69383db9a"}
-2026/06/26 05:09:46 job worker claim error: i/o timeout
-2026/06/26 05:09:50 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:09:52.056+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.9ms)","duration":"3023.9ms","level":"slow","span":"ad220e036e0679e7","trace":"8144f69c19e080e3297abd3032d37212"}
-{"@timestamp":"2026-06-26T05:09:52.056+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.9ms","level":"error","span":"ad220e036e0679e7","trace":"8144f69c19e080e3297abd3032d37212"}
-2026/06/26 05:09:54 job worker claim error: i/o timeout
-2026/06/26 05:09:56 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-177]) incomplete read of message header: read tcp 127.0.0.1:51014->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-177]) incomplete read of message header: read tcp 127.0.0.1:51014->127.0.0.1:27017: i/o timeout }, ] }
-{"@timestamp":"2026-06-26T05:09:58.078+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.4ms)","duration":"3017.4ms","level":"slow","span":"03bbde39680af84f","trace":"3496bcd7d145dfa543dc89b87ff4e48e"}
-{"@timestamp":"2026-06-26T05:09:58.078+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.4ms","level":"error","span":"03bbde39680af84f","trace":"3496bcd7d145dfa543dc89b87ff4e48e"}
-2026/06/26 05:09:58 job worker claim error: i/o timeout
-2026/06/26 05:10:02 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:10:04.108+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3025.8ms)","duration":"3025.8ms","level":"slow","span":"ed8f725f12a3aba4","trace":"ab1682755f9abdf90f064c51cd979d05"}
-{"@timestamp":"2026-06-26T05:10:04.108+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3025.8ms","level":"error","span":"ed8f725f12a3aba4","trace":"ab1682755f9abdf90f064c51cd979d05"}
-2026/06/26 05:27:02 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:27:03.518+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.3ms)","duration":"3015.3ms","level":"slow","span":"70d9ae8c928b9198","trace":"b704cf48cca3d2813a53ff15107f312d"}
-{"@timestamp":"2026-06-26T05:27:03.518+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.3ms","level":"error","span":"70d9ae8c928b9198","trace":"b704cf48cca3d2813a53ff15107f312d"}
-2026/06/26 05:43:40 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:43:43.205+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.7ms)","duration":"3017.7ms","level":"slow","span":"96d17d4cf9e626ea","trace":"27bf4ddb6d57fe168121781aadcfffbf"}
-{"@timestamp":"2026-06-26T05:43:43.205+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.7ms","level":"error","span":"96d17d4cf9e626ea","trace":"27bf4ddb6d57fe168121781aadcfffbf"}
-{"@timestamp":"2026-06-26T05:43:43.983+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.7Mi, TotalAlloc=58.1Mi, Sys=19.6Mi, NumGC=34","level":"stat"}
-{"@timestamp":"2026-06-26T05:43:43.999+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 11, drop: 0","level":"stat"}
-2026/06/26 05:43:44 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:54:15.282+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3019.7ms, med: 3020.4ms, 90th: 3025.7ms, 99th: 3025.7ms, 99.9th: 3025.7ms","level":"stat"}
-2026/06/26 05:54:15 job scheduler tick error: i/o timeout
-2026/06/26 05:54:16 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:54:17.893+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.5ms)","duration":"3013.5ms","level":"slow","span":"08e3f6b986888df6","trace":"223ba6d086594cd4d450826c4b383468"}
-{"@timestamp":"2026-06-26T05:54:17.893+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.5ms","level":"error","span":"08e3f6b986888df6","trace":"223ba6d086594cd4d450826c4b383468"}
-2026/06/26 05:54:20 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:54:23.921+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.7ms)","duration":"3022.7ms","level":"slow","span":"b9f3c9fae3635792","trace":"1657bfe1fbfd9e06f7d93ba914dfe638"}
-{"@timestamp":"2026-06-26T05:54:23.922+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.7ms","level":"error","span":"b9f3c9fae3635792","trace":"1657bfe1fbfd9e06f7d93ba914dfe638"}
-2026/06/26 05:54:24 job worker claim error: i/o timeout
-2026/06/26 05:54:25 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-183]) incomplete read of message header: read tcp 127.0.0.1:51055->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-183]) incomplete read of message header: read tcp 127.0.0.1:51055->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 05:54:28 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:54:29.954+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.8ms)","duration":"3026.8ms","level":"slow","span":"18c83ec16921428c","trace":"03244652c8b9f5074f771f6903e69d76"}
-{"@timestamp":"2026-06-26T05:54:29.955+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.8ms","level":"error","span":"18c83ec16921428c","trace":"03244652c8b9f5074f771f6903e69d76"}
-2026/06/26 05:54:33 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:54:35.978+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.3ms)","duration":"3016.3ms","level":"slow","span":"a8c1a7d7ba9f548f","trace":"6b84226c9931ef712cfe828d97d6b36d"}
-{"@timestamp":"2026-06-26T05:54:35.978+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.3ms","level":"error","span":"a8c1a7d7ba9f548f","trace":"6b84226c9931ef712cfe828d97d6b36d"}
-2026/06/26 05:54:37 job worker claim error: i/o timeout
-2026/06/26 05:54:41 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:54:42.000+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.3ms)","duration":"3016.3ms","level":"slow","span":"e33dd2e142aa92a8","trace":"422b03286c86d191d1a367d00814b823"}
-{"@timestamp":"2026-06-26T05:54:42.000+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.3ms","level":"error","span":"e33dd2e142aa92a8","trace":"422b03286c86d191d1a367d00814b823"}
-2026/06/26 05:54:45 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:54:48.029+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.5ms)","duration":"3023.5ms","level":"slow","span":"dc570b45bd41a892","trace":"a2e70f611054e2ff3b3431fc84ad570d"}
-{"@timestamp":"2026-06-26T05:54:48.029+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.5ms","level":"error","span":"dc570b45bd41a892","trace":"a2e70f611054e2ff3b3431fc84ad570d"}
-2026/06/26 05:54:49 job worker claim error: i/o timeout
-2026/06/26 05:54:53 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:54:54.058+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.9ms)","duration":"3023.9ms","level":"slow","span":"b9131de00f179815","trace":"944bcfd62adda978ee20ebbf306867cc"}
-{"@timestamp":"2026-06-26T05:54:54.058+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.9ms","level":"error","span":"b9131de00f179815","trace":"944bcfd62adda978ee20ebbf306867cc"}
-2026/06/26 05:54:55 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-189]) incomplete read of message header: read tcp 127.0.0.1:51118->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-189]) incomplete read of message header: read tcp 127.0.0.1:51118->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 05:55:29 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:55:32.920+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.9ms)","duration":"3024.9ms","level":"slow","span":"4aba3433b4e90351","trace":"8fbc3aa16ea0bfb457b7ea54ff44d316"}
-{"@timestamp":"2026-06-26T05:55:32.920+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.9ms","level":"error","span":"4aba3433b4e90351","trace":"8fbc3aa16ea0bfb457b7ea54ff44d316"}
-2026/06/26 05:55:33 job worker claim error: i/o timeout
-2026/06/26 05:55:38 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:55:38.947+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.2ms)","duration":"3021.2ms","level":"slow","span":"1ea3625937122790","trace":"bfd99b692fd66b20e1f0bd550656d2b9"}
-{"@timestamp":"2026-06-26T05:55:38.947+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.2ms","level":"error","span":"1ea3625937122790","trace":"bfd99b692fd66b20e1f0bd550656d2b9"}
-2026/06/26 05:55:42 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:55:44.966+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.9ms)","duration":"3012.9ms","level":"slow","span":"32b86db67871f526","trace":"22dc3ea03b3a8d6d7e2da6552b345658"}
-{"@timestamp":"2026-06-26T05:55:44.966+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.9ms","level":"error","span":"32b86db67871f526","trace":"22dc3ea03b3a8d6d7e2da6552b345658"}
-{"@timestamp":"2026-06-26T05:55:45.486+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=5.4Mi, TotalAlloc=59.8Mi, Sys=19.6Mi, NumGC=34","level":"stat"}
-{"@timestamp":"2026-06-26T05:55:45.503+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-2026/06/26 05:55:46 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:55:48.117+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3020.0ms, med: 3022.6ms, 90th: 3026.7ms, 99th: 3026.7ms, 99.9th: 3026.7ms","level":"stat"}
-2026/06/26 05:55:48 job scheduler tick error: i/o timeout
-2026/06/26 05:55:50 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:55:50.987+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.0ms)","duration":"3015.0ms","level":"slow","span":"dc918d287a4c60e2","trace":"36c3bbf8947afc39cec579f7e6917a2b"}
-{"@timestamp":"2026-06-26T05:55:50.987+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.0ms","level":"error","span":"dc918d287a4c60e2","trace":"36c3bbf8947afc39cec579f7e6917a2b"}
-2026/06/26 05:55:54 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:55:57.016+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.2ms)","duration":"3023.2ms","level":"slow","span":"b93f989d2a492c14","trace":"48edffb2a96de1edffbed00deecc73fb"}
-{"@timestamp":"2026-06-26T05:55:57.016+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.2ms","level":"error","span":"b93f989d2a492c14","trace":"48edffb2a96de1edffbed00deecc73fb"}
-2026/06/26 05:55:57 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-195]) incomplete read of message header: read tcp 127.0.0.1:51139->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-195]) incomplete read of message header: read tcp 127.0.0.1:51139->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 05:55:58 job worker claim error: i/o timeout
-2026/06/26 05:56:02 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T05:56:03.041+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.8ms)","duration":"3019.8ms","level":"slow","span":"d3404f28985d6d99","trace":"e153cb377527ddbc853ca596e405ab81"}
-{"@timestamp":"2026-06-26T05:56:03.041+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.8ms","level":"error","span":"d3404f28985d6d99","trace":"e153cb377527ddbc853ca596e405ab81"}
-2026/06/26 06:12:47 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T06:12:54.096+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(6375.4ms)","duration":"6375.4ms","level":"slow","span":"27c05ced3eaaee67","trace":"017e1d22c871eea0a47901ce57d64e0a"}
-{"@timestamp":"2026-06-26T06:12:54.218+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"6375.4ms","level":"error","span":"27c05ced3eaaee67","trace":"017e1d22c871eea0a47901ce57d64e0a"}
-2026/06/26 06:12:55 job worker claim error: i/o timeout
-2026/06/26 06:12:59 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T06:13:00.237+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.7ms)","duration":"3013.7ms","level":"slow","span":"863239b44bbff703","trace":"90db8a101cc7696c900678ab4cd4c51d"}
-{"@timestamp":"2026-06-26T06:13:00.237+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.7ms","level":"error","span":"863239b44bbff703","trace":"90db8a101cc7696c900678ab4cd4c51d"}
-2026/06/26 06:13:03 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T06:13:06.262+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.7ms)","duration":"3020.7ms","level":"slow","span":"d02c77ed51e65980","trace":"bf85b7833068f2e3cc643a11a8faddf8"}
-{"@timestamp":"2026-06-26T06:13:06.262+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.7ms","level":"error","span":"d02c77ed51e65980","trace":"bf85b7833068f2e3cc643a11a8faddf8"}
-2026/06/26 06:13:07 job worker claim error: i/o timeout
-2026/06/26 06:13:11 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T06:13:12.284+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.8ms)","duration":"3016.8ms","level":"slow","span":"0c0de1c70d4eb5f0","trace":"d8616456c2a579f41055a25572b1b5a0"}
-{"@timestamp":"2026-06-26T06:13:12.285+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.8ms","level":"error","span":"0c0de1c70d4eb5f0","trace":"d8616456c2a579f41055a25572b1b5a0"}
-2026/06/26 06:13:12 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-201]) incomplete read of message header: read tcp 127.0.0.1:51181->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-201]) incomplete read of message header: read tcp 127.0.0.1:51181->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 06:13:15 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T06:13:18.318+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.0ms)","duration":"3027.0ms","level":"slow","span":"69674cc715fe5ae4","trace":"84f58f363ceb387259d08525df457cb4"}
-{"@timestamp":"2026-06-26T06:13:18.318+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.0ms","level":"error","span":"69674cc715fe5ae4","trace":"84f58f363ceb387259d08525df457cb4"}
-2026/06/26 06:13:19 job worker claim error: i/o timeout
-2026/06/26 06:13:23 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T06:13:24.348+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.1ms)","duration":"3026.1ms","level":"slow","span":"dd38f6501c2f6828","trace":"d01c0995578852eec89796013ab76f7e"}
-{"@timestamp":"2026-06-26T06:13:24.348+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.1ms","level":"error","span":"dd38f6501c2f6828","trace":"d01c0995578852eec89796013ab76f7e"}
-2026/06/26 06:13:27 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T06:13:30.375+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.7ms)","duration":"3022.7ms","level":"slow","span":"29b50735193f147d","trace":"74ce3df7adcbb6bad6f5c8fa83a05dbd"}
-{"@timestamp":"2026-06-26T06:13:30.375+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.7ms","level":"error","span":"29b50735193f147d","trace":"74ce3df7adcbb6bad6f5c8fa83a05dbd"}
-{"@timestamp":"2026-06-26T06:13:30.438+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.8Mi, TotalAlloc=62.0Mi, Sys=19.6Mi, NumGC=35","level":"stat"}
-{"@timestamp":"2026-06-26T06:13:30.453+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-2026/06/26 06:31:12 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T06:31:13.841+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3355.9ms, med: 3022.6ms, 90th: 6375.3ms, 99th: 6375.3ms, 99.9th: 6375.3ms","level":"stat"}
-2026/06/26 06:31:14 job scheduler tick error: i/o timeout
-{"@timestamp":"2026-06-26T06:31:14.651+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.2ms)","duration":"3021.2ms","level":"slow","span":"ceaf4869af4f9bfd","trace":"928da8566cdeeb4f41941efbb7a29c96"}
-{"@timestamp":"2026-06-26T06:31:14.651+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.2ms","level":"error","span":"ceaf4869af4f9bfd","trace":"928da8566cdeeb4f41941efbb7a29c96"}
-2026/06/26 06:31:16 job worker claim error: i/o timeout
-2026/06/26 06:48:43 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T06:48:44.328+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.5ms)","duration":"3023.5ms","level":"slow","span":"c30108fe8b868263","trace":"6fa4c7c00a2a0bb6d9231ce14e4a1f18"}
-{"@timestamp":"2026-06-26T06:48:44.329+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.5ms","level":"error","span":"c30108fe8b868263","trace":"6fa4c7c00a2a0bb6d9231ce14e4a1f18"}
-2026/06/26 06:55:14 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-207]) incomplete read of message header: read tcp 127.0.0.1:51204->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-207]) incomplete read of message header: read tcp 127.0.0.1:51204->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 06:55:15 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T06:55:17.980+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3011.8ms)","duration":"3011.8ms","level":"slow","span":"86126878e2678826","trace":"e196eb2cf7be6ca7ddd87bf273409897"}
-{"@timestamp":"2026-06-26T06:55:17.980+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3011.8ms","level":"error","span":"86126878e2678826","trace":"e196eb2cf7be6ca7ddd87bf273409897"}
-2026/06/26 06:55:19 job worker claim error: i/o timeout
-2026/06/26 06:55:23 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T06:55:23.996+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.8ms)","duration":"3012.8ms","level":"slow","span":"c909814cb56c77c9","trace":"96d9d7f4d382c7339b3ea36619632d8c"}
-{"@timestamp":"2026-06-26T06:55:23.996+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.8ms","level":"error","span":"c909814cb56c77c9","trace":"96d9d7f4d382c7339b3ea36619632d8c"}
-2026/06/26 06:55:27 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T06:55:30.025+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.4ms)","duration":"3024.4ms","level":"slow","span":"829c25e12eb5abf1","trace":"f8d54addd7af6c5882a11c35614f2203"}
-{"@timestamp":"2026-06-26T06:55:30.025+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.4ms","level":"error","span":"829c25e12eb5abf1","trace":"f8d54addd7af6c5882a11c35614f2203"}
-2026/06/26 06:55:31 job worker claim error: i/o timeout
-2026/06/26 06:55:35 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T06:55:36.048+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.6ms)","duration":"3022.6ms","level":"slow","span":"3647c104623909c5","trace":"ea20d6e4f66d92251398a922ce9421ea"}
-{"@timestamp":"2026-06-26T06:55:36.048+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.6ms","level":"error","span":"3647c104623909c5","trace":"ea20d6e4f66d92251398a922ce9421ea"}
-2026/06/26 06:55:39 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T06:55:42.079+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.9ms)","duration":"3026.9ms","level":"slow","span":"9af217accaae6662","trace":"0de3a2fea59bd568fe6942a09c893dfe"}
-{"@timestamp":"2026-06-26T06:55:42.079+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.9ms","level":"error","span":"9af217accaae6662","trace":"0de3a2fea59bd568fe6942a09c893dfe"}
-2026/06/26 06:55:43 job worker claim error: i/o timeout
-2026/06/26 06:55:44 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-213]) incomplete read of message header: read tcp 127.0.0.1:51283->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-213]) incomplete read of message header: read tcp 127.0.0.1:51283->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 06:55:47 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T06:55:48.096+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.4ms)","duration":"3013.4ms","level":"slow","span":"6be2945789045e84","trace":"bfa67c873d7ecba24ecef83087764009"}
-{"@timestamp":"2026-06-26T06:55:48.097+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.4ms","level":"error","span":"6be2945789045e84","trace":"bfa67c873d7ecba24ecef83087764009"}
-2026/06/26 06:55:51 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T06:55:54.123+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.0ms)","duration":"3021.0ms","level":"slow","span":"2f34ca20ff9e0c8f","trace":"03e549f3a03851524bb76de8cb1690a9"}
-{"@timestamp":"2026-06-26T06:55:54.124+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.0ms","level":"error","span":"2f34ca20ff9e0c8f","trace":"03e549f3a03851524bb76de8cb1690a9"}
-2026/06/26 06:55:55 job worker claim error: i/o timeout
-2026/06/26 06:55:59 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T06:56:00.141+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3011.6ms)","duration":"3011.6ms","level":"slow","span":"d1bc1f59634fa8e6","trace":"77071c10188e5d038dc9a036f844aa41"}
-{"@timestamp":"2026-06-26T06:56:00.141+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3011.6ms","level":"error","span":"d1bc1f59634fa8e6","trace":"77071c10188e5d038dc9a036f844aa41"}
-{"@timestamp":"2026-06-26T07:00:14.780+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.4Mi, TotalAlloc=64.3Mi, Sys=19.6Mi, NumGC=36","level":"stat"}
-{"@timestamp":"2026-06-26T07:00:14.797+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 10, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T07:00:15.959+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.2ms)","duration":"3019.2ms","level":"slow","span":"6d539df7f6e138f6","trace":"bb9fa17ec862deb92164578b35ee1bb4"}
-{"@timestamp":"2026-06-26T07:00:15.959+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.2ms","level":"error","span":"6d539df7f6e138f6","trace":"bb9fa17ec862deb92164578b35ee1bb4"}
-2026/06/26 07:00:16 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:00:17.409+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3018.8ms, med: 3021.1ms, 90th: 3026.7ms, 99th: 3026.7ms, 99.9th: 3026.7ms","level":"stat"}
-2026/06/26 07:00:17 job scheduler tick error: i/o timeout
-2026/06/26 07:00:20 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:00:21.989+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.2ms)","duration":"3024.2ms","level":"slow","span":"b9a3e387d85789d9","trace":"b65b0fc2ae10b021833c95ea47402737"}
-{"@timestamp":"2026-06-26T07:00:21.989+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.2ms","level":"error","span":"b9a3e387d85789d9","trace":"b65b0fc2ae10b021833c95ea47402737"}
-2026/06/26 07:00:24 job worker claim error: i/o timeout
-2026/06/26 07:00:27 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-219]) incomplete read of message header: read tcp 127.0.0.1:51307->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-219]) incomplete read of message header: read tcp 127.0.0.1:51307->127.0.0.1:27017: i/o timeout }, ] }
-{"@timestamp":"2026-06-26T07:00:28.015+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.8ms)","duration":"3019.8ms","level":"slow","span":"506ff50fa12b5fea","trace":"f296daa39634abd2f6abb8b8ae6ef644"}
-{"@timestamp":"2026-06-26T07:00:28.015+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.8ms","level":"error","span":"506ff50fa12b5fea","trace":"f296daa39634abd2f6abb8b8ae6ef644"}
-2026/06/26 07:00:28 job worker claim error: i/o timeout
-2026/06/26 07:00:32 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:00:34.037+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.8ms)","duration":"3015.8ms","level":"slow","span":"471a5cb1934a73a9","trace":"e44ab83dbf2fe2ef558d5f93294e1bd9"}
-{"@timestamp":"2026-06-26T07:00:34.038+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.8ms","level":"error","span":"471a5cb1934a73a9","trace":"e44ab83dbf2fe2ef558d5f93294e1bd9"}
-2026/06/26 07:00:36 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:00:40.064+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.6ms)","duration":"3022.6ms","level":"slow","span":"bf6608e63292dbe0","trace":"ff371f71f4c25f4639bff65849b21982"}
-{"@timestamp":"2026-06-26T07:00:40.064+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.6ms","level":"error","span":"bf6608e63292dbe0","trace":"ff371f71f4c25f4639bff65849b21982"}
-2026/06/26 07:00:40 job worker claim error: i/o timeout
-2026/06/26 07:00:44 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:00:46.084+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.2ms)","duration":"3015.2ms","level":"slow","span":"21b58a1fd178a802","trace":"c626c34941584bd0ddc91213b8c1b2f9"}
-{"@timestamp":"2026-06-26T07:00:46.084+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.2ms","level":"error","span":"21b58a1fd178a802","trace":"c626c34941584bd0ddc91213b8c1b2f9"}
-2026/06/26 07:00:48 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:00:52.116+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3025.9ms)","duration":"3025.9ms","level":"slow","span":"a21ad5ffed0e5bb4","trace":"4099744634b8cb5d90c070e4555e71cc"}
-{"@timestamp":"2026-06-26T07:00:52.116+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3025.9ms","level":"error","span":"a21ad5ffed0e5bb4","trace":"4099744634b8cb5d90c070e4555e71cc"}
-2026/06/26 07:00:52 job worker claim error: i/o timeout
-2026/06/26 07:17:21 job worker claim error: i/o timeout
-2026/06/26 07:17:22 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-225]) incomplete read of message header: read tcp 127.0.0.1:51333->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-225]) incomplete read of message header: read tcp 127.0.0.1:51333->127.0.0.1:27017: i/o timeout }, ] }
-{"@timestamp":"2026-06-26T07:17:23.409+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.7ms)","duration":"3020.7ms","level":"slow","span":"a7bcc8f6a3243683","trace":"f95114da93683ac5fd0e6cfe31f85659"}
-{"@timestamp":"2026-06-26T07:17:23.409+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.7ms","level":"error","span":"a7bcc8f6a3243683","trace":"f95114da93683ac5fd0e6cfe31f85659"}
-2026/06/26 07:18:35 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:18:39.266+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3033.8ms)","duration":"3033.8ms","level":"slow","span":"612916807468385e","trace":"b8a2bc8a56fa985ece009e5a71d28499"}
-{"@timestamp":"2026-06-26T07:18:39.267+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3033.8ms","level":"error","span":"612916807468385e","trace":"b8a2bc8a56fa985ece009e5a71d28499"}
-2026/06/26 07:18:39 job worker claim error: i/o timeout
-2026/06/26 07:18:43 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:18:45.294+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.8ms)","duration":"3015.8ms","level":"slow","span":"4495fadefa9aa4a7","trace":"4d595973b93e850b01212f15f03c4490"}
-{"@timestamp":"2026-06-26T07:18:45.294+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.8ms","level":"error","span":"4495fadefa9aa4a7","trace":"4d595973b93e850b01212f15f03c4490"}
-2026/06/26 07:18:47 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:18:49.885+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.0Mi, TotalAlloc=66.5Mi, Sys=19.6Mi, NumGC=37","level":"stat"}
-{"@timestamp":"2026-06-26T07:18:49.902+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T07:18:51.325+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.1ms)","duration":"3022.1ms","level":"slow","span":"c3b57a8655e3b395","trace":"177590471697981d95409ced4400cf2e"}
-{"@timestamp":"2026-06-26T07:18:51.325+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.1ms","level":"error","span":"c3b57a8655e3b395","trace":"177590471697981d95409ced4400cf2e"}
-2026/06/26 07:18:51 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:18:52.519+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3021.4ms, med: 3022.0ms, 90th: 3033.7ms, 99th: 3033.7ms, 99.9th: 3033.7ms","level":"stat"}
-2026/06/26 07:18:52 job scheduler tick error: i/o timeout
-2026/06/26 07:18:55 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:18:57.358+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.9ms)","duration":"3023.9ms","level":"slow","span":"d42d739286582f74","trace":"a3dbbc7779fe7883207870631371473f"}
-{"@timestamp":"2026-06-26T07:18:57.358+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.9ms","level":"error","span":"d42d739286582f74","trace":"a3dbbc7779fe7883207870631371473f"}
-2026/06/26 07:18:59 job worker claim error: i/o timeout
-2026/06/26 07:19:02 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-231]) incomplete read of message header: read tcp 127.0.0.1:51377->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-231]) incomplete read of message header: read tcp 127.0.0.1:51377->127.0.0.1:27017: i/o timeout }, ] }
-{"@timestamp":"2026-06-26T07:34:47.272+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(946906.6ms)","duration":"946906.6ms","level":"slow","span":"a57211457bdf8c80","trace":"d822f69cdc4bc1ea833918630dee49ff"}
-{"@timestamp":"2026-06-26T07:34:47.272+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"946906.6ms","level":"error","span":"a57211457bdf8c80","trace":"d822f69cdc4bc1ea833918630dee49ff"}
-2026/06/26 07:34:47 job worker claim error: i/o timeout
-2026/06/26 07:34:51 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:34:53.296+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.0ms)","duration":"3017.0ms","level":"slow","span":"e33805ca839a5f1b","trace":"a1252bae28253a265430c74875a3fc53"}
-{"@timestamp":"2026-06-26T07:34:53.296+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.0ms","level":"error","span":"e33805ca839a5f1b","trace":"a1252bae28253a265430c74875a3fc53"}
-2026/06/26 07:34:55 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:34:59.312+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.4ms)","duration":"3012.4ms","level":"slow","span":"cff3e29a34c1a85b","trace":"7df1436b9471d7f456954e936672a54a"}
-{"@timestamp":"2026-06-26T07:34:59.313+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.4ms","level":"error","span":"cff3e29a34c1a85b","trace":"7df1436b9471d7f456954e936672a54a"}
-2026/06/26 07:34:59 job worker claim error: i/o timeout
-2026/06/26 07:35:03 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:35:05.335+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.9ms)","duration":"3017.9ms","level":"slow","span":"a5846753014f8931","trace":"5dced19dfea091b15a3dfbe66cfa8530"}
-{"@timestamp":"2026-06-26T07:35:05.335+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.9ms","level":"error","span":"a5846753014f8931","trace":"5dced19dfea091b15a3dfbe66cfa8530"}
-2026/06/26 07:35:07 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:35:11.359+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.4ms)","duration":"3018.4ms","level":"slow","span":"4a80a22de8c6d4ab","trace":"86562201cc1e12ba903e726546107ce3"}
-{"@timestamp":"2026-06-26T07:35:11.359+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.4ms","level":"error","span":"4a80a22de8c6d4ab","trace":"86562201cc1e12ba903e726546107ce3"}
-2026/06/26 07:35:11 job worker claim error: i/o timeout
-2026/06/26 07:35:15 job worker claim error: i/o timeout
-2026/06/26 07:35:16 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-237]) incomplete read of message header: read tcp 127.0.0.1:51423->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-237]) incomplete read of message header: read tcp 127.0.0.1:51423->127.0.0.1:27017: i/o timeout }, ] }
-{"@timestamp":"2026-06-26T07:35:17.380+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.7ms)","duration":"3016.7ms","level":"slow","span":"8e96227453acb4e6","trace":"850a61a30562aa9a046e16d0b72e4ade"}
-{"@timestamp":"2026-06-26T07:35:17.381+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.7ms","level":"error","span":"8e96227453acb4e6","trace":"850a61a30562aa9a046e16d0b72e4ade"}
-2026/06/26 07:35:19 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:35:23.404+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.9ms)","duration":"3017.9ms","level":"slow","span":"bdd3e6b7d876973a","trace":"ee74f3dda6744891a611b65be7d1dcd9"}
-{"@timestamp":"2026-06-26T07:35:23.404+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.9ms","level":"error","span":"bdd3e6b7d876973a","trace":"ee74f3dda6744891a611b65be7d1dcd9"}
-2026/06/26 07:35:23 job worker claim error: i/o timeout
-2026/06/26 07:35:27 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:35:29.423+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.3ms)","duration":"3013.3ms","level":"slow","span":"bcfb25dc84b55b8b","trace":"c505bc6f827c916ced8d5e84784d35cf"}
-{"@timestamp":"2026-06-26T07:35:29.423+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.3ms","level":"error","span":"bcfb25dc84b55b8b","trace":"c505bc6f827c916ced8d5e84784d35cf"}
-2026/06/26 07:35:31 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:35:33.764+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=68.8Mi, Sys=19.6Mi, NumGC=38","level":"stat"}
-{"@timestamp":"2026-06-26T07:35:33.783+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T07:46:01.088+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(628659.3ms)","duration":"628659.3ms","level":"slow","span":"7b59cfa667d4b29d","trace":"e0cac95b3c7dddc5c14b69faf6d01fa8"}
-{"@timestamp":"2026-06-26T07:46:01.088+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"628659.3ms","level":"error","span":"7b59cfa667d4b29d","trace":"e0cac95b3c7dddc5c14b69faf6d01fa8"}
-2026/06/26 07:46:01 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:46:02.028+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 159970.2ms, med: 3017.8ms, 90th: 946906.4ms, 99th: 946906.4ms, 99.9th: 946906.4ms","level":"stat"}
-2026/06/26 07:46:02 job scheduler tick error: i/o timeout
-2026/06/26 07:46:05 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:46:07.115+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.3ms)","duration":"3021.3ms","level":"slow","span":"61f13b9761138927","trace":"8d18ca2c18870d96bcb8983cac765aa8"}
-{"@timestamp":"2026-06-26T07:46:07.115+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.3ms","level":"error","span":"61f13b9761138927","trace":"8d18ca2c18870d96bcb8983cac765aa8"}
-2026/06/26 07:46:09 job worker claim error: i/o timeout
-2026/06/26 07:46:11 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-243]) incomplete read of message header: read tcp 127.0.0.1:51445->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-243]) incomplete read of message header: read tcp 127.0.0.1:51445->127.0.0.1:27017: i/o timeout }, ] }
-{"@timestamp":"2026-06-26T07:46:13.142+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.1ms)","duration":"3023.1ms","level":"slow","span":"4a8b0b124815a4ce","trace":"d013c0e0c9d455bda331af6d68bc8c95"}
-{"@timestamp":"2026-06-26T07:46:13.142+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.1ms","level":"error","span":"4a8b0b124815a4ce","trace":"d013c0e0c9d455bda331af6d68bc8c95"}
-2026/06/26 07:46:13 job worker claim error: i/o timeout
-2026/06/26 07:46:17 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:46:19.160+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.7ms)","duration":"3012.7ms","level":"slow","span":"93c9ed06fa73ce40","trace":"a81a44c9ed58c849fe642004ed75d899"}
-{"@timestamp":"2026-06-26T07:46:19.160+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.7ms","level":"error","span":"93c9ed06fa73ce40","trace":"a81a44c9ed58c849fe642004ed75d899"}
-2026/06/26 07:46:21 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:56:15.348+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(593181.6ms)","duration":"593181.6ms","level":"slow","span":"ec34624021fed3ce","trace":"85e3ed3944e2baca5b169ee1ebb62bb7"}
-{"@timestamp":"2026-06-26T07:56:15.348+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"593181.6ms","level":"error","span":"ec34624021fed3ce","trace":"85e3ed3944e2baca5b169ee1ebb62bb7"}
-2026/06/26 07:56:15 job worker claim error: i/o timeout
-2026/06/26 07:56:19 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:56:21.370+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.0ms)","duration":"3021.0ms","level":"slow","span":"55f9446a4be1ba11","trace":"9001a5169fa898b6fb5eeac899861284"}
-{"@timestamp":"2026-06-26T07:56:21.371+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.0ms","level":"error","span":"55f9446a4be1ba11","trace":"9001a5169fa898b6fb5eeac899861284"}
-2026/06/26 07:56:23 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:56:27.392+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.9ms)","duration":"3019.9ms","level":"slow","span":"32be0e5647ededab","trace":"528f90b86d66b7a5ab5b3008d0fc6f92"}
-{"@timestamp":"2026-06-26T07:56:27.393+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.9ms","level":"error","span":"32be0e5647ededab","trace":"528f90b86d66b7a5ab5b3008d0fc6f92"}
-2026/06/26 07:56:27 job worker claim error: i/o timeout
-2026/06/26 07:56:31 job worker claim error: i/o timeout
-2026/06/26 07:56:31 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-249]) incomplete read of message header: read tcp 127.0.0.1:51480->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-249]) incomplete read of message header: read tcp 127.0.0.1:51480->127.0.0.1:27017: i/o timeout }, ] }
-{"@timestamp":"2026-06-26T07:56:33.413+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.8ms)","duration":"3015.8ms","level":"slow","span":"e8032c46a495f285","trace":"de91a967f4da4a11852a57c1400738ba"}
-{"@timestamp":"2026-06-26T07:56:33.413+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.8ms","level":"error","span":"e8032c46a495f285","trace":"de91a967f4da4a11852a57c1400738ba"}
-2026/06/26 07:56:35 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:56:39.437+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.8ms)","duration":"3021.8ms","level":"slow","span":"852ed51d940f5c27","trace":"bc51920ff716c6024783326c2bee8c15"}
-{"@timestamp":"2026-06-26T07:56:39.437+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.8ms","level":"error","span":"852ed51d940f5c27","trace":"bc51920ff716c6024783326c2bee8c15"}
-2026/06/26 07:56:39 job worker claim error: i/o timeout
-2026/06/26 07:56:43 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:56:45.461+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.6ms)","duration":"3019.6ms","level":"slow","span":"a4fa84852bbb9eed","trace":"54d9b6690fd6c3c8af79f27db9f17d4d"}
-{"@timestamp":"2026-06-26T07:56:45.461+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.6ms","level":"error","span":"a4fa84852bbb9eed","trace":"54d9b6690fd6c3c8af79f27db9f17d4d"}
-2026/06/26 07:56:47 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:56:49.527+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=5.3Mi, TotalAlloc=70.5Mi, Sys=19.6Mi, NumGC=38","level":"stat"}
-{"@timestamp":"2026-06-26T07:56:49.544+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T07:56:51.488+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.0ms)","duration":"3022.0ms","level":"slow","span":"0eb43ba81e2a12d9","trace":"7d6050cc5c8bd41dc8b90654f8d88fd9"}
-{"@timestamp":"2026-06-26T07:56:51.488+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.0ms","level":"error","span":"0eb43ba81e2a12d9","trace":"7d6050cc5c8bd41dc8b90654f8d88fd9"}
-2026/06/26 07:56:51 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T07:56:52.158+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 62035.7ms, med: 3021.2ms, 90th: 593181.5ms, 99th: 593181.5ms, 99.9th: 593181.5ms","level":"stat"}
-2026/06/26 07:56:52 job scheduler tick error: i/o timeout
-2026/06/26 08:00:06 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T08:00:06.888+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2065.3ms)","duration":"2065.3ms","level":"slow","span":"98588c303dddb024","trace":"e0cc1ab253c65e80a971df9389a29941"}
-{"@timestamp":"2026-06-26T08:00:06.888+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2065.3ms","level":"info","span":"98588c303dddb024","trace":"e0cc1ab253c65e80a971df9389a29941"}
-{"@timestamp":"2026-06-26T08:00:11.933+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2038.3ms)","duration":"2038.3ms","level":"slow","span":"630a7987f0639856","trace":"151e7c35dc4c2a8a0d3870b69c127ee6"}
-{"@timestamp":"2026-06-26T08:00:11.933+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2038.3ms","level":"info","span":"630a7987f0639856","trace":"151e7c35dc4c2a8a0d3870b69c127ee6"}
-{"@timestamp":"2026-06-26T08:00:16.948+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2010.8ms)","duration":"2010.8ms","level":"slow","span":"2539296d3c477ca1","trace":"501775828ecb27a18be4cc34b5474270"}
-{"@timestamp":"2026-06-26T08:00:16.948+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2010.8ms","level":"info","span":"2539296d3c477ca1","trace":"501775828ecb27a18be4cc34b5474270"}
-{"@timestamp":"2026-06-26T08:00:22.021+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2070.2ms)","duration":"2070.2ms","level":"slow","span":"38673f8713343274","trace":"c6d3f43fe956cfc125d2d3bdbb5380d2"}
-{"@timestamp":"2026-06-26T08:00:22.022+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2070.2ms","level":"info","span":"38673f8713343274","trace":"c6d3f43fe956cfc125d2d3bdbb5380d2"}
-{"@timestamp":"2026-06-26T08:00:27.068+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.4ms)","duration":"2043.4ms","level":"slow","span":"da390d8398758abd","trace":"b49e2d9d22f7a82405ddd8471f690ac7"}
-{"@timestamp":"2026-06-26T08:00:27.068+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.4ms","level":"info","span":"da390d8398758abd","trace":"b49e2d9d22f7a82405ddd8471f690ac7"}
-{"@timestamp":"2026-06-26T08:00:32.110+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2039.0ms)","duration":"2039.0ms","level":"slow","span":"5fc77c577af4bbd2","trace":"3edaf29015200d3b8233e4f46292a0aa"}
-{"@timestamp":"2026-06-26T08:00:32.110+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2039.0ms","level":"info","span":"5fc77c577af4bbd2","trace":"3edaf29015200d3b8233e4f46292a0aa"}
-{"@timestamp":"2026-06-26T08:00:37.158+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.3ms)","duration":"2044.3ms","level":"slow","span":"7441bbc30ce81ac0","trace":"4095d9abdf4a22b78fc82f33fa09623a"}
-{"@timestamp":"2026-06-26T08:00:37.158+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.3ms","level":"info","span":"7441bbc30ce81ac0","trace":"4095d9abdf4a22b78fc82f33fa09623a"}
-{"@timestamp":"2026-06-26T08:00:42.241+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2080.1ms)","duration":"2080.1ms","level":"slow","span":"3c65916395972def","trace":"08342b09db2ac2a858558a2ab7c5d1fe"}
-{"@timestamp":"2026-06-26T08:00:42.241+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2080.1ms","level":"info","span":"3c65916395972def","trace":"08342b09db2ac2a858558a2ab7c5d1fe"}
-{"@timestamp":"2026-06-26T08:00:47.321+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2076.4ms)","duration":"2076.4ms","level":"slow","span":"ab27b838299a3dbb","trace":"5150288cb40a1a9ddf4aaf371521492a"}
-{"@timestamp":"2026-06-26T08:00:47.321+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2076.4ms","level":"info","span":"ab27b838299a3dbb","trace":"5150288cb40a1a9ddf4aaf371521492a"}
-{"@timestamp":"2026-06-26T08:00:52.404+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2079.4ms)","duration":"2079.4ms","level":"slow","span":"283429adcca1de70","trace":"411178f31d839c1f862949bf3864f62b"}
-{"@timestamp":"2026-06-26T08:00:52.404+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2079.4ms","level":"info","span":"283429adcca1de70","trace":"411178f31d839c1f862949bf3864f62b"}
-{"@timestamp":"2026-06-26T08:00:57.437+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.0ms)","duration":"2031.0ms","level":"slow","span":"fad9918b23468f67","trace":"9d30a07720d4eef8feab4a4d1c0c321a"}
-{"@timestamp":"2026-06-26T08:00:57.437+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.0ms","level":"info","span":"fad9918b23468f67","trace":"9d30a07720d4eef8feab4a4d1c0c321a"}
-{"@timestamp":"2026-06-26T08:00:59.826+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=71.4Mi, Sys=19.6Mi, NumGC=39","level":"stat"}
-{"@timestamp":"2026-06-26T08:00:59.843+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T08:01:02.457+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2052.4ms, med: 2065.3ms, 90th: 2080.0ms, 99th: 2080.0ms, 99.9th: 2080.0ms","level":"stat"}
-{"@timestamp":"2026-06-26T08:01:02.478+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2038.0ms)","duration":"2038.0ms","level":"slow","span":"db7918ff865f99fd","trace":"0e06f7e793137035ae0f63bb209e33d8"}
-{"@timestamp":"2026-06-26T08:01:02.479+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2038.0ms","level":"info","span":"db7918ff865f99fd","trace":"0e06f7e793137035ae0f63bb209e33d8"}
-{"@timestamp":"2026-06-26T08:01:07.515+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2033.2ms)","duration":"2033.2ms","level":"slow","span":"630afc353a59964b","trace":"9ea3cf7a9ef201923536b63e8d21974b"}
-{"@timestamp":"2026-06-26T08:01:07.515+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2033.2ms","level":"info","span":"630afc353a59964b","trace":"9ea3cf7a9ef201923536b63e8d21974b"}
-{"@timestamp":"2026-06-26T08:01:12.563+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.4ms)","duration":"2045.4ms","level":"slow","span":"c6280abafdcf7618","trace":"ad27eaa6660d7dfe72be2de8d1e40de0"}
-{"@timestamp":"2026-06-26T08:01:12.564+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.4ms","level":"info","span":"c6280abafdcf7618","trace":"ad27eaa6660d7dfe72be2de8d1e40de0"}
-{"@timestamp":"2026-06-26T08:01:17.610+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.2ms)","duration":"2043.2ms","level":"slow","span":"7a193ab09aeaa7c0","trace":"0d4c2b70959f097481b855884e1a3eac"}
-{"@timestamp":"2026-06-26T08:01:17.610+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.2ms","level":"info","span":"7a193ab09aeaa7c0","trace":"0d4c2b70959f097481b855884e1a3eac"}
-{"@timestamp":"2026-06-26T08:01:22.653+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2040.3ms)","duration":"2040.3ms","level":"slow","span":"10a858a93f14298d","trace":"214fd103e2dc0e3217b599aba6c7bef8"}
-{"@timestamp":"2026-06-26T08:01:22.653+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2040.3ms","level":"info","span":"10a858a93f14298d","trace":"214fd103e2dc0e3217b599aba6c7bef8"}
-{"@timestamp":"2026-06-26T08:01:27.706+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2049.7ms)","duration":"2049.7ms","level":"slow","span":"749b743a2ce858b6","trace":"00ed6d13592560d8735cdf20b749f760"}
-{"@timestamp":"2026-06-26T08:01:27.706+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2049.7ms","level":"info","span":"749b743a2ce858b6","trace":"00ed6d13592560d8735cdf20b749f760"}
-{"@timestamp":"2026-06-26T08:01:32.778+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2070.3ms)","duration":"2070.3ms","level":"slow","span":"af4704a55db8373e","trace":"c6a3bddbdd22a7f5488ceae1e99b1020"}
-{"@timestamp":"2026-06-26T08:01:32.779+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2070.3ms","level":"info","span":"af4704a55db8373e","trace":"c6a3bddbdd22a7f5488ceae1e99b1020"}
-{"@timestamp":"2026-06-26T08:01:37.806+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.0ms)","duration":"2025.0ms","level":"slow","span":"a1fc78dabe0c5fb2","trace":"b9e7070a09150e87176161cc3c6cbf30"}
-{"@timestamp":"2026-06-26T08:01:37.806+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.0ms","level":"info","span":"a1fc78dabe0c5fb2","trace":"b9e7070a09150e87176161cc3c6cbf30"}
-{"@timestamp":"2026-06-26T08:01:42.843+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2033.6ms)","duration":"2033.6ms","level":"slow","span":"a633e29a7f552a60","trace":"1840386c6f659f74d6b2cc28975d7601"}
-{"@timestamp":"2026-06-26T08:01:42.844+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2033.6ms","level":"info","span":"a633e29a7f552a60","trace":"1840386c6f659f74d6b2cc28975d7601"}
-{"@timestamp":"2026-06-26T08:01:47.878+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.2ms)","duration":"2031.2ms","level":"slow","span":"ff71fdd5952c98af","trace":"680024fcd46aaaac2ffcc60dc2644ea5"}
-{"@timestamp":"2026-06-26T08:01:47.878+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.2ms","level":"info","span":"ff71fdd5952c98af","trace":"680024fcd46aaaac2ffcc60dc2644ea5"}
-{"@timestamp":"2026-06-26T08:01:52.923+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2041.7ms)","duration":"2041.7ms","level":"slow","span":"db9578fbc71adcab","trace":"c57492a11c1ab3839a0404009afe0d93"}
-{"@timestamp":"2026-06-26T08:01:52.923+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2041.7ms","level":"info","span":"db9578fbc71adcab","trace":"c57492a11c1ab3839a0404009afe0d93"}
-{"@timestamp":"2026-06-26T08:01:58.008+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2081.1ms)","duration":"2081.1ms","level":"slow","span":"29b82dd0e05f68d6","trace":"6c72224fbc485ff9330393f3c211792f"}
-{"@timestamp":"2026-06-26T08:01:58.008+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2081.1ms","level":"info","span":"29b82dd0e05f68d6","trace":"6c72224fbc485ff9330393f3c211792f"}
-{"@timestamp":"2026-06-26T08:01:59.826+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.8Mi, TotalAlloc=71.6Mi, Sys=19.6Mi, NumGC=39","level":"stat"}
-{"@timestamp":"2026-06-26T08:01:59.843+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T08:02:02.456+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2044.2ms, med: 2041.7ms, 90th: 2081.0ms, 99th: 2081.0ms, 99.9th: 2081.0ms","level":"stat"}
-{"@timestamp":"2026-06-26T08:02:03.073+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2060.7ms)","duration":"2060.7ms","level":"slow","span":"5bb6659319ab9959","trace":"1d8ae7d2fe60c8d072f9649bd9bcddf1"}
-{"@timestamp":"2026-06-26T08:02:03.073+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2060.7ms","level":"info","span":"5bb6659319ab9959","trace":"1d8ae7d2fe60c8d072f9649bd9bcddf1"}
-{"@timestamp":"2026-06-26T08:02:08.137+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2060.7ms)","duration":"2060.7ms","level":"slow","span":"133a9356ef561b94","trace":"dec6aff83f8426f1ccea7c0f4714f997"}
-{"@timestamp":"2026-06-26T08:02:08.138+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2060.7ms","level":"info","span":"133a9356ef561b94","trace":"dec6aff83f8426f1ccea7c0f4714f997"}
-{"@timestamp":"2026-06-26T08:02:13.200+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2060.2ms)","duration":"2060.2ms","level":"slow","span":"dedf11aca10e8c3d","trace":"0dba9c4bc3cf0ea7691a568efbb6dc79"}
-{"@timestamp":"2026-06-26T08:02:13.200+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2060.2ms","level":"info","span":"dedf11aca10e8c3d","trace":"0dba9c4bc3cf0ea7691a568efbb6dc79"}
-{"@timestamp":"2026-06-26T08:02:18.260+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2055.6ms)","duration":"2055.6ms","level":"slow","span":"80d4a51f00e0e2c0","trace":"f330e8f607071c40149096dbb13842eb"}
-{"@timestamp":"2026-06-26T08:02:18.260+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2055.6ms","level":"info","span":"80d4a51f00e0e2c0","trace":"f330e8f607071c40149096dbb13842eb"}
-{"@timestamp":"2026-06-26T08:02:23.299+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.6ms)","duration":"2034.6ms","level":"slow","span":"3ec3cb3931a4add6","trace":"4ab622d4d3a1fc9bce7466cd6a021ea8"}
-{"@timestamp":"2026-06-26T08:02:23.299+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.6ms","level":"info","span":"3ec3cb3931a4add6","trace":"4ab622d4d3a1fc9bce7466cd6a021ea8"}
-{"@timestamp":"2026-06-26T08:02:28.338+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.9ms)","duration":"2035.9ms","level":"slow","span":"bd64300091d86024","trace":"bbdbd9b8fde1ece3a5cae7bad8fe6d47"}
-{"@timestamp":"2026-06-26T08:02:28.338+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.9ms","level":"info","span":"bd64300091d86024","trace":"bbdbd9b8fde1ece3a5cae7bad8fe6d47"}
-{"@timestamp":"2026-06-26T08:02:33.445+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2103.7ms)","duration":"2103.7ms","level":"slow","span":"b2a17fada292ba91","trace":"26eacc46a73880b5f5682a86e4a6be1d"}
-{"@timestamp":"2026-06-26T08:02:33.445+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2103.7ms","level":"info","span":"b2a17fada292ba91","trace":"26eacc46a73880b5f5682a86e4a6be1d"}
-{"@timestamp":"2026-06-26T08:02:38.483+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.0ms)","duration":"2034.0ms","level":"slow","span":"5b6ec417f5cc0292","trace":"1b6ceade18766faceb42964aedcfcad7"}
-{"@timestamp":"2026-06-26T08:02:38.486+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.0ms","level":"info","span":"5b6ec417f5cc0292","trace":"1b6ceade18766faceb42964aedcfcad7"}
-{"@timestamp":"2026-06-26T08:02:43.542+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2051.4ms)","duration":"2051.4ms","level":"slow","span":"71250ece1a275d49","trace":"aec8ad27f9b843b1523947cf941026d3"}
-{"@timestamp":"2026-06-26T08:02:43.542+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2051.4ms","level":"info","span":"71250ece1a275d49","trace":"aec8ad27f9b843b1523947cf941026d3"}
-{"@timestamp":"2026-06-26T08:02:48.582+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2036.9ms)","duration":"2036.9ms","level":"slow","span":"92f33b9b43ba3c22","trace":"e285a61dd50f790d488d68c15cc50667"}
-{"@timestamp":"2026-06-26T08:02:48.583+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2036.9ms","level":"info","span":"92f33b9b43ba3c22","trace":"e285a61dd50f790d488d68c15cc50667"}
-{"@timestamp":"2026-06-26T08:02:53.611+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.0ms)","duration":"2024.0ms","level":"slow","span":"829eb16def8ab80b","trace":"2de68f806c81f023e105c3b912780375"}
-{"@timestamp":"2026-06-26T08:02:53.611+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.0ms","level":"info","span":"829eb16def8ab80b","trace":"2de68f806c81f023e105c3b912780375"}
-{"@timestamp":"2026-06-26T08:02:58.641+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.2ms)","duration":"2027.2ms","level":"slow","span":"ec8eaa89c64ff513","trace":"161699c0c8e51948132247ffda5caedd"}
-{"@timestamp":"2026-06-26T08:02:58.641+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.2ms","level":"info","span":"ec8eaa89c64ff513","trace":"161699c0c8e51948132247ffda5caedd"}
-{"@timestamp":"2026-06-26T08:02:59.825+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=72.1Mi, Sys=19.6Mi, NumGC=40","level":"stat"}
-{"@timestamp":"2026-06-26T08:02:59.842+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T08:03:02.456+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2048.6ms, med: 2051.3ms, 90th: 2103.6ms, 99th: 2103.6ms, 99.9th: 2103.6ms","level":"stat"}
-{"@timestamp":"2026-06-26T08:03:03.686+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2042.8ms)","duration":"2042.8ms","level":"slow","span":"3a1a84758f646ab5","trace":"dcf9c33cc9cc26b13a0fcc9e1487e378"}
-{"@timestamp":"2026-06-26T08:03:03.686+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2042.8ms","level":"info","span":"3a1a84758f646ab5","trace":"dcf9c33cc9cc26b13a0fcc9e1487e378"}
-{"@timestamp":"2026-06-26T08:03:08.744+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2054.8ms)","duration":"2054.8ms","level":"slow","span":"cb1f9a16cdbb727f","trace":"a447c4a0e4d5651159e1059f798040c8"}
-{"@timestamp":"2026-06-26T08:03:08.744+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2054.8ms","level":"info","span":"cb1f9a16cdbb727f","trace":"a447c4a0e4d5651159e1059f798040c8"}
-{"@timestamp":"2026-06-26T08:03:13.823+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2075.6ms)","duration":"2075.6ms","level":"slow","span":"f57559e8a86aa1f6","trace":"fed5926b1c07705735b9cf57778b9acc"}
-{"@timestamp":"2026-06-26T08:03:13.824+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2075.6ms","level":"info","span":"f57559e8a86aa1f6","trace":"fed5926b1c07705735b9cf57778b9acc"}
-{"@timestamp":"2026-06-26T08:03:18.856+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.4ms)","duration":"2030.4ms","level":"slow","span":"ffa062f76f7c3066","trace":"9eb04b888be435ede496fcb96f103380"}
-{"@timestamp":"2026-06-26T08:03:18.857+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.4ms","level":"info","span":"ffa062f76f7c3066","trace":"9eb04b888be435ede496fcb96f103380"}
-{"@timestamp":"2026-06-26T08:03:23.912+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2051.7ms)","duration":"2051.7ms","level":"slow","span":"412ada4f61b81d47","trace":"2e6456ab085b951ed478f714721b7750"}
-{"@timestamp":"2026-06-26T08:03:23.912+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2051.7ms","level":"info","span":"412ada4f61b81d47","trace":"2e6456ab085b951ed478f714721b7750"}
-{"@timestamp":"2026-06-26T08:03:28.954+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2038.8ms)","duration":"2038.8ms","level":"slow","span":"d1b2e915e1e78f13","trace":"6e6bd104cb851c155f4f618ad3c4df6f"}
-{"@timestamp":"2026-06-26T08:03:28.954+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2038.8ms","level":"info","span":"d1b2e915e1e78f13","trace":"6e6bd104cb851c155f4f618ad3c4df6f"}
-{"@timestamp":"2026-06-26T08:03:34.005+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2046.9ms)","duration":"2046.9ms","level":"slow","span":"8ca9a1ad0596893a","trace":"ecb12e6874f40d78bdf2700a697fe649"}
-{"@timestamp":"2026-06-26T08:03:34.005+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2046.9ms","level":"info","span":"8ca9a1ad0596893a","trace":"ecb12e6874f40d78bdf2700a697fe649"}
-{"@timestamp":"2026-06-26T08:03:39.051+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2042.7ms)","duration":"2042.7ms","level":"slow","span":"4f5cf9a908742da2","trace":"542e691c2a423261101b2bc7b94ffd43"}
-{"@timestamp":"2026-06-26T08:03:39.051+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2042.7ms","level":"info","span":"4f5cf9a908742da2","trace":"542e691c2a423261101b2bc7b94ffd43"}
-{"@timestamp":"2026-06-26T08:03:44.094+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2040.2ms)","duration":"2040.2ms","level":"slow","span":"5dc17cb8f6d7dac6","trace":"8fe7a7f9ea43c195af97e64ecbafe2ab"}
-{"@timestamp":"2026-06-26T08:03:44.094+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2040.2ms","level":"info","span":"5dc17cb8f6d7dac6","trace":"8fe7a7f9ea43c195af97e64ecbafe2ab"}
-{"@timestamp":"2026-06-26T08:03:49.143+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.4ms)","duration":"2045.4ms","level":"slow","span":"7069114e3ce27293","trace":"9c6da244ade9d4205e1f8d7dbee92738"}
-{"@timestamp":"2026-06-26T08:03:49.143+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.4ms","level":"info","span":"7069114e3ce27293","trace":"9c6da244ade9d4205e1f8d7dbee92738"}
-{"@timestamp":"2026-06-26T08:03:54.217+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2069.6ms)","duration":"2069.6ms","level":"slow","span":"3350a9f9d044c88d","trace":"0404b024d2fbef3a11b6224f50789b3c"}
-{"@timestamp":"2026-06-26T08:03:54.218+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2069.6ms","level":"info","span":"3350a9f9d044c88d","trace":"0404b024d2fbef3a11b6224f50789b3c"}
-{"@timestamp":"2026-06-26T08:03:59.281+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2060.0ms)","duration":"2060.0ms","level":"slow","span":"6f10f474e48a4432","trace":"3efb4ee42eaa0870c922992292f63003"}
-{"@timestamp":"2026-06-26T08:03:59.281+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2060.0ms","level":"info","span":"6f10f474e48a4432","trace":"3efb4ee42eaa0870c922992292f63003"}
-{"@timestamp":"2026-06-26T08:03:59.825+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=72.3Mi, Sys=19.6Mi, NumGC=40","level":"stat"}
-{"@timestamp":"2026-06-26T08:03:59.842+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T08:04:02.456+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2049.8ms, med: 2046.8ms, 90th: 2075.5ms, 99th: 2075.5ms, 99.9th: 2075.5ms","level":"stat"}
-{"@timestamp":"2026-06-26T08:04:04.348+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2064.9ms)","duration":"2064.9ms","level":"slow","span":"b8450e00566cac97","trace":"6cfe03f54e4e461b4cf8f88837632613"}
-{"@timestamp":"2026-06-26T08:04:04.348+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2064.9ms","level":"info","span":"b8450e00566cac97","trace":"6cfe03f54e4e461b4cf8f88837632613"}
-{"@timestamp":"2026-06-26T08:04:09.402+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2052.1ms)","duration":"2052.1ms","level":"slow","span":"2002aaa171dd85de","trace":"f1f0ca12a312626a151f7df57f0b61b4"}
-{"@timestamp":"2026-06-26T08:04:09.402+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2052.1ms","level":"info","span":"2002aaa171dd85de","trace":"f1f0ca12a312626a151f7df57f0b61b4"}
-{"@timestamp":"2026-06-26T08:04:14.436+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.5ms)","duration":"2030.5ms","level":"slow","span":"c05c649ad6768a70","trace":"427976394a8dee771f171a945c929d64"}
-{"@timestamp":"2026-06-26T08:04:14.436+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.5ms","level":"info","span":"c05c649ad6768a70","trace":"427976394a8dee771f171a945c929d64"}
-{"@timestamp":"2026-06-26T08:04:19.471+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.5ms)","duration":"2031.5ms","level":"slow","span":"c686c5d0abb078e1","trace":"76bfcb761ff2eca32a7a85d0b7704944"}
-{"@timestamp":"2026-06-26T08:04:19.471+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.5ms","level":"info","span":"c686c5d0abb078e1","trace":"76bfcb761ff2eca32a7a85d0b7704944"}
-{"@timestamp":"2026-06-26T08:04:24.549+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2073.1ms)","duration":"2073.1ms","level":"slow","span":"c13ab8a1ad10a585","trace":"8f58cd16cd7606cd52fff6edb0a1e3f1"}
-{"@timestamp":"2026-06-26T08:04:24.549+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2073.1ms","level":"info","span":"c13ab8a1ad10a585","trace":"8f58cd16cd7606cd52fff6edb0a1e3f1"}
-{"@timestamp":"2026-06-26T08:04:29.615+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2060.1ms)","duration":"2060.1ms","level":"slow","span":"8b8fc11df04b43c6","trace":"61302fe459a20e7d59a5421b77538aed"}
-{"@timestamp":"2026-06-26T08:04:29.615+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2060.1ms","level":"info","span":"8b8fc11df04b43c6","trace":"61302fe459a20e7d59a5421b77538aed"}
-{"@timestamp":"2026-06-26T08:04:34.686+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2066.1ms)","duration":"2066.1ms","level":"slow","span":"0f21af9d8be04168","trace":"612af32a54c8aba55492e41b76264d26"}
-{"@timestamp":"2026-06-26T08:04:34.686+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2066.1ms","level":"info","span":"0f21af9d8be04168","trace":"612af32a54c8aba55492e41b76264d26"}
-{"@timestamp":"2026-06-26T08:04:39.735+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.7ms)","duration":"2045.7ms","level":"slow","span":"3baeb7e16153b3e1","trace":"fcea3218062169da719f6ed084134b4b"}
-{"@timestamp":"2026-06-26T08:04:39.735+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.7ms","level":"info","span":"3baeb7e16153b3e1","trace":"fcea3218062169da719f6ed084134b4b"}
-{"@timestamp":"2026-06-26T08:04:44.785+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2046.2ms)","duration":"2046.2ms","level":"slow","span":"ad72f87a282d0b0a","trace":"3e9c657faf9e8d396610dde151b68511"}
-{"@timestamp":"2026-06-26T08:04:44.785+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2046.2ms","level":"info","span":"ad72f87a282d0b0a","trace":"3e9c657faf9e8d396610dde151b68511"}
-{"@timestamp":"2026-06-26T08:04:49.815+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.2ms)","duration":"2027.2ms","level":"slow","span":"b171146dc14fedc3","trace":"090520e2797749b7c499569c948c5eac"}
-{"@timestamp":"2026-06-26T08:04:49.815+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.2ms","level":"info","span":"b171146dc14fedc3","trace":"090520e2797749b7c499569c948c5eac"}
-{"@timestamp":"2026-06-26T08:04:54.881+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2063.1ms)","duration":"2063.1ms","level":"slow","span":"140597be24108d25","trace":"2f2bee01f85577649e908c12629e064c"}
-{"@timestamp":"2026-06-26T08:04:54.881+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2063.1ms","level":"info","span":"140597be24108d25","trace":"2f2bee01f85577649e908c12629e064c"}
-{"@timestamp":"2026-06-26T08:04:59.824+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=72.8Mi, Sys=19.6Mi, NumGC=41","level":"stat"}
-{"@timestamp":"2026-06-26T08:04:59.841+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 11, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T08:04:59.936+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2052.9ms)","duration":"2052.9ms","level":"slow","span":"ad70f2cd1894dc5c","trace":"4b40862613cbaad0a90c4a166f0efe0a"}
-{"@timestamp":"2026-06-26T08:04:59.936+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2052.9ms","level":"info","span":"ad70f2cd1894dc5c","trace":"4b40862613cbaad0a90c4a166f0efe0a"}
-{"@timestamp":"2026-06-26T08:05:02.455+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2051.0ms, med: 2052.8ms, 90th: 2073.0ms, 99th: 2073.0ms, 99.9th: 2073.0ms","level":"stat"}
-{"@timestamp":"2026-06-26T08:05:04.978+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2040.6ms)","duration":"2040.6ms","level":"slow","span":"649797bf07a8d418","trace":"8c60ea54ce3d93afdec3fda088c62425"}
-{"@timestamp":"2026-06-26T08:05:04.978+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2040.6ms","level":"info","span":"649797bf07a8d418","trace":"8c60ea54ce3d93afdec3fda088c62425"}
-{"@timestamp":"2026-06-26T08:05:10.036+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2055.8ms)","duration":"2055.8ms","level":"slow","span":"db9126f70d9093df","trace":"d6646d4030afeef6c1a796c51d753195"}
-{"@timestamp":"2026-06-26T08:05:10.036+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2055.8ms","level":"info","span":"db9126f70d9093df","trace":"d6646d4030afeef6c1a796c51d753195"}
-{"@timestamp":"2026-06-26T08:05:15.078+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2040.7ms)","duration":"2040.7ms","level":"slow","span":"0c3a1acf78a612df","trace":"b54aef5bf21b0b723f60d2a0a3fb2ca3"}
-{"@timestamp":"2026-06-26T08:05:15.079+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2040.7ms","level":"info","span":"0c3a1acf78a612df","trace":"b54aef5bf21b0b723f60d2a0a3fb2ca3"}
-{"@timestamp":"2026-06-26T08:05:20.098+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2017.1ms)","duration":"2017.1ms","level":"slow","span":"b88b62369f5e3981","trace":"633d7941e1c024fd15da048a6abd3c24"}
-{"@timestamp":"2026-06-26T08:05:20.098+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2017.1ms","level":"info","span":"b88b62369f5e3981","trace":"633d7941e1c024fd15da048a6abd3c24"}
-{"@timestamp":"2026-06-26T08:05:25.150+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2048.3ms)","duration":"2048.3ms","level":"slow","span":"0cb33a1256c25d2e","trace":"6b93172e9392ba5e68c3b5d59c960723"}
-{"@timestamp":"2026-06-26T08:05:25.150+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2048.3ms","level":"info","span":"0cb33a1256c25d2e","trace":"6b93172e9392ba5e68c3b5d59c960723"}
-{"@timestamp":"2026-06-26T08:05:30.206+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2053.1ms)","duration":"2053.1ms","level":"slow","span":"537f1868ce5a8b0c","trace":"d092995ae0b0406fbf6a50d3dd4bbd30"}
-{"@timestamp":"2026-06-26T08:05:30.206+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2053.1ms","level":"info","span":"537f1868ce5a8b0c","trace":"d092995ae0b0406fbf6a50d3dd4bbd30"}
-{"@timestamp":"2026-06-26T08:05:35.249+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2041.2ms)","duration":"2041.2ms","level":"slow","span":"aece937e047fa054","trace":"a4b4a6a5280810f89aec53283f9d6162"}
-{"@timestamp":"2026-06-26T08:05:35.249+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2041.2ms","level":"info","span":"aece937e047fa054","trace":"a4b4a6a5280810f89aec53283f9d6162"}
-{"@timestamp":"2026-06-26T08:05:40.305+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2053.8ms)","duration":"2053.8ms","level":"slow","span":"7b790dafec185fd2","trace":"a89f4855076f2dd8e4adb1f934051d4c"}
-{"@timestamp":"2026-06-26T08:05:40.305+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2053.8ms","level":"info","span":"7b790dafec185fd2","trace":"a89f4855076f2dd8e4adb1f934051d4c"}
-{"@timestamp":"2026-06-26T08:05:45.359+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2051.7ms)","duration":"2051.7ms","level":"slow","span":"adc8194ca59de677","trace":"e0873e90c2cd5c5c3c0a32711a12f1da"}
-{"@timestamp":"2026-06-26T08:05:45.359+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2051.7ms","level":"info","span":"adc8194ca59de677","trace":"e0873e90c2cd5c5c3c0a32711a12f1da"}
-{"@timestamp":"2026-06-26T08:05:50.413+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2050.7ms)","duration":"2050.7ms","level":"slow","span":"157e0923be0688b9","trace":"384e111edb5c826d526a1cf25c543cb2"}
-{"@timestamp":"2026-06-26T08:05:50.413+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2050.7ms","level":"info","span":"157e0923be0688b9","trace":"384e111edb5c826d526a1cf25c543cb2"}
-{"@timestamp":"2026-06-26T08:05:55.455+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2038.3ms)","duration":"2038.3ms","level":"slow","span":"85ff31ffc1797005","trace":"63735b5d24ee87d5d9930b880c8e54c6"}
-{"@timestamp":"2026-06-26T08:05:55.455+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2038.3ms","level":"info","span":"85ff31ffc1797005","trace":"63735b5d24ee87d5d9930b880c8e54c6"}
-{"@timestamp":"2026-06-26T08:05:59.824+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.6Mi, TotalAlloc=73.0Mi, Sys=19.6Mi, NumGC=41","level":"stat"}
-{"@timestamp":"2026-06-26T08:05:59.840+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T08:06:00.502+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.0ms)","duration":"2043.0ms","level":"slow","span":"6a9e1f7359638a41","trace":"c95ec87cedec00a7ac1b5e2204bb78f2"}
-{"@timestamp":"2026-06-26T08:06:00.502+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.0ms","level":"info","span":"6a9e1f7359638a41","trace":"c95ec87cedec00a7ac1b5e2204bb78f2"}
-{"@timestamp":"2026-06-26T08:06:02.455+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2044.4ms, med: 2048.1ms, 90th: 2055.7ms, 99th: 2055.7ms, 99.9th: 2055.7ms","level":"stat"}
-{"@timestamp":"2026-06-26T08:06:05.560+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2055.4ms)","duration":"2055.4ms","level":"slow","span":"2e1ffcf95d995d4b","trace":"9567435ea6d13ffe1e7503f9b3bf56f6"}
-{"@timestamp":"2026-06-26T08:06:05.560+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2055.4ms","level":"info","span":"2e1ffcf95d995d4b","trace":"9567435ea6d13ffe1e7503f9b3bf56f6"}
-{"@timestamp":"2026-06-26T08:06:10.587+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.4ms)","duration":"2021.4ms","level":"slow","span":"20cc6b94da2c04fb","trace":"fb345ba50aa46ac4115fd2bbcd2512a8"}
-{"@timestamp":"2026-06-26T08:06:10.587+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.4ms","level":"info","span":"20cc6b94da2c04fb","trace":"fb345ba50aa46ac4115fd2bbcd2512a8"}
-{"@timestamp":"2026-06-26T08:06:15.631+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2040.0ms)","duration":"2040.0ms","level":"slow","span":"c9687d48b00a1877","trace":"41fdb05ae6a5f7a2471e83da699f7b7e"}
-{"@timestamp":"2026-06-26T08:06:15.631+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2040.0ms","level":"info","span":"c9687d48b00a1877","trace":"41fdb05ae6a5f7a2471e83da699f7b7e"}
-{"@timestamp":"2026-06-26T08:06:20.693+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2058.8ms)","duration":"2058.8ms","level":"slow","span":"e9040e5ca4f3e0be","trace":"585050738287c8e9caa2dce1a63a8947"}
-{"@timestamp":"2026-06-26T08:06:20.693+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2058.8ms","level":"info","span":"e9040e5ca4f3e0be","trace":"585050738287c8e9caa2dce1a63a8947"}
-{"@timestamp":"2026-06-26T08:06:25.753+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2057.0ms)","duration":"2057.0ms","level":"slow","span":"765240942ad8cd65","trace":"d9ec776223e1a70a3adc485b69c59a57"}
-{"@timestamp":"2026-06-26T08:06:25.754+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2057.0ms","level":"info","span":"765240942ad8cd65","trace":"d9ec776223e1a70a3adc485b69c59a57"}
-{"@timestamp":"2026-06-26T08:06:30.814+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2059.1ms)","duration":"2059.1ms","level":"slow","span":"e30094d7006c86b0","trace":"1083cdc6c3ce522b3798c4a7524b2ce8"}
-{"@timestamp":"2026-06-26T08:06:30.815+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2059.1ms","level":"info","span":"e30094d7006c86b0","trace":"1083cdc6c3ce522b3798c4a7524b2ce8"}
-{"@timestamp":"2026-06-26T08:06:35.887+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2069.5ms)","duration":"2069.5ms","level":"slow","span":"6224ff851a295ad1","trace":"5ba2560eea1fd728c9017e36d41cdccf"}
-{"@timestamp":"2026-06-26T08:06:35.888+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2069.5ms","level":"info","span":"6224ff851a295ad1","trace":"5ba2560eea1fd728c9017e36d41cdccf"}
-{"@timestamp":"2026-06-26T08:06:40.933+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2041.7ms)","duration":"2041.7ms","level":"slow","span":"2a9d4949547ce2b8","trace":"8a483bcafb129dec7b7e2ee57ea5677e"}
-{"@timestamp":"2026-06-26T08:06:40.933+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2041.7ms","level":"info","span":"2a9d4949547ce2b8","trace":"8a483bcafb129dec7b7e2ee57ea5677e"}
-{"@timestamp":"2026-06-26T08:06:45.973+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2036.6ms)","duration":"2036.6ms","level":"slow","span":"2c2c8b3441bf1694","trace":"63c26f172b507973eb000d76a94ad6b4"}
-{"@timestamp":"2026-06-26T08:06:45.973+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2036.6ms","level":"info","span":"2c2c8b3441bf1694","trace":"63c26f172b507973eb000d76a94ad6b4"}
-{"@timestamp":"2026-06-26T08:06:51.033+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2056.8ms)","duration":"2056.8ms","level":"slow","span":"bfe9de318373ad1b","trace":"23a5783cf195e565585bc1bec07f1f39"}
-{"@timestamp":"2026-06-26T08:06:51.033+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2056.8ms","level":"info","span":"bfe9de318373ad1b","trace":"23a5783cf195e565585bc1bec07f1f39"}
-{"@timestamp":"2026-06-26T08:06:56.041+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2004.6ms)","duration":"2004.6ms","level":"slow","span":"fff43aee5a524f61","trace":"fa14418e5e7640d2968094382401bc83"}
-{"@timestamp":"2026-06-26T08:06:56.041+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2004.6ms","level":"info","span":"fff43aee5a524f61","trace":"fa14418e5e7640d2968094382401bc83"}
-{"@timestamp":"2026-06-26T08:06:59.823+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=73.5Mi, Sys=19.6Mi, NumGC=42","level":"stat"}
-{"@timestamp":"2026-06-26T08:06:59.840+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T08:07:01.111+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2066.6ms)","duration":"2066.6ms","level":"slow","span":"6fbb0b0f65e33fa2","trace":"b8eb818947508510b80c1d38a11e9807"}
-{"@timestamp":"2026-06-26T08:07:01.111+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2066.6ms","level":"info","span":"6fbb0b0f65e33fa2","trace":"b8eb818947508510b80c1d38a11e9807"}
-{"@timestamp":"2026-06-26T08:07:02.453+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2047.2ms, med: 2056.7ms, 90th: 2069.4ms, 99th: 2069.4ms, 99.9th: 2069.4ms","level":"stat"}
-{"@timestamp":"2026-06-26T08:07:06.144+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.3ms)","duration":"2029.3ms","level":"slow","span":"8bc6240b994b59f4","trace":"493b94baafb424fb1f9d44ca735f5d8a"}
-{"@timestamp":"2026-06-26T08:07:06.144+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.3ms","level":"info","span":"8bc6240b994b59f4","trace":"493b94baafb424fb1f9d44ca735f5d8a"}
-{"@timestamp":"2026-06-26T08:07:11.245+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2097.5ms)","duration":"2097.5ms","level":"slow","span":"1d9588cf77e74643","trace":"56ef18200cf9319cdd9ff73d703265c4"}
-{"@timestamp":"2026-06-26T08:07:11.245+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2097.5ms","level":"info","span":"1d9588cf77e74643","trace":"56ef18200cf9319cdd9ff73d703265c4"}
-{"@timestamp":"2026-06-26T08:07:16.295+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2047.1ms)","duration":"2047.1ms","level":"slow","span":"331f0d2809e2715d","trace":"47089706ef52f2234f15c9707478d3d3"}
-{"@timestamp":"2026-06-26T08:07:16.295+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2047.1ms","level":"info","span":"331f0d2809e2715d","trace":"47089706ef52f2234f15c9707478d3d3"}
-{"@timestamp":"2026-06-26T08:07:21.329+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.6ms)","duration":"2030.6ms","level":"slow","span":"bec07c46883388dd","trace":"3da492574e480e836950e94ec7c2362b"}
-{"@timestamp":"2026-06-26T08:07:21.329+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.6ms","level":"info","span":"bec07c46883388dd","trace":"3da492574e480e836950e94ec7c2362b"}
-{"@timestamp":"2026-06-26T08:07:26.376+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.0ms)","duration":"2044.0ms","level":"slow","span":"17b9c35d214a4d4a","trace":"21385ee7136f096469f16402bd63dda7"}
-{"@timestamp":"2026-06-26T08:07:26.376+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.0ms","level":"info","span":"17b9c35d214a4d4a","trace":"21385ee7136f096469f16402bd63dda7"}
-{"@timestamp":"2026-06-26T08:07:31.403+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.6ms)","duration":"2023.6ms","level":"slow","span":"b12c71caaf24ece3","trace":"7bffbf786fa455eb17903eb4a644e236"}
-{"@timestamp":"2026-06-26T08:07:31.403+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.6ms","level":"info","span":"b12c71caaf24ece3","trace":"7bffbf786fa455eb17903eb4a644e236"}
-{"@timestamp":"2026-06-26T08:07:36.451+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.5ms)","duration":"2044.5ms","level":"slow","span":"d87719ebfaba155c","trace":"7be5f77327a5a972b552893569d01651"}
-{"@timestamp":"2026-06-26T08:07:36.451+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.5ms","level":"info","span":"d87719ebfaba155c","trace":"7be5f77327a5a972b552893569d01651"}
-{"@timestamp":"2026-06-26T08:07:41.512+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2057.5ms)","duration":"2057.5ms","level":"slow","span":"6120b143d364c86b","trace":"31b0b035e06d491f2edd531c2b51c67c"}
-{"@timestamp":"2026-06-26T08:07:41.512+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2057.5ms","level":"info","span":"6120b143d364c86b","trace":"31b0b035e06d491f2edd531c2b51c67c"}
-{"@timestamp":"2026-06-26T08:07:46.559+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.3ms)","duration":"2044.3ms","level":"slow","span":"08fe8b50e8cfe87e","trace":"13e363d05cc6a6b2b8350b843d70d199"}
-{"@timestamp":"2026-06-26T08:07:46.559+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.3ms","level":"info","span":"08fe8b50e8cfe87e","trace":"13e363d05cc6a6b2b8350b843d70d199"}
-{"@timestamp":"2026-06-26T08:07:51.610+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2046.6ms)","duration":"2046.6ms","level":"slow","span":"ba504743568f5005","trace":"943de084107f59230324f8d20ce0774d"}
-{"@timestamp":"2026-06-26T08:07:51.610+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2046.6ms","level":"info","span":"ba504743568f5005","trace":"943de084107f59230324f8d20ce0774d"}
-{"@timestamp":"2026-06-26T08:07:56.648+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.6ms)","duration":"2034.6ms","level":"slow","span":"fdb4e63bc19eb672","trace":"4e63b6ab5ffc68e5dac6a1246661238a"}
-{"@timestamp":"2026-06-26T08:07:56.648+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.6ms","level":"info","span":"fdb4e63bc19eb672","trace":"4e63b6ab5ffc68e5dac6a1246661238a"}
-{"@timestamp":"2026-06-26T08:07:59.822+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=73.8Mi, Sys=19.6Mi, NumGC=42","level":"stat"}
-{"@timestamp":"2026-06-26T08:07:59.839+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T08:08:01.687+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.5ms)","duration":"2035.5ms","level":"slow","span":"bb1eae79252bec5f","trace":"8e41794e193dbbb02ae30744a3e96f53"}
-{"@timestamp":"2026-06-26T08:08:01.687+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.5ms","level":"info","span":"bb1eae79252bec5f","trace":"8e41794e193dbbb02ae30744a3e96f53"}
-{"@timestamp":"2026-06-26T08:08:02.453+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2044.5ms, med: 2044.2ms, 90th: 2097.4ms, 99th: 2097.4ms, 99.9th: 2097.4ms","level":"stat"}
-{"@timestamp":"2026-06-26T08:08:06.748+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2057.9ms)","duration":"2057.9ms","level":"slow","span":"f28bbbab8c410564","trace":"96a2ea2e4903066b7ef148b70f6cec28"}
-{"@timestamp":"2026-06-26T08:08:06.748+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2057.9ms","level":"info","span":"f28bbbab8c410564","trace":"96a2ea2e4903066b7ef148b70f6cec28"}
-{"@timestamp":"2026-06-26T08:08:11.778+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2024.1ms)","duration":"2024.1ms","level":"slow","span":"543510e9687be804","trace":"127ee8314953ec1b2ebe36d8e67f6ea2"}
-{"@timestamp":"2026-06-26T08:08:11.778+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2024.1ms","level":"info","span":"543510e9687be804","trace":"127ee8314953ec1b2ebe36d8e67f6ea2"}
-{"@timestamp":"2026-06-26T08:08:16.790+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2008.1ms)","duration":"2008.1ms","level":"slow","span":"9c40199b19ca0191","trace":"be1d5e0f16555d4288382fc286064bb3"}
-{"@timestamp":"2026-06-26T08:08:16.790+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2008.1ms","level":"info","span":"9c40199b19ca0191","trace":"be1d5e0f16555d4288382fc286064bb3"}
-{"@timestamp":"2026-06-26T08:08:21.836+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2042.3ms)","duration":"2042.3ms","level":"slow","span":"4ebc9a9a382a5bd7","trace":"cd465a67564c039011ee20b46d2fcf79"}
-{"@timestamp":"2026-06-26T08:08:21.836+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2042.3ms","level":"info","span":"4ebc9a9a382a5bd7","trace":"cd465a67564c039011ee20b46d2fcf79"}
-{"@timestamp":"2026-06-26T08:08:26.873+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.3ms)","duration":"2034.3ms","level":"slow","span":"3cef22feb9fd1416","trace":"fc6be8046d34b9b2ced8239e0c568288"}
-{"@timestamp":"2026-06-26T08:08:26.874+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.3ms","level":"info","span":"3cef22feb9fd1416","trace":"fc6be8046d34b9b2ced8239e0c568288"}
-{"@timestamp":"2026-06-26T08:08:31.912+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.0ms)","duration":"2035.0ms","level":"slow","span":"95f07bc5766fe0b3","trace":"e73159697f3faf4974997794fa44eee1"}
-{"@timestamp":"2026-06-26T08:08:31.912+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.0ms","level":"info","span":"95f07bc5766fe0b3","trace":"e73159697f3faf4974997794fa44eee1"}
-{"@timestamp":"2026-06-26T08:08:36.938+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2022.2ms)","duration":"2022.2ms","level":"slow","span":"53fab1597f99ad52","trace":"1bf370d83a4b797dceef9d67b53d4114"}
-{"@timestamp":"2026-06-26T08:08:36.938+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2022.2ms","level":"info","span":"53fab1597f99ad52","trace":"1bf370d83a4b797dceef9d67b53d4114"}
-{"@timestamp":"2026-06-26T08:08:42.016+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2076.1ms)","duration":"2076.1ms","level":"slow","span":"67923a5f590cd5b4","trace":"b7ea575d60b62b04ecacd1d2faffd6c0"}
-{"@timestamp":"2026-06-26T08:08:42.016+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2076.1ms","level":"info","span":"67923a5f590cd5b4","trace":"b7ea575d60b62b04ecacd1d2faffd6c0"}
-{"@timestamp":"2026-06-26T08:08:47.061+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2042.3ms)","duration":"2042.3ms","level":"slow","span":"6bcffb7e6c1e747c","trace":"50a11b61ed0fba1c800b57e56b3b20a4"}
-{"@timestamp":"2026-06-26T08:08:47.061+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2042.3ms","level":"info","span":"6bcffb7e6c1e747c","trace":"50a11b61ed0fba1c800b57e56b3b20a4"}
-{"@timestamp":"2026-06-26T08:08:52.128+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2063.2ms)","duration":"2063.2ms","level":"slow","span":"078ec80fa4359c64","trace":"57ccb3cc814a9de2d844378e9bbaec92"}
-{"@timestamp":"2026-06-26T08:08:52.128+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2063.2ms","level":"info","span":"078ec80fa4359c64","trace":"57ccb3cc814a9de2d844378e9bbaec92"}
-{"@timestamp":"2026-06-26T08:08:57.153+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.9ms)","duration":"2021.9ms","level":"slow","span":"225ae14da79029f3","trace":"fd5cbb9efe60ab1d624533ab1a4a1f38"}
-{"@timestamp":"2026-06-26T08:08:57.153+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.9ms","level":"info","span":"225ae14da79029f3","trace":"fd5cbb9efe60ab1d624533ab1a4a1f38"}
-{"@timestamp":"2026-06-26T08:08:59.822+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=74.3Mi, Sys=19.6Mi, NumGC=43","level":"stat"}
-{"@timestamp":"2026-06-26T08:08:59.839+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T08:09:02.203+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.8ms)","duration":"2045.8ms","level":"slow","span":"01d7149c9c8367d6","trace":"750454c5f2070018974ffe4c46c1d409"}
-{"@timestamp":"2026-06-26T08:09:02.203+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.8ms","level":"info","span":"01d7149c9c8367d6","trace":"750454c5f2070018974ffe4c46c1d409"}
-{"@timestamp":"2026-06-26T08:09:02.452+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2039.2ms, med: 2042.2ms, 90th: 2076.0ms, 99th: 2076.0ms, 99.9th: 2076.0ms","level":"stat"}
-{"@timestamp":"2026-06-26T08:09:07.223+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2017.0ms)","duration":"2017.0ms","level":"slow","span":"1b29735426ec7234","trace":"527fc39718e7179e2ebb905ca7b69a01"}
-{"@timestamp":"2026-06-26T08:09:07.223+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2017.0ms","level":"info","span":"1b29735426ec7234","trace":"527fc39718e7179e2ebb905ca7b69a01"}
-{"@timestamp":"2026-06-26T08:09:12.258+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.5ms)","duration":"2032.5ms","level":"slow","span":"190b1d93ae785cbd","trace":"58e35636229ac1b6b77d89ba5c496166"}
-{"@timestamp":"2026-06-26T08:09:12.258+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.5ms","level":"info","span":"190b1d93ae785cbd","trace":"58e35636229ac1b6b77d89ba5c496166"}
-{"@timestamp":"2026-06-26T08:09:17.320+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2058.1ms)","duration":"2058.1ms","level":"slow","span":"ed2674f3dbfe6062","trace":"feb54eb72e618d01a705008e08df8bc4"}
-{"@timestamp":"2026-06-26T08:09:17.320+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2058.1ms","level":"info","span":"ed2674f3dbfe6062","trace":"feb54eb72e618d01a705008e08df8bc4"}
-{"@timestamp":"2026-06-26T08:09:22.394+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2070.7ms)","duration":"2070.7ms","level":"slow","span":"63d431ed8bea16c1","trace":"9b99b4abfe735d2ed57d513fc396158a"}
-{"@timestamp":"2026-06-26T08:09:22.394+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2070.7ms","level":"info","span":"63d431ed8bea16c1","trace":"9b99b4abfe735d2ed57d513fc396158a"}
-{"@timestamp":"2026-06-26T08:09:27.425+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.5ms)","duration":"2028.5ms","level":"slow","span":"985340e312e713c1","trace":"5ec8138bad3fb3f1083848d8578f1c55"}
-{"@timestamp":"2026-06-26T08:09:27.425+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.5ms","level":"info","span":"985340e312e713c1","trace":"5ec8138bad3fb3f1083848d8578f1c55"}
-{"@timestamp":"2026-06-26T08:09:32.483+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2054.5ms)","duration":"2054.5ms","level":"slow","span":"75933b4bbb582ed9","trace":"749d57199cd8ac71997f77cc72bc1612"}
-{"@timestamp":"2026-06-26T08:09:32.483+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2054.5ms","level":"info","span":"75933b4bbb582ed9","trace":"749d57199cd8ac71997f77cc72bc1612"}
-{"@timestamp":"2026-06-26T08:09:37.510+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2023.5ms)","duration":"2023.5ms","level":"slow","span":"1b86503b452cebbe","trace":"556ed094ed620f4097c1733bdc85a3b3"}
-{"@timestamp":"2026-06-26T08:09:37.510+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2023.5ms","level":"info","span":"1b86503b452cebbe","trace":"556ed094ed620f4097c1733bdc85a3b3"}
-{"@timestamp":"2026-06-26T08:09:42.576+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2062.4ms)","duration":"2062.4ms","level":"slow","span":"2734167759b93ccc","trace":"c6fe03aaa57a81bf10e5dcc56405930d"}
-{"@timestamp":"2026-06-26T08:09:42.576+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2062.4ms","level":"info","span":"2734167759b93ccc","trace":"c6fe03aaa57a81bf10e5dcc56405930d"}
-{"@timestamp":"2026-06-26T08:09:47.595+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.2ms)","duration":"2016.2ms","level":"slow","span":"065b7262225ff87f","trace":"a7f35d626251d038310597077f1785a4"}
-{"@timestamp":"2026-06-26T08:09:47.595+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.2ms","level":"info","span":"065b7262225ff87f","trace":"a7f35d626251d038310597077f1785a4"}
-{"@timestamp":"2026-06-26T08:09:52.647+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2048.4ms)","duration":"2048.4ms","level":"slow","span":"3e9b4e13c0f6dfc2","trace":"cb28da1f51c72118f46e674f28bdb732"}
-{"@timestamp":"2026-06-26T08:09:52.647+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2048.4ms","level":"info","span":"3e9b4e13c0f6dfc2","trace":"cb28da1f51c72118f46e674f28bdb732"}
-{"@timestamp":"2026-06-26T08:09:57.741+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2092.4ms)","duration":"2092.4ms","level":"slow","span":"e9047c55df19513c","trace":"afe6ae366d96ca947c50c1e413b6d585"}
-{"@timestamp":"2026-06-26T08:09:57.742+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2092.4ms","level":"info","span":"e9047c55df19513c","trace":"afe6ae366d96ca947c50c1e413b6d585"}
-{"@timestamp":"2026-06-26T08:09:59.821+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=74.5Mi, Sys=19.6Mi, NumGC=43","level":"stat"}
-{"@timestamp":"2026-06-26T08:09:59.838+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T08:10:02.452+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2045.7ms, med: 2054.4ms, 90th: 2092.3ms, 99th: 2092.3ms, 99.9th: 2092.3ms","level":"stat"}
-{"@timestamp":"2026-06-26T08:10:02.795+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2048.2ms)","duration":"2048.2ms","level":"slow","span":"da6e73471cfe3f16","trace":"2800deb562fb39f4a8b16e8fe5334ccc"}
-{"@timestamp":"2026-06-26T08:10:02.795+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2048.2ms","level":"info","span":"da6e73471cfe3f16","trace":"2800deb562fb39f4a8b16e8fe5334ccc"}
-{"@timestamp":"2026-06-26T08:10:07.846+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2047.8ms)","duration":"2047.8ms","level":"slow","span":"d979c5725dd7b4b3","trace":"05639994871082309c064ee345b51d22"}
-{"@timestamp":"2026-06-26T08:10:07.846+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2047.8ms","level":"info","span":"d979c5725dd7b4b3","trace":"05639994871082309c064ee345b51d22"}
-{"@timestamp":"2026-06-26T08:10:12.905+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2056.2ms)","duration":"2056.2ms","level":"slow","span":"d511a646d6502250","trace":"dea3b5d6ec4bf6a243bbb948b89827a4"}
-{"@timestamp":"2026-06-26T08:10:12.905+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2056.2ms","level":"info","span":"d511a646d6502250","trace":"dea3b5d6ec4bf6a243bbb948b89827a4"}
-{"@timestamp":"2026-06-26T08:10:17.961+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2053.5ms)","duration":"2053.5ms","level":"slow","span":"3b45743587efa2b8","trace":"9b13f278e0f69d6ea9dde136920ee8ab"}
-{"@timestamp":"2026-06-26T08:10:17.961+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2053.5ms","level":"info","span":"3b45743587efa2b8","trace":"9b13f278e0f69d6ea9dde136920ee8ab"}
-{"@timestamp":"2026-06-26T08:10:22.990+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.1ms)","duration":"2027.1ms","level":"slow","span":"b3610d0824d1ac47","trace":"adbf1289262821f9d4e7caf3335e0af7"}
-{"@timestamp":"2026-06-26T08:10:22.991+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.1ms","level":"info","span":"b3610d0824d1ac47","trace":"adbf1289262821f9d4e7caf3335e0af7"}
-{"@timestamp":"2026-06-26T08:10:28.062+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2067.5ms)","duration":"2067.5ms","level":"slow","span":"84ecfee11c890a62","trace":"ad750b0651514b1d76d46fe12cd90b70"}
-{"@timestamp":"2026-06-26T08:10:28.062+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2067.5ms","level":"info","span":"84ecfee11c890a62","trace":"ad750b0651514b1d76d46fe12cd90b70"}
-{"@timestamp":"2026-06-26T08:10:33.106+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2041.5ms)","duration":"2041.5ms","level":"slow","span":"bdcec73455274d38","trace":"704c575953570edc89f23efa3779e9d6"}
-{"@timestamp":"2026-06-26T08:10:33.107+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2041.5ms","level":"info","span":"bdcec73455274d38","trace":"704c575953570edc89f23efa3779e9d6"}
-{"@timestamp":"2026-06-26T08:10:38.143+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.6ms)","duration":"2032.6ms","level":"slow","span":"53819c9c6080e1be","trace":"46452b8f18d776c0bd2727779af3c194"}
-{"@timestamp":"2026-06-26T08:10:38.143+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.6ms","level":"info","span":"53819c9c6080e1be","trace":"46452b8f18d776c0bd2727779af3c194"}
-{"@timestamp":"2026-06-26T08:10:43.185+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2038.7ms)","duration":"2038.7ms","level":"slow","span":"29e086dc76c067ac","trace":"49cfe54deec57d6b4ac23a5d822bc4e6"}
-{"@timestamp":"2026-06-26T08:10:43.186+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2038.7ms","level":"info","span":"29e086dc76c067ac","trace":"49cfe54deec57d6b4ac23a5d822bc4e6"}
-{"@timestamp":"2026-06-26T08:10:48.251+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2062.6ms)","duration":"2062.6ms","level":"slow","span":"ec84523bb08cb341","trace":"da40763716d08099320ebf267a9a6464"}
-{"@timestamp":"2026-06-26T08:10:48.252+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2062.6ms","level":"info","span":"ec84523bb08cb341","trace":"da40763716d08099320ebf267a9a6464"}
-{"@timestamp":"2026-06-26T08:10:53.324+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2069.6ms)","duration":"2069.6ms","level":"slow","span":"fa497a99b586d58c","trace":"335e494b43d6dbfdbe2fde16736ae4ed"}
-{"@timestamp":"2026-06-26T08:10:53.324+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2069.6ms","level":"info","span":"fa497a99b586d58c","trace":"335e494b43d6dbfdbe2fde16736ae4ed"}
-{"@timestamp":"2026-06-26T08:10:58.369+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2041.7ms)","duration":"2041.7ms","level":"slow","span":"c9f2367cdf2b4e18","trace":"b01b127ab465d6aefd0814d844e246c5"}
-{"@timestamp":"2026-06-26T08:10:58.370+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2041.7ms","level":"info","span":"c9f2367cdf2b4e18","trace":"b01b127ab465d6aefd0814d844e246c5"}
-{"@timestamp":"2026-06-26T08:10:59.821+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=74.9Mi, Sys=19.6Mi, NumGC=44","level":"stat"}
-{"@timestamp":"2026-06-26T08:10:59.838+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T08:11:12.182+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2048.8ms, med: 2048.2ms, 90th: 2069.5ms, 99th: 2069.5ms, 99.9th: 2069.5ms","level":"stat"}
-{"@timestamp":"2026-06-26T08:11:13.137+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(11764.0ms)","duration":"11764.0ms","level":"slow","span":"765349062a528c4a","trace":"e215ac4281650b30f355d7235cc4b555"}
-{"@timestamp":"2026-06-26T08:11:13.137+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"11764.0ms","level":"info","span":"765349062a528c4a","trace":"e215ac4281650b30f355d7235cc4b555"}
-{"@timestamp":"2026-06-26T08:11:18.235+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2092.7ms)","duration":"2092.7ms","level":"slow","span":"29ff10d18f6063b3","trace":"03b6736725145ab41e8505e8214d803f"}
-{"@timestamp":"2026-06-26T08:11:18.235+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2092.7ms","level":"info","span":"29ff10d18f6063b3","trace":"03b6736725145ab41e8505e8214d803f"}
-{"@timestamp":"2026-06-26T08:11:23.287+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2047.6ms)","duration":"2047.6ms","level":"slow","span":"cbff373590b8f309","trace":"251b7ddba94c930484d27292a1b57717"}
-{"@timestamp":"2026-06-26T08:11:23.287+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2047.6ms","level":"info","span":"cbff373590b8f309","trace":"251b7ddba94c930484d27292a1b57717"}
-{"@timestamp":"2026-06-26T08:11:28.303+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2010.1ms)","duration":"2010.1ms","level":"slow","span":"9f9c7f52e87ed8cc","trace":"836911065c89114893086e100621ae78"}
-{"@timestamp":"2026-06-26T08:11:28.303+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2010.1ms","level":"info","span":"9f9c7f52e87ed8cc","trace":"836911065c89114893086e100621ae78"}
-{"@timestamp":"2026-06-26T08:11:33.379+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2071.0ms)","duration":"2071.0ms","level":"slow","span":"1a43b8a5efa968ce","trace":"833eed8da8a2d38d9c8079f854ed98ad"}
-{"@timestamp":"2026-06-26T08:11:33.379+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2071.0ms","level":"info","span":"1a43b8a5efa968ce","trace":"833eed8da8a2d38d9c8079f854ed98ad"}
-{"@timestamp":"2026-06-26T08:11:38.421+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2036.7ms)","duration":"2036.7ms","level":"slow","span":"c2499780ac62f6d8","trace":"3851b08fad32dd632dcf70f1278faa66"}
-{"@timestamp":"2026-06-26T08:11:38.421+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2036.7ms","level":"info","span":"c2499780ac62f6d8","trace":"3851b08fad32dd632dcf70f1278faa66"}
-{"@timestamp":"2026-06-26T08:11:43.488+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2061.0ms)","duration":"2061.0ms","level":"slow","span":"c1bcb140bf26b439","trace":"16e9293de3ca8a9dc15501e04aa566cc"}
-{"@timestamp":"2026-06-26T08:11:43.488+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2061.0ms","level":"info","span":"c1bcb140bf26b439","trace":"16e9293de3ca8a9dc15501e04aa566cc"}
-{"@timestamp":"2026-06-26T08:11:48.531+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2037.3ms)","duration":"2037.3ms","level":"slow","span":"5849281bc936572c","trace":"8efcb6295f11761fbb70e240f1db6f6a"}
-{"@timestamp":"2026-06-26T08:11:48.531+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2037.3ms","level":"info","span":"5849281bc936572c","trace":"8efcb6295f11761fbb70e240f1db6f6a"}
-{"@timestamp":"2026-06-26T08:11:53.623+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2087.5ms)","duration":"2087.5ms","level":"slow","span":"f8706736b6751767","trace":"702bad5a3bf5c91918d0a59486509d24"}
-{"@timestamp":"2026-06-26T08:11:53.623+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2087.5ms","level":"info","span":"f8706736b6751767","trace":"702bad5a3bf5c91918d0a59486509d24"}
-{"@timestamp":"2026-06-26T08:11:58.703+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2074.6ms)","duration":"2074.6ms","level":"slow","span":"f1364ab1fba36339","trace":"e9937a60bbf8a72bcb32feaa0eee7b03"}
-{"@timestamp":"2026-06-26T08:11:58.703+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2074.6ms","level":"info","span":"f1364ab1fba36339","trace":"e9937a60bbf8a72bcb32feaa0eee7b03"}
-{"@timestamp":"2026-06-26T08:14:01.289+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2058.1ms)","duration":"2058.1ms","level":"slow","span":"4d71f717fd3927eb","trace":"718f9b6852094548eaa89829c5f42ed8"}
-{"@timestamp":"2026-06-26T08:14:01.289+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2058.1ms","level":"info","span":"4d71f717fd3927eb","trace":"718f9b6852094548eaa89829c5f42ed8"}
-{"@timestamp":"2026-06-26T08:14:07.080+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=75.1Mi, Sys=19.6Mi, NumGC=44","level":"stat"}
-{"@timestamp":"2026-06-26T08:14:07.097+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 11, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T08:14:09.714+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2939.9ms, med: 2070.9ms, 90th: 11763.8ms, 99th: 11763.8ms, 99.9th: 11763.8ms","level":"stat"}
-2026/06/26 08:14:12 job worker claim error: read tcp 127.0.0.1:51546->127.0.0.1:6379: i/o timeout
-2026/06/26 08:14:13 job scheduler tick error: i/o timeout
-2026/06/26 08:14:16 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T08:14:16.311+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(12014.1ms)","duration":"12014.1ms","level":"slow","span":"27b088a2c586361f","trace":"51bcc179e023119f5f2d6722c9e07384"}
-{"@timestamp":"2026-06-26T08:14:16.311+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"12014.1ms","level":"error","span":"27b088a2c586361f","trace":"51bcc179e023119f5f2d6722c9e07384"}
-2026/06/26 08:14:20 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T08:14:22.347+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.1ms)","duration":"3027.1ms","level":"slow","span":"91b220aa3e850dcf","trace":"9f126d0dbce1bf8df460a0e1fed50aa9"}
-{"@timestamp":"2026-06-26T08:14:22.347+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.1ms","level":"error","span":"91b220aa3e850dcf","trace":"9f126d0dbce1bf8df460a0e1fed50aa9"}
-2026/06/26 08:14:24 job worker claim error: i/o timeout
-2026/06/26 08:14:28 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T08:14:28.383+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3028.0ms)","duration":"3028.0ms","level":"slow","span":"9b043d7508ca731d","trace":"25757b191e45a1d75588e04ef3e4a410"}
-{"@timestamp":"2026-06-26T08:14:28.383+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3028.0ms","level":"error","span":"9b043d7508ca731d","trace":"25757b191e45a1d75588e04ef3e4a410"}
-2026/06/26 08:14:32 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T08:14:34.411+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.5ms)","duration":"3020.5ms","level":"slow","span":"5ed6d9f2280f97a7","trace":"ef53b8273f5e68a4d1631f3c43562334"}
-{"@timestamp":"2026-06-26T08:14:34.411+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.5ms","level":"error","span":"5ed6d9f2280f97a7","trace":"ef53b8273f5e68a4d1631f3c43562334"}
-2026/06/26 08:14:36 job worker claim error: i/o timeout
-2026/06/26 08:14:40 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T08:14:40.438+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.9ms)","duration":"3020.9ms","level":"slow","span":"e1191227c890278d","trace":"7bcb2f5bad79f00bd22047a5b5989952"}
-{"@timestamp":"2026-06-26T08:14:40.438+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.9ms","level":"error","span":"e1191227c890278d","trace":"7bcb2f5bad79f00bd22047a5b5989952"}
-2026/06/26 08:14:46 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T08:14:48.867+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(5421.9ms)","duration":"5421.9ms","level":"slow","span":"3bace35fc5523e75","trace":"936d99d683e5ae59332f1344a494c0fc"}
-{"@timestamp":"2026-06-26T08:14:48.867+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"5421.9ms","level":"error","span":"3bace35fc5523e75","trace":"936d99d683e5ae59332f1344a494c0fc"}
-2026/06/26 08:14:50 job worker claim error: i/o timeout
-2026/06/26 08:14:54 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T08:14:54.886+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.4ms)","duration":"3013.4ms","level":"slow","span":"85cac1bee45ac5db","trace":"00e9cc29d49feb5dea9c883a882eb85f"}
-{"@timestamp":"2026-06-26T08:14:54.887+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.4ms","level":"error","span":"85cac1bee45ac5db","trace":"00e9cc29d49feb5dea9c883a882eb85f"}
-2026/06/26 08:14:55 job reaper error: connection(127.0.0.1:27017[-258]) incomplete read of message header: read tcp 127.0.0.1:51541->127.0.0.1:27017: use of closed network connection
-2026/06/26 08:14:58 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T08:15:00.905+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.8ms)","duration":"3012.8ms","level":"slow","span":"b8abcc19fe280039","trace":"430e8937f8d2e6c05000cf76bf362859"}
-{"@timestamp":"2026-06-26T08:15:00.905+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.8ms","level":"error","span":"b8abcc19fe280039","trace":"430e8937f8d2e6c05000cf76bf362859"}
-2026/06/26 08:15:02 job worker claim error: i/o timeout
-2026/06/26 08:15:06 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T08:15:06.923+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.4ms)","duration":"3012.4ms","level":"slow","span":"371097499e94f11f","trace":"7ef0d2c4cb13f667ef878c210c9a53e4"}
-{"@timestamp":"2026-06-26T08:15:06.923+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.4ms","level":"error","span":"371097499e94f11f","trace":"7ef0d2c4cb13f667ef878c210c9a53e4"}
-{"@timestamp":"2026-06-26T08:15:09.493+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.8Mi, TotalAlloc=77.1Mi, Sys=19.6Mi, NumGC=45","level":"stat"}
-{"@timestamp":"2026-06-26T08:15:09.509+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 8, pass: 9, drop: 0","level":"stat"}
-2026/06/26 08:15:10 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T08:15:12.124+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 4285.4ms, med: 3026.9ms, 90th: 12014.0ms, 99th: 12014.0ms, 99.9th: 12014.0ms","level":"stat"}
-2026/06/26 08:15:12 job scheduler tick error: i/o timeout
-{"@timestamp":"2026-06-26T08:15:12.956+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.1ms)","duration":"3027.1ms","level":"slow","span":"e902c5aeb77290f9","trace":"c55d59232c6db5cae4583250bcd3f3ae"}
-{"@timestamp":"2026-06-26T08:15:12.956+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.1ms","level":"error","span":"e902c5aeb77290f9","trace":"c55d59232c6db5cae4583250bcd3f3ae"}
-2026/06/26 08:15:14 job worker claim error: i/o timeout
-2026/06/26 08:15:18 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T08:15:18.985+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.0ms)","duration":"3022.0ms","level":"slow","span":"2e7d5a95c33b3dba","trace":"55ca1f7709984e7aefd9c370ac150dae"}
-{"@timestamp":"2026-06-26T08:15:18.985+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.0ms","level":"error","span":"2e7d5a95c33b3dba","trace":"55ca1f7709984e7aefd9c370ac150dae"}
-2026/06/26 08:15:22 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T08:15:25.004+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.9ms)","duration":"3012.9ms","level":"slow","span":"c95275bba5e5b1a2","trace":"611255416c4660ec29fc544b115a59b3"}
-{"@timestamp":"2026-06-26T08:15:25.004+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.9ms","level":"error","span":"c95275bba5e5b1a2","trace":"611255416c4660ec29fc544b115a59b3"}
-2026/06/26 08:15:25 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-273]) incomplete read of message header: read tcp 127.0.0.1:54164->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-273]) incomplete read of message header: read tcp 127.0.0.1:54164->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 08:15:26 job worker claim error: i/o timeout
-2026/06/26 08:30:36 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T08:30:36.246+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.3ms)","duration":"3012.3ms","level":"slow","span":"a9f72f4b7e6b0e01","trace":"d27c2c7e790fe7032e7b571c094a6faf"}
-{"@timestamp":"2026-06-26T08:30:36.246+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.3ms","level":"error","span":"a9f72f4b7e6b0e01","trace":"d27c2c7e790fe7032e7b571c094a6faf"}
-2026/06/26 08:45:59 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T08:46:01.016+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.3ms)","duration":"3013.3ms","level":"slow","span":"86d98b39611c7372","trace":"26958463c4df418263dc5c424e52915e"}
-{"@timestamp":"2026-06-26T08:46:01.017+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.3ms","level":"error","span":"86d98b39611c7372","trace":"26958463c4df418263dc5c424e52915e"}
-2026/06/26 09:03:07 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T09:22:54.514+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1185766.0ms)","duration":"1185766.0ms","level":"slow","span":"13125cbead4dc5db","trace":"3a95b4c2385496fb5d41b559edbda254"}
-{"@timestamp":"2026-06-26T09:22:54.514+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1185766.0ms","level":"error","span":"13125cbead4dc5db","trace":"3a95b4c2385496fb5d41b559edbda254"}
-2026/06/26 09:22:54 job worker claim error: i/o timeout
-2026/06/26 09:22:58 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T09:38:19.266+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(921747.1ms)","duration":"921747.1ms","level":"slow","span":"d119c464a62c0c7d","trace":"3fdc1ce392427f7c1e29189d59cbb02a"}
-{"@timestamp":"2026-06-26T09:38:19.267+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"921747.1ms","level":"error","span":"d119c464a62c0c7d","trace":"3fdc1ce392427f7c1e29189d59cbb02a"}
-2026/06/26 09:38:21 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T09:38:47.986+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(25713.2ms)","duration":"25713.2ms","level":"slow","span":"870ff86ea52345aa","trace":"972111919455cedba71ffe8a8c6d42d2"}
-{"@timestamp":"2026-06-26T09:38:47.986+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"25713.2ms","level":"error","span":"870ff86ea52345aa","trace":"972111919455cedba71ffe8a8c6d42d2"}
-2026/06/26 09:38:48 job worker claim error: i/o timeout
-2026/06/26 09:38:48 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-279]) incomplete read of message header: read tcp 127.0.0.1:57306->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-279]) incomplete read of message header: read tcp 127.0.0.1:57306->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 09:38:52 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T09:38:54.005+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.1ms)","duration":"3015.1ms","level":"slow","span":"c1158a3c95f45278","trace":"f20608754244c9eb87cd826cdfefa6ad"}
-{"@timestamp":"2026-06-26T09:38:54.006+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.1ms","level":"error","span":"c1158a3c95f45278","trace":"f20608754244c9eb87cd826cdfefa6ad"}
-2026/06/26 09:38:56 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T09:39:00.029+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.8ms)","duration":"3016.8ms","level":"slow","span":"f95b75f65f6900b8","trace":"81ae9bd4f8fed96652df6408088c3720"}
-{"@timestamp":"2026-06-26T09:39:00.029+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.8ms","level":"error","span":"f95b75f65f6900b8","trace":"81ae9bd4f8fed96652df6408088c3720"}
-2026/06/26 09:39:00 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T09:39:02.362+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.6Mi, TotalAlloc=79.2Mi, Sys=19.6Mi, NumGC=46","level":"stat"}
-{"@timestamp":"2026-06-26T09:39:02.379+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-2026/06/26 09:39:04 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T09:39:04.993+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 215434.4ms, med: 3021.9ms, 90th: 1185765.9ms, 99th: 1185765.9ms, 99.9th: 1185765.9ms","level":"stat"}
-2026/06/26 09:39:05 job scheduler tick error: i/o timeout
-{"@timestamp":"2026-06-26T09:39:06.061+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3025.8ms)","duration":"3025.8ms","level":"slow","span":"5f1082411174933f","trace":"763eb004ebb3aa96de35c7d106148d9f"}
-{"@timestamp":"2026-06-26T09:39:06.061+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3025.8ms","level":"error","span":"5f1082411174933f","trace":"763eb004ebb3aa96de35c7d106148d9f"}
-2026/06/26 09:39:08 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T09:39:12.095+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.9ms)","duration":"3027.9ms","level":"slow","span":"1c270dafa3865f41","trace":"dcf106f69eba2491a6952b5539bc5b3e"}
-{"@timestamp":"2026-06-26T09:39:12.095+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.9ms","level":"error","span":"1c270dafa3865f41","trace":"dcf106f69eba2491a6952b5539bc5b3e"}
-2026/06/26 09:39:12 job worker claim error: i/o timeout
-2026/06/26 09:39:16 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T09:39:18.125+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.5ms)","duration":"3024.5ms","level":"slow","span":"1edf3a52e20cc1f4","trace":"3ea38ec31f642681759c4dd74886b2c5"}
-{"@timestamp":"2026-06-26T09:39:18.125+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.5ms","level":"error","span":"1edf3a52e20cc1f4","trace":"3ea38ec31f642681759c4dd74886b2c5"}
-2026/06/26 09:39:18 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-285]) incomplete read of message header: read tcp 127.0.0.1:60354->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-285]) incomplete read of message header: read tcp 127.0.0.1:60354->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 09:39:20 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T09:39:24.151+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.3ms)","duration":"3020.3ms","level":"slow","span":"cda7b62e6d854892","trace":"48cad4d40262ec35612979af1b1123a1"}
-{"@timestamp":"2026-06-26T09:39:24.151+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.3ms","level":"error","span":"cda7b62e6d854892","trace":"48cad4d40262ec35612979af1b1123a1"}
-2026/06/26 09:39:24 job worker claim error: i/o timeout
-2026/06/26 09:39:28 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T09:39:30.181+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.4ms)","duration":"3024.4ms","level":"slow","span":"e154dad50acb9ae6","trace":"86e6b163100c3966262ee12d432e1524"}
-{"@timestamp":"2026-06-26T09:39:30.181+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.4ms","level":"error","span":"e154dad50acb9ae6","trace":"86e6b163100c3966262ee12d432e1524"}
-2026/06/26 09:39:32 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T09:56:27.401+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.4ms)","duration":"3013.4ms","level":"slow","span":"734f5374249338c5","trace":"4f8e7630e8554fc58bfab56766e9aa72"}
-{"@timestamp":"2026-06-26T09:56:27.401+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.4ms","level":"error","span":"734f5374249338c5","trace":"4f8e7630e8554fc58bfab56766e9aa72"}
-2026/06/26 09:56:27 job worker claim error: i/o timeout
-2026/06/26 10:11:56 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T10:11:58.187+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3025.8ms)","duration":"3025.8ms","level":"slow","span":"e0d940e31571381c","trace":"dbc919901e73a89b863e91f12d7fae41"}
-{"@timestamp":"2026-06-26T10:11:58.187+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3025.8ms","level":"error","span":"e0d940e31571381c","trace":"dbc919901e73a89b863e91f12d7fae41"}
-2026/06/26 10:12:00 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T10:12:04.211+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.4ms)","duration":"3018.4ms","level":"slow","span":"a7216aab3bb6fea0","trace":"8ef21a8790f52e244f8016ba86f9c0d5"}
-{"@timestamp":"2026-06-26T10:12:04.211+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.4ms","level":"error","span":"a7216aab3bb6fea0","trace":"8ef21a8790f52e244f8016ba86f9c0d5"}
-2026/06/26 10:12:04 job worker claim error: i/o timeout
-2026/06/26 10:12:04 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-291]) incomplete read of message header: read tcp 127.0.0.1:62371->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-291]) incomplete read of message header: read tcp 127.0.0.1:62371->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 10:12:08 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T10:12:10.230+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.9ms)","duration":"3012.9ms","level":"slow","span":"7b42fe32b7f18759","trace":"b70ec86624ac0b07ea14fc5e9254a008"}
-{"@timestamp":"2026-06-26T10:12:10.230+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.9ms","level":"error","span":"7b42fe32b7f18759","trace":"b70ec86624ac0b07ea14fc5e9254a008"}
-2026/06/26 10:12:12 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T10:12:16.249+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3012.8ms)","duration":"3012.8ms","level":"slow","span":"4a44ffcd744392fc","trace":"213150ad9904aaa0c017501bbf02d209"}
-{"@timestamp":"2026-06-26T10:12:16.249+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3012.8ms","level":"error","span":"4a44ffcd744392fc","trace":"213150ad9904aaa0c017501bbf02d209"}
-2026/06/26 10:12:16 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T10:12:18.202+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.8Mi, TotalAlloc=81.5Mi, Sys=19.6Mi, NumGC=47","level":"stat"}
-{"@timestamp":"2026-06-26T10:12:18.217+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-2026/06/26 10:12:20 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T10:12:20.832+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3020.4ms, med: 3024.2ms, 90th: 3027.7ms, 99th: 3027.7ms, 99.9th: 3027.7ms","level":"stat"}
-2026/06/26 10:12:21 job scheduler tick error: i/o timeout
-{"@timestamp":"2026-06-26T10:12:22.280+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3025.9ms)","duration":"3025.9ms","level":"slow","span":"9a5005be06c8b08c","trace":"1dd9ff188b0a5f1ceb0c5a7145b4223f"}
-{"@timestamp":"2026-06-26T10:12:22.280+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3025.9ms","level":"error","span":"9a5005be06c8b08c","trace":"1dd9ff188b0a5f1ceb0c5a7145b4223f"}
-2026/06/26 10:12:24 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T10:12:28.305+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.7ms)","duration":"3018.7ms","level":"slow","span":"c1357e55e9a404bd","trace":"049c85e2867042a1b486afd2988205a7"}
-{"@timestamp":"2026-06-26T10:12:28.305+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.7ms","level":"error","span":"c1357e55e9a404bd","trace":"049c85e2867042a1b486afd2988205a7"}
-2026/06/26 10:12:28 job worker claim error: i/o timeout
-2026/06/26 10:12:32 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T10:12:34.330+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.0ms)","duration":"3019.0ms","level":"slow","span":"f5e8696e52b0d2c8","trace":"354a629d98e5b371f6f29e86972002be"}
-{"@timestamp":"2026-06-26T10:12:34.330+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.0ms","level":"error","span":"f5e8696e52b0d2c8","trace":"354a629d98e5b371f6f29e86972002be"}
-2026/06/26 10:12:34 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-297]) incomplete read of message header: read tcp 127.0.0.1:64837->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-297]) incomplete read of message header: read tcp 127.0.0.1:64837->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 10:12:36 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T10:29:36.820+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1019484.5ms)","duration":"1019484.5ms","level":"slow","span":"fb615245835f461d","trace":"7280b60fc9176548c66b651860f779c6"}
-{"@timestamp":"2026-06-26T10:29:36.820+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1019484.5ms","level":"error","span":"fb615245835f461d","trace":"7280b60fc9176548c66b651860f779c6"}
-2026/06/26 10:29:36 job worker claim error: i/o timeout
-2026/06/26 10:46:36 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T10:46:38.576+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.2ms)","duration":"3016.2ms","level":"slow","span":"b86f5d1e219a7ada","trace":"68c49535f9666a8121f5fc5797174d23"}
-{"@timestamp":"2026-06-26T10:46:38.576+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.2ms","level":"error","span":"b86f5d1e219a7ada","trace":"68c49535f9666a8121f5fc5797174d23"}
-2026/06/26 11:03:06 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T11:03:10.340+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.5ms)","duration":"3015.5ms","level":"slow","span":"cb8c2bced817d7cc","trace":"189d90c6d5fbfeb5546b257ebfbe5590"}
-{"@timestamp":"2026-06-26T11:03:10.340+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.5ms","level":"error","span":"cb8c2bced817d7cc","trace":"189d90c6d5fbfeb5546b257ebfbe5590"}
-2026/06/26 11:03:10 job worker claim error: i/o timeout
-2026/06/26 11:12:57 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T11:12:59.120+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.5ms)","duration":"3026.5ms","level":"slow","span":"f76cc351fd7b6c9a","trace":"abb338374ccb2d2952d8a26fcfe0dd4d"}
-{"@timestamp":"2026-06-26T11:12:59.121+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.5ms","level":"error","span":"f76cc351fd7b6c9a","trace":"abb338374ccb2d2952d8a26fcfe0dd4d"}
-2026/06/26 11:13:01 job worker claim error: i/o timeout
-2026/06/26 11:13:05 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-303]) incomplete read of message header: read tcp 127.0.0.1:50228->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-303]) incomplete read of message header: read tcp 127.0.0.1:50228->127.0.0.1:27017: i/o timeout }, ] }
-{"@timestamp":"2026-06-26T11:13:05.151+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3025.8ms)","duration":"3025.8ms","level":"slow","span":"88f2bfe27c406dc2","trace":"11108fa1b89e79fbc0c20cf95d8b3367"}
-{"@timestamp":"2026-06-26T11:13:05.151+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3025.8ms","level":"error","span":"88f2bfe27c406dc2","trace":"11108fa1b89e79fbc0c20cf95d8b3367"}
-2026/06/26 11:13:05 job worker claim error: i/o timeout
-2026/06/26 11:13:09 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T11:13:11.176+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.9ms)","duration":"3018.9ms","level":"slow","span":"514c0fe6bba91666","trace":"1236d2e964abc67846a7d2a8e0f7e97a"}
-{"@timestamp":"2026-06-26T11:13:11.176+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.9ms","level":"error","span":"514c0fe6bba91666","trace":"1236d2e964abc67846a7d2a8e0f7e97a"}
-2026/06/26 11:13:13 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T11:13:17.195+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3014.0ms)","duration":"3014.0ms","level":"slow","span":"a27ea2a8aceb90f0","trace":"2f74b581d810536490aee28632c95d29"}
-{"@timestamp":"2026-06-26T11:13:17.196+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3014.0ms","level":"error","span":"a27ea2a8aceb90f0","trace":"2f74b581d810536490aee28632c95d29"}
-2026/06/26 11:13:17 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T11:13:18.903+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=5.5Mi, TotalAlloc=83.3Mi, Sys=19.6Mi, NumGC=47","level":"stat"}
-{"@timestamp":"2026-06-26T11:13:18.920+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-2026/06/26 11:13:21 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T11:13:21.534+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 104666.3ms, med: 3018.9ms, 90th: 1019484.4ms, 99th: 1019484.4ms, 99.9th: 1019484.4ms","level":"stat"}
-2026/06/26 11:13:21 job scheduler tick error: i/o timeout
-{"@timestamp":"2026-06-26T11:13:23.227+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.8ms)","duration":"3026.8ms","level":"slow","span":"c5a83f7ad28cb100","trace":"3a3e68ee84d73865b2a1974a2d9c079c"}
-{"@timestamp":"2026-06-26T11:13:23.228+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.8ms","level":"error","span":"c5a83f7ad28cb100","trace":"3a3e68ee84d73865b2a1974a2d9c079c"}
-2026/06/26 11:13:25 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T11:13:29.260+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.4ms)","duration":"3026.4ms","level":"slow","span":"b132976d39c37976","trace":"635206e3da630bfb1eb73658e1ccbbc1"}
-{"@timestamp":"2026-06-26T11:13:29.260+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.4ms","level":"error","span":"b132976d39c37976","trace":"635206e3da630bfb1eb73658e1ccbbc1"}
-2026/06/26 11:13:29 job worker claim error: i/o timeout
-2026/06/26 11:13:33 job worker claim error: i/o timeout
-2026/06/26 11:13:35 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-309]) incomplete read of message header: read tcp 127.0.0.1:52353->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-309]) incomplete read of message header: read tcp 127.0.0.1:52353->127.0.0.1:27017: i/o timeout }, ] }
-{"@timestamp":"2026-06-26T11:13:35.285+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.7ms)","duration":"3018.7ms","level":"slow","span":"4b7b286a6734113c","trace":"6a10aded8fece074cdbc923d013847f1"}
-{"@timestamp":"2026-06-26T11:13:35.285+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.7ms","level":"error","span":"4b7b286a6734113c","trace":"6a10aded8fece074cdbc923d013847f1"}
-2026/06/26 11:13:37 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T11:17:28.380+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(230089.1ms)","duration":"230089.1ms","level":"slow","span":"06280e3d0eaacaf3","trace":"de977a45e7ffee8d868b479c516e86b9"}
-{"@timestamp":"2026-06-26T11:17:28.380+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"230089.1ms","level":"error","span":"06280e3d0eaacaf3","trace":"de977a45e7ffee8d868b479c516e86b9"}
-2026/06/26 11:17:28 job worker claim error: i/o timeout
-2026/06/26 11:17:32 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T11:17:34.408+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3025.3ms)","duration":"3025.3ms","level":"slow","span":"5bf8038fc440b58f","trace":"0440c2c6c3c7bdc93621652e5f532acf"}
-{"@timestamp":"2026-06-26T11:17:34.408+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3025.3ms","level":"error","span":"5bf8038fc440b58f","trace":"0440c2c6c3c7bdc93621652e5f532acf"}
-2026/06/26 11:17:36 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T11:17:40.436+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3021.0ms)","duration":"3021.0ms","level":"slow","span":"2effecaa4029b2d4","trace":"3485fb42d6d3423cf7c09856c83e14bf"}
-{"@timestamp":"2026-06-26T11:17:40.436+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3021.0ms","level":"error","span":"2effecaa4029b2d4","trace":"3485fb42d6d3423cf7c09856c83e14bf"}
-2026/06/26 11:17:40 job worker claim error: i/o timeout
-2026/06/26 11:17:44 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T11:17:46.458+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.2ms)","duration":"3015.2ms","level":"slow","span":"abebcb4194e43f0d","trace":"0a405ebeb4f28c9df69ac5e5429f3a49"}
-{"@timestamp":"2026-06-26T11:17:46.458+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.2ms","level":"error","span":"abebcb4194e43f0d","trace":"0a405ebeb4f28c9df69ac5e5429f3a49"}
-2026/06/26 11:17:48 job worker claim error: i/o timeout
-2026/06/26 11:17:52 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-315]) incomplete read of message header: read tcp 127.0.0.1:54001->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-315]) incomplete read of message header: read tcp 127.0.0.1:54001->127.0.0.1:27017: i/o timeout }, ] }
-{"@timestamp":"2026-06-26T11:17:52.488+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.3ms)","duration":"3024.3ms","level":"slow","span":"fd5010b425e5fc46","trace":"2c2e8d472ebbd5092272ff70398f408a"}
-{"@timestamp":"2026-06-26T11:17:52.488+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.3ms","level":"error","span":"fd5010b425e5fc46","trace":"2c2e8d472ebbd5092272ff70398f408a"}
-2026/06/26 11:17:52 job worker claim error: i/o timeout
-2026/06/26 11:17:56 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T11:17:58.511+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.5ms)","duration":"3017.5ms","level":"slow","span":"8e2a314760136140","trace":"e497c05331f5798940cba211347d5b32"}
-{"@timestamp":"2026-06-26T11:17:58.511+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.5ms","level":"error","span":"8e2a314760136140","trace":"e497c05331f5798940cba211347d5b32"}
-2026/06/26 11:18:00 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T11:18:04.538+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3022.0ms)","duration":"3022.0ms","level":"slow","span":"e517b5faa997876b","trace":"039f31d8592ab7efb8a929913dee8332"}
-{"@timestamp":"2026-06-26T11:18:04.538+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3022.0ms","level":"error","span":"e517b5faa997876b","trace":"039f31d8592ab7efb8a929913dee8332"}
-2026/06/26 11:18:04 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T11:18:05.953+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=5.2Mi, TotalAlloc=85.5Mi, Sys=19.6Mi, NumGC=48","level":"stat"}
-{"@timestamp":"2026-06-26T11:18:05.968+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T11:18:08.584+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 25728.5ms, med: 3024.2ms, 90th: 230089.0ms, 99th: 230089.0ms, 99.9th: 230089.0ms","level":"stat"}
-2026/06/26 11:18:08 job worker claim error: i/o timeout
-2026/06/26 11:18:08 job scheduler tick error: i/o timeout
-{"@timestamp":"2026-06-26T11:18:10.572+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.7ms)","duration":"3027.7ms","level":"slow","span":"a8dab3b7f9b0c07f","trace":"c9fe66ad3d7c97e0d2a4abb9d2e8a56b"}
-{"@timestamp":"2026-06-26T11:18:10.572+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.7ms","level":"error","span":"a8dab3b7f9b0c07f","trace":"c9fe66ad3d7c97e0d2a4abb9d2e8a56b"}
-2026/06/26 11:18:12 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T11:35:09.652+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1016073.8ms)","duration":"1016073.8ms","level":"slow","span":"49d232d688475c41","trace":"50552483ffb1f4a1c7c19b686f62dbb2"}
-{"@timestamp":"2026-06-26T11:35:09.652+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1016073.8ms","level":"error","span":"49d232d688475c41","trace":"50552483ffb1f4a1c7c19b686f62dbb2"}
-2026/06/26 11:35:09 job worker claim error: i/o timeout
-2026/06/26 11:50:56 job worker claim error: i/o timeout
-2026/06/26 11:50:57 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-321]) incomplete read of message header: read tcp 127.0.0.1:55745->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-321]) incomplete read of message header: read tcp 127.0.0.1:55745->127.0.0.1:27017: i/o timeout }, ] }
-{"@timestamp":"2026-06-26T11:50:58.421+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(945764.4ms)","duration":"945764.4ms","level":"slow","span":"66e13c460c4f54c0","trace":"40991b44c0f8ee83c552005504d5a014"}
-{"@timestamp":"2026-06-26T11:50:58.422+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"945764.4ms","level":"error","span":"66e13c460c4f54c0","trace":"40991b44c0f8ee83c552005504d5a014"}
-2026/06/26 11:51:00 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T12:08:28.194+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1046765.2ms)","duration":"1046765.2ms","level":"slow","span":"5f78451ab4ada769","trace":"4e74284a99fcd87b5ec497244da208be"}
-{"@timestamp":"2026-06-26T12:08:28.194+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1046765.2ms","level":"error","span":"5f78451ab4ada769","trace":"4e74284a99fcd87b5ec497244da208be"}
-2026/06/26 12:08:28 job worker claim error: i/o timeout
-2026/06/26 12:13:56 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T12:13:57.963+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.6ms)","duration":"3023.6ms","level":"slow","span":"130caa66f62df168","trace":"9ba36921e17030ce295f03449b2668b8"}
-{"@timestamp":"2026-06-26T12:13:57.964+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.6ms","level":"error","span":"130caa66f62df168","trace":"9ba36921e17030ce295f03449b2668b8"}
-2026/06/26 12:14:00 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T12:14:03.987+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3016.7ms)","duration":"3016.7ms","level":"slow","span":"597b1ccaa1df2453","trace":"4350ef86218eb914e289c3832b35eb8a"}
-{"@timestamp":"2026-06-26T12:14:03.987+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3016.7ms","level":"error","span":"597b1ccaa1df2453","trace":"4350ef86218eb914e289c3832b35eb8a"}
-2026/06/26 12:14:04 job worker claim error: i/o timeout
-2026/06/26 12:14:08 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T12:14:10.012+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.3ms)","duration":"3018.3ms","level":"slow","span":"e854694f314aeaa6","trace":"21808f60292798dd0f25c68c1f772026"}
-{"@timestamp":"2026-06-26T12:14:10.012+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.3ms","level":"error","span":"e854694f314aeaa6","trace":"21808f60292798dd0f25c68c1f772026"}
-2026/06/26 12:14:12 job worker claim error: i/o timeout
-2026/06/26 12:14:15 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-327]) incomplete read of message header: read tcp 127.0.0.1:57813->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-327]) incomplete read of message header: read tcp 127.0.0.1:57813->127.0.0.1:27017: i/o timeout }, ] }
-{"@timestamp":"2026-06-26T12:14:16.033+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3014.7ms)","duration":"3014.7ms","level":"slow","span":"a5dd2760d1cf2041","trace":"b029e537b2aaf093b0ab51a62713a176"}
-{"@timestamp":"2026-06-26T12:14:16.034+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3014.7ms","level":"error","span":"a5dd2760d1cf2041","trace":"b029e537b2aaf093b0ab51a62713a176"}
-2026/06/26 12:14:16 job worker claim error: i/o timeout
-2026/06/26 12:14:20 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T12:14:22.054+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3014.7ms)","duration":"3014.7ms","level":"slow","span":"f6742aff0b4a4b78","trace":"61340478d568e68df20d4c20fed91413"}
-{"@timestamp":"2026-06-26T12:14:22.054+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3014.7ms","level":"error","span":"f6742aff0b4a4b78","trace":"61340478d568e68df20d4c20fed91413"}
-2026/06/26 12:14:24 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T12:14:28.073+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3014.2ms)","duration":"3014.2ms","level":"slow","span":"d2de8e1a1cd702ab","trace":"b2ba556804da6a3873f875f0ca77c557"}
-{"@timestamp":"2026-06-26T12:14:28.074+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3014.2ms","level":"error","span":"d2de8e1a1cd702ab","trace":"b2ba556804da6a3873f875f0ca77c557"}
-2026/06/26 12:14:28 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T12:14:29.228+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.5Mi, TotalAlloc=87.6Mi, Sys=19.6Mi, NumGC=49","level":"stat"}
-{"@timestamp":"2026-06-26T12:14:29.245+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T12:14:31.859+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 302973.2ms, med: 3023.5ms, 90th: 1046765.1ms, 99th: 1046765.1ms, 99.9th: 1046765.1ms","level":"stat"}
-2026/06/26 12:14:32 job scheduler tick error: i/o timeout
-2026/06/26 12:14:32 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T12:14:34.098+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.1ms)","duration":"3019.1ms","level":"slow","span":"054e2f4689b71943","trace":"91d7d29f6b26049ee218403becfb0c79"}
-{"@timestamp":"2026-06-26T12:14:34.098+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.1ms","level":"error","span":"054e2f4689b71943","trace":"91d7d29f6b26049ee218403becfb0c79"}
-2026/06/26 12:14:36 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T12:14:40.133+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3028.5ms)","duration":"3028.5ms","level":"slow","span":"5725c3b97e1050f0","trace":"37c0fd410728306236411f04ffe9cca6"}
-{"@timestamp":"2026-06-26T12:14:40.133+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3028.5ms","level":"error","span":"5725c3b97e1050f0","trace":"37c0fd410728306236411f04ffe9cca6"}
-2026/06/26 12:14:40 job worker claim error: i/o timeout
-2026/06/26 12:32:34 job worker claim error: i/o timeout
-2026/06/26 12:32:35 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-333]) incomplete read of message header: read tcp 127.0.0.1:58761->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-333]) incomplete read of message header: read tcp 127.0.0.1:58761->127.0.0.1:27017: i/o timeout }, ] }
-{"@timestamp":"2026-06-26T12:32:36.229+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.5ms)","duration":"3027.5ms","level":"slow","span":"efe9e562fdec2b02","trace":"7bd8add0a9cdc6c10877227a5fa8fd89"}
-{"@timestamp":"2026-06-26T12:32:36.229+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.5ms","level":"error","span":"efe9e562fdec2b02","trace":"7bd8add0a9cdc6c10877227a5fa8fd89"}
-2026/06/26 12:50:04 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T12:50:05.493+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.0ms)","duration":"3024.0ms","level":"slow","span":"37d82df9760ebc6c","trace":"12631215cf1377a02c1372b264bc9ab2"}
-{"@timestamp":"2026-06-26T12:50:05.493+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.0ms","level":"error","span":"37d82df9760ebc6c","trace":"12631215cf1377a02c1372b264bc9ab2"}
-2026/06/26 13:07:16 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T13:07:19.276+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3017.9ms)","duration":"3017.9ms","level":"slow","span":"ed6952e410a8646b","trace":"b388b05a4dbd78ba6996a9bbf4b73a64"}
-{"@timestamp":"2026-06-26T13:07:19.277+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3017.9ms","level":"error","span":"ed6952e410a8646b","trace":"b388b05a4dbd78ba6996a9bbf4b73a64"}
-2026/06/26 13:07:20 job worker claim error: i/o timeout
-2026/06/26 13:14:57 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T13:14:59.060+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.8ms)","duration":"3024.8ms","level":"slow","span":"16a3b21ec60e44ab","trace":"fee25274acbe7640654267483ababdb7"}
-{"@timestamp":"2026-06-26T13:14:59.061+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.8ms","level":"error","span":"16a3b21ec60e44ab","trace":"fee25274acbe7640654267483ababdb7"}
-2026/06/26 13:15:01 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T13:15:05.083+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.1ms)","duration":"3019.1ms","level":"slow","span":"5d28d9957d232cec","trace":"e660474f726b1faa957b45a1464c5399"}
-{"@timestamp":"2026-06-26T13:15:05.083+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.1ms","level":"error","span":"5d28d9957d232cec","trace":"e660474f726b1faa957b45a1464c5399"}
-2026/06/26 13:15:06 job worker claim error: i/o timeout
-2026/06/26 13:15:10 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T13:15:11.116+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.7ms)","duration":"3026.7ms","level":"slow","span":"35a5b907edd0cac1","trace":"9fda9264797dda5b313e7e46d7a011fe"}
-{"@timestamp":"2026-06-26T13:15:11.116+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.7ms","level":"error","span":"35a5b907edd0cac1","trace":"9fda9264797dda5b313e7e46d7a011fe"}
-2026/06/26 13:15:12 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-339]) incomplete read of message header: read tcp 127.0.0.1:60817->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-339]) incomplete read of message header: read tcp 127.0.0.1:60817->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 13:15:14 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T13:15:17.147+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3026.1ms)","duration":"3026.1ms","level":"slow","span":"ee839fc233f1d55a","trace":"c18cdf2092dda63efbccd2fc556a9dd6"}
-{"@timestamp":"2026-06-26T13:15:17.147+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3026.1ms","level":"error","span":"ee839fc233f1d55a","trace":"c18cdf2092dda63efbccd2fc556a9dd6"}
-2026/06/26 13:15:18 job worker claim error: i/o timeout
-2026/06/26 13:15:22 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T13:15:23.173+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3019.7ms)","duration":"3019.7ms","level":"slow","span":"7c3e36bd004eea2f","trace":"51f65debff1bd542007f0948edaadde8"}
-{"@timestamp":"2026-06-26T13:15:23.173+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3019.7ms","level":"error","span":"7c3e36bd004eea2f","trace":"51f65debff1bd542007f0948edaadde8"}
-2026/06/26 13:15:26 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T13:15:26.564+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.1Mi, TotalAlloc=89.9Mi, Sys=19.6Mi, NumGC=50","level":"stat"}
-{"@timestamp":"2026-06-26T13:15:26.580+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 10, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T13:15:29.195+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 3023.2ms, med: 3024.7ms, 90th: 3028.4ms, 99th: 3028.4ms, 99.9th: 3028.4ms","level":"stat"}
-{"@timestamp":"2026-06-26T13:15:29.195+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3015.8ms)","duration":"3015.8ms","level":"slow","span":"2bf605d781493fd7","trace":"78040aec4422d766772a0ced0b9fc206"}
-{"@timestamp":"2026-06-26T13:15:29.195+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3015.8ms","level":"error","span":"2bf605d781493fd7","trace":"78040aec4422d766772a0ced0b9fc206"}
-2026/06/26 13:15:29 job scheduler tick error: i/o timeout
-2026/06/26 13:15:30 job worker claim error: i/o timeout
-2026/06/26 13:15:34 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T13:15:35.220+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.9ms)","duration":"3018.9ms","level":"slow","span":"f8696a54d6595379","trace":"901bfeba0d4925434724f3d65f3e10d3"}
-{"@timestamp":"2026-06-26T13:15:35.225+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.9ms","level":"error","span":"f8696a54d6595379","trace":"901bfeba0d4925434724f3d65f3e10d3"}
-2026/06/26 13:15:38 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T13:31:10.353+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(932123.8ms)","duration":"932123.8ms","level":"slow","span":"e24b20e0fea0acfd","trace":"e64e8c65f240e4f1b072445c56089166"}
-{"@timestamp":"2026-06-26T13:31:10.353+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"932123.8ms","level":"error","span":"e24b20e0fea0acfd","trace":"e64e8c65f240e4f1b072445c56089166"}
-2026/06/26 13:31:11 job worker claim error: i/o timeout
-2026/06/26 13:31:11 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-345]) incomplete read of message header: read tcp 127.0.0.1:62293->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-345]) incomplete read of message header: read tcp 127.0.0.1:62293->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 13:31:15 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T13:49:09.137+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1075779.9ms)","duration":"1075779.9ms","level":"slow","span":"06b9ad42b9a0490e","trace":"bd1257cac1924e05aed833ee1d524dc4"}
-{"@timestamp":"2026-06-26T13:49:09.137+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1075779.9ms","level":"error","span":"06b9ad42b9a0490e","trace":"bd1257cac1924e05aed833ee1d524dc4"}
-2026/06/26 13:49:12 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T14:07:04.930+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1072786.9ms)","duration":"1072786.9ms","level":"slow","span":"1da605071a86cece","trace":"0078cee758b5b48a9aed5ebf01df59d3"}
-{"@timestamp":"2026-06-26T14:07:04.930+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1072786.9ms","level":"error","span":"1da605071a86cece","trace":"0078cee758b5b48a9aed5ebf01df59d3"}
-2026/06/26 14:07:05 job worker claim error: i/o timeout
-2026/06/26 14:15:55 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T14:15:56.712+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(528776.4ms)","duration":"528776.4ms","level":"slow","span":"6928de45c2fa19f2","trace":"c50fa27217364bf8367c16a91c66e949"}
-{"@timestamp":"2026-06-26T14:15:56.712+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"528776.4ms","level":"error","span":"6928de45c2fa19f2","trace":"c50fa27217364bf8367c16a91c66e949"}
-2026/06/26 14:15:59 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T14:16:02.732+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3014.1ms)","duration":"3014.1ms","level":"slow","span":"4df8533961d7d7d8","trace":"bb2f44a1506f2756db85b9b63b265405"}
-{"@timestamp":"2026-06-26T14:16:02.732+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3014.1ms","level":"error","span":"4df8533961d7d7d8","trace":"bb2f44a1506f2756db85b9b63b265405"}
-2026/06/26 14:16:03 job worker claim error: i/o timeout
-2026/06/26 14:16:07 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T14:16:08.757+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.9ms)","duration":"3018.9ms","level":"slow","span":"9b7e0c250bab01a8","trace":"b750acc2df3af0745e876a2ac12af14f"}
-{"@timestamp":"2026-06-26T14:16:08.757+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.9ms","level":"error","span":"9b7e0c250bab01a8","trace":"b750acc2df3af0745e876a2ac12af14f"}
-2026/06/26 14:16:10 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-351]) incomplete read of message header: read tcp 127.0.0.1:64090->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-351]) incomplete read of message header: read tcp 127.0.0.1:64090->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 14:16:11 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T14:16:14.785+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.2ms)","duration":"3023.2ms","level":"slow","span":"dd060a5da6adc154","trace":"e39146b5b8111906e82429278080e6cf"}
-{"@timestamp":"2026-06-26T14:16:14.785+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.2ms","level":"error","span":"dd060a5da6adc154","trace":"e39146b5b8111906e82429278080e6cf"}
-2026/06/26 14:16:15 job worker claim error: i/o timeout
-2026/06/26 14:16:19 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T14:16:20.809+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.6ms)","duration":"3018.6ms","level":"slow","span":"d3eac44bb34dbc17","trace":"6d52d9770aa54523985acc266a1d896e"}
-{"@timestamp":"2026-06-26T14:16:20.809+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.6ms","level":"error","span":"d3eac44bb34dbc17","trace":"6d52d9770aa54523985acc266a1d896e"}
-2026/06/26 14:16:23 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T14:16:23.891+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=92.2Mi, Sys=19.6Mi, NumGC=51","level":"stat"}
-{"@timestamp":"2026-06-26T14:16:23.908+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 10, pass: 10, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T14:16:26.522+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 362757.5ms, med: 3023.1ms, 90th: 1075779.9ms, 99th: 1075779.9ms, 99.9th: 1075779.9ms","level":"stat"}
-{"@timestamp":"2026-06-26T14:16:26.839+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.8ms)","duration":"3024.8ms","level":"slow","span":"671f3b5ddc077739","trace":"4b28106dee191c0f07a6afe388f29a34"}
-{"@timestamp":"2026-06-26T14:16:26.839+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.8ms","level":"error","span":"671f3b5ddc077739","trace":"4b28106dee191c0f07a6afe388f29a34"}
-2026/06/26 14:16:26 job scheduler tick error: i/o timeout
-2026/06/26 14:16:27 job worker claim error: i/o timeout
-2026/06/26 14:16:31 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T14:16:32.872+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.4ms)","duration":"3027.4ms","level":"slow","span":"4add76cd2ffc6f67","trace":"f403906b25216dfcd0e6c15204f408c7"}
-{"@timestamp":"2026-06-26T14:16:32.872+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.4ms","level":"error","span":"4add76cd2ffc6f67","trace":"f403906b25216dfcd0e6c15204f408c7"}
-2026/06/26 14:16:35 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T14:33:23.860+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1007982.5ms)","duration":"1007982.5ms","level":"slow","span":"b1ae69c73e721c1e","trace":"f4a4e5aefadca481ed316061d45d47c1"}
-{"@timestamp":"2026-06-26T14:33:23.860+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1007982.5ms","level":"error","span":"b1ae69c73e721c1e","trace":"f4a4e5aefadca481ed316061d45d47c1"}
-2026/06/26 14:33:24 job worker claim error: i/o timeout
-2026/06/26 14:33:25 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-357]) incomplete read of message header: read tcp 127.0.0.1:49527->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-357]) incomplete read of message header: read tcp 127.0.0.1:49527->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 14:50:42 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T14:50:43.630+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1036764.4ms)","duration":"1036764.4ms","level":"slow","span":"57273833d34c2f16","trace":"151f4a42c9d3baa3740698a1ec051320"}
-{"@timestamp":"2026-06-26T14:50:43.630+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1036764.4ms","level":"error","span":"57273833d34c2f16","trace":"151f4a42c9d3baa3740698a1ec051320"}
-2026/06/26 14:50:46 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T15:08:36.373+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(1069739.1ms)","duration":"1069739.1ms","level":"slow","span":"dee7a0bb7946787e","trace":"968b3a474b7282a4f125eb2098ce0aa2"}
-{"@timestamp":"2026-06-26T15:08:36.373+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"1069739.1ms","level":"error","span":"dee7a0bb7946787e","trace":"968b3a474b7282a4f125eb2098ce0aa2"}
-2026/06/26 15:08:37 job worker claim error: i/o timeout
-2026/06/26 15:16:56 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T15:16:57.117+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(497737.6ms)","duration":"497737.6ms","level":"slow","span":"21bad82d872036b9","trace":"b8389680ab3c65438327464586a798f4"}
-{"@timestamp":"2026-06-26T15:16:57.117+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"497737.6ms","level":"error","span":"21bad82d872036b9","trace":"b8389680ab3c65438327464586a798f4"}
-2026/06/26 15:17:00 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T15:17:03.136+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3013.3ms)","duration":"3013.3ms","level":"slow","span":"4d8d86383ac2d474","trace":"0d56bd1ace0a8a2a240377dfe69a079e"}
-{"@timestamp":"2026-06-26T15:17:03.136+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3013.3ms","level":"error","span":"4d8d86383ac2d474","trace":"0d56bd1ace0a8a2a240377dfe69a079e"}
-2026/06/26 15:17:04 job worker claim error: i/o timeout
-2026/06/26 15:17:08 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T15:17:09.164+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.6ms)","duration":"3023.6ms","level":"slow","span":"524fbebda39271c8","trace":"0dfff953a5d751726eeb386a2b7d4c8d"}
-{"@timestamp":"2026-06-26T15:17:09.164+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.6ms","level":"error","span":"524fbebda39271c8","trace":"0dfff953a5d751726eeb386a2b7d4c8d"}
-2026/06/26 15:17:10 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-363]) incomplete read of message header: read tcp 127.0.0.1:51196->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-363]) incomplete read of message header: read tcp 127.0.0.1:51196->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 15:17:12 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T15:17:15.186+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3018.5ms)","duration":"3018.5ms","level":"slow","span":"e294ff83b4a37894","trace":"e15ecf8c4df5cfbe781f19f516b0da61"}
-{"@timestamp":"2026-06-26T15:17:15.186+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3018.5ms","level":"error","span":"e294ff83b4a37894","trace":"e15ecf8c4df5cfbe781f19f516b0da61"}
-2026/06/26 15:17:16 job worker claim error: i/o timeout
-2026/06/26 15:17:20 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T15:17:21.216+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.4ms)","duration":"3024.4ms","level":"slow","span":"4be4b2ed8aa51628","trace":"0dee513b7ead86468df0eee0af68734f"}
-{"@timestamp":"2026-06-26T15:17:21.216+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.4ms","level":"error","span":"4be4b2ed8aa51628","trace":"0dee513b7ead86468df0eee0af68734f"}
-{"@timestamp":"2026-06-26T15:35:07.298+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 9, pass: 10, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T15:35:07.298+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=5.2Mi, TotalAlloc=93.8Mi, Sys=19.6Mi, NumGC=51","level":"stat"}
-2026/06/26 15:35:07 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T15:35:09.864+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 363035.4ms, med: 3027.3ms, 90th: 1069739.0ms, 99th: 1069739.0ms, 99.9th: 1069739.0ms","level":"stat"}
-2026/06/26 15:35:10 job scheduler tick error: i/o timeout
-{"@timestamp":"2026-06-26T15:35:10.447+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.6ms)","duration":"3027.6ms","level":"slow","span":"f02e98b835d95a31","trace":"1e947aaffe477794a819419c8e061fa3"}
-{"@timestamp":"2026-06-26T15:35:10.447+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.6ms","level":"error","span":"f02e98b835d95a31","trace":"1e947aaffe477794a819419c8e061fa3"}
-2026/06/26 15:35:11 job worker claim error: i/o timeout
-2026/06/26 15:52:04 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T15:52:05.234+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3027.8ms)","duration":"3027.8ms","level":"slow","span":"5d90ef2328c23fdd","trace":"42c05bdf2d13cf9757cc34310cc2cdac"}
-{"@timestamp":"2026-06-26T15:52:05.234+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3027.8ms","level":"error","span":"5d90ef2328c23fdd","trace":"42c05bdf2d13cf9757cc34310cc2cdac"}
-2026/06/26 16:07:27 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T16:07:30.112+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3023.4ms)","duration":"3023.4ms","level":"slow","span":"8ed9f0718a0f3039","trace":"dd92e776d90852578bdbcb82c7035957"}
-{"@timestamp":"2026-06-26T16:07:30.112+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3023.4ms","level":"error","span":"8ed9f0718a0f3039","trace":"dd92e776d90852578bdbcb82c7035957"}
-2026/06/26 16:07:31 job reaper error: server selection error: server selection timeout, current topology: { Type: Single, Servers: [{ Addr: 127.0.0.1:27017, Type: Unknown, Last error: connection(127.0.0.1:27017[-369]) incomplete read of message header: read tcp 127.0.0.1:53335->127.0.0.1:27017: i/o timeout: connection(127.0.0.1:27017[-369]) incomplete read of message header: read tcp 127.0.0.1:53335->127.0.0.1:27017: i/o timeout }, ] }
-2026/06/26 16:07:31 job worker claim error: i/o timeout
-2026/06/26 16:17:57 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T16:17:58.862+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3020.7ms)","duration":"3020.7ms","level":"slow","span":"4136d5d84c03d250","trace":"856021292ce826f3aef4e4d407a38d84"}
-{"@timestamp":"2026-06-26T16:17:58.862+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3020.7ms","level":"error","span":"4136d5d84c03d250","trace":"856021292ce826f3aef4e4d407a38d84"}
-2026/06/26 16:18:01 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T16:18:04.896+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3029.1ms)","duration":"3029.1ms","level":"slow","span":"e1c8c7cf12bb0bea","trace":"a08c6d3c5489b4fd3d29caed2ed51041"}
-{"@timestamp":"2026-06-26T16:18:04.896+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3029.1ms","level":"error","span":"e1c8c7cf12bb0bea","trace":"a08c6d3c5489b4fd3d29caed2ed51041"}
-2026/06/26 16:18:05 job worker claim error: i/o timeout
-2026/06/26 16:18:09 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T16:18:10.926+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(3024.5ms)","duration":"3024.5ms","level":"slow","span":"e7c953850f9810fb","trace":"66009a73e4b51fbee205cbf76334ea3d"}
-{"@timestamp":"2026-06-26T16:18:10.926+08:00","caller":"handler/loghandler.go:169","content":"[HTTP] 500 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node\nPOST /api/v1/internal/workers/jobs/claim HTTP/1.1\r\nHost: 127.0.0.1:8890\r\nAccept: */*\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: *\r\nConnection: keep-alive\r\nContent-Length: 62\r\nContent-Type: application/json\r\nSec-Fetch-Mode: cors\r\nUser-Agent: node\r\n\r\n{\"worker_type\":\"node\",\"worker_id\":\"local-style-8d-node-72804\"}","duration":"3024.5ms","level":"error","span":"e7c953850f9810fb","trace":"66009a73e4b51fbee205cbf76334ea3d"}
-2026/06/26 16:18:14 job worker claim error: i/o timeout
-{"@timestamp":"2026-06-26T16:25:00.911+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(406980.0ms)","duration":"406980.0ms","level":"slow","span":"4675282c15d6837c","trace":"85a4fd43207f6864c9a227a4cd9443bc"}
-{"@timestamp":"2026-06-26T16:25:00.911+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"406980.0ms","level":"info","span":"4675282c15d6837c","trace":"85a4fd43207f6864c9a227a4cd9443bc"}
-{"@timestamp":"2026-06-26T16:25:05.987+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2072.2ms)","duration":"2072.2ms","level":"slow","span":"9ce306693bd67b1b","trace":"37d4eff9ad72817506ba163ce9621f24"}
-{"@timestamp":"2026-06-26T16:25:05.987+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2072.2ms","level":"info","span":"9ce306693bd67b1b","trace":"37d4eff9ad72817506ba163ce9621f24"}
-{"@timestamp":"2026-06-26T16:25:09.131+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2058.1ms)","duration":"2058.1ms","level":"slow","span":"51a80e13ce520d65","trace":"ad38b7ce4a99c9cc87c99c6795f673ae"}
-{"@timestamp":"2026-06-26T16:25:09.131+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2058.1ms","level":"info","span":"51a80e13ce520d65","trace":"ad38b7ce4a99c9cc87c99c6795f673ae"}
-{"@timestamp":"2026-06-26T16:25:14.209+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2073.0ms)","duration":"2073.0ms","level":"slow","span":"6268904dea49f417","trace":"bec74581cd18e11d588bd064f36a75b2"}
-{"@timestamp":"2026-06-26T16:25:14.209+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2073.0ms","level":"info","span":"6268904dea49f417","trace":"bec74581cd18e11d588bd064f36a75b2"}
-{"@timestamp":"2026-06-26T16:25:18.410+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.3Mi, TotalAlloc=95.6Mi, Sys=19.6Mi, NumGC=52","level":"stat"}
-{"@timestamp":"2026-06-26T16:25:18.427+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 10, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T16:25:19.249+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2038.6ms)","duration":"2038.6ms","level":"slow","span":"1413a9d8cc7a5304","trace":"b732651e4220321a6fea04c74930d6e3"}
-{"@timestamp":"2026-06-26T16:25:19.249+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2038.6ms","level":"info","span":"1413a9d8cc7a5304","trace":"b732651e4220321a6fea04c74930d6e3"}
-{"@timestamp":"2026-06-26T16:25:21.051+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 39397.5ms, med: 3024.4ms, 90th: 406980.0ms, 99th: 406980.0ms, 99.9th: 406980.0ms","level":"stat"}
-{"@timestamp":"2026-06-26T16:25:24.288+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.5ms)","duration":"2034.5ms","level":"slow","span":"d3e14238ccf4db00","trace":"c4fbe7d2163cef5c96cc19f4593d8dfb"}
-{"@timestamp":"2026-06-26T16:25:24.288+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.5ms","level":"info","span":"d3e14238ccf4db00","trace":"c4fbe7d2163cef5c96cc19f4593d8dfb"}
-{"@timestamp":"2026-06-26T16:25:29.327+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.7ms)","duration":"2034.7ms","level":"slow","span":"2c83a396e0f0b401","trace":"a9f18bef2bdbcd2ed7d53e36350092aa"}
-{"@timestamp":"2026-06-26T16:25:29.327+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.7ms","level":"info","span":"2c83a396e0f0b401","trace":"a9f18bef2bdbcd2ed7d53e36350092aa"}
-{"@timestamp":"2026-06-26T16:25:34.373+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2043.6ms)","duration":"2043.6ms","level":"slow","span":"322cdd1dbb257f85","trace":"7e214f63662a10fb7f9bdb1e7f7d97a3"}
-{"@timestamp":"2026-06-26T16:25:34.374+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2043.6ms","level":"info","span":"322cdd1dbb257f85","trace":"7e214f63662a10fb7f9bdb1e7f7d97a3"}
-{"@timestamp":"2026-06-26T16:25:39.397+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2019.8ms)","duration":"2019.8ms","level":"slow","span":"6924f901a2f46272","trace":"00c2b581bed7f13db116e03c3cb5f840"}
-{"@timestamp":"2026-06-26T16:25:39.397+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2019.8ms","level":"info","span":"6924f901a2f46272","trace":"00c2b581bed7f13db116e03c3cb5f840"}
-{"@timestamp":"2026-06-26T16:25:44.472+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2071.8ms)","duration":"2071.8ms","level":"slow","span":"7c2f0e289666a20e","trace":"72eeb5770786cff5bca5de4085951918"}
-{"@timestamp":"2026-06-26T16:25:44.472+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2071.8ms","level":"info","span":"7c2f0e289666a20e","trace":"72eeb5770786cff5bca5de4085951918"}
-{"@timestamp":"2026-06-26T16:25:49.539+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2063.6ms)","duration":"2063.6ms","level":"slow","span":"b8a086c2f4f09dbf","trace":"f8f46489dcd22f68ae1a7721f944aae2"}
-{"@timestamp":"2026-06-26T16:25:49.539+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2063.6ms","level":"info","span":"b8a086c2f4f09dbf","trace":"f8f46489dcd22f68ae1a7721f944aae2"}
-{"@timestamp":"2026-06-26T16:25:54.610+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2068.3ms)","duration":"2068.3ms","level":"slow","span":"013305431f656a74","trace":"85df184f76adc2a8c816c799d8025c30"}
-{"@timestamp":"2026-06-26T16:25:54.610+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2068.3ms","level":"info","span":"013305431f656a74","trace":"85df184f76adc2a8c816c799d8025c30"}
-{"@timestamp":"2026-06-26T16:25:59.659+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.1ms)","duration":"2044.1ms","level":"slow","span":"277fcc95fe073a1d","trace":"5691c5d5b246daba401ca8baea3b1978"}
-{"@timestamp":"2026-06-26T16:25:59.659+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.1ms","level":"info","span":"277fcc95fe073a1d","trace":"5691c5d5b246daba401ca8baea3b1978"}
-{"@timestamp":"2026-06-26T16:26:04.702+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2040.5ms)","duration":"2040.5ms","level":"slow","span":"8120e5f69e3949ef","trace":"634b501aa8dcadb408112d47ff5f2230"}
-{"@timestamp":"2026-06-26T16:26:04.702+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2040.5ms","level":"info","span":"8120e5f69e3949ef","trace":"634b501aa8dcadb408112d47ff5f2230"}
-{"@timestamp":"2026-06-26T16:26:09.755+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2051.1ms)","duration":"2051.1ms","level":"slow","span":"1a4e4b04b9f2abcc","trace":"079c6ef50e09e591f34cdc89a21a2c80"}
-{"@timestamp":"2026-06-26T16:26:09.756+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2051.1ms","level":"info","span":"1a4e4b04b9f2abcc","trace":"079c6ef50e09e591f34cdc89a21a2c80"}
-{"@timestamp":"2026-06-26T16:26:14.829+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2069.8ms)","duration":"2069.8ms","level":"slow","span":"914ba2a6415765d7","trace":"fd0723e9f5872e891f49f8c586abf7be"}
-{"@timestamp":"2026-06-26T16:26:14.829+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2069.8ms","level":"info","span":"914ba2a6415765d7","trace":"fd0723e9f5872e891f49f8c586abf7be"}
-{"@timestamp":"2026-06-26T16:26:18.406+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=4.5Mi, TotalAlloc=95.8Mi, Sys=19.6Mi, NumGC=52","level":"stat"}
-{"@timestamp":"2026-06-26T16:26:18.423+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T16:26:19.860+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2028.8ms)","duration":"2028.8ms","level":"slow","span":"d82c6109ac8f2094","trace":"b178730f82259f9df3a34a9a0b3c8e0d"}
-{"@timestamp":"2026-06-26T16:26:19.860+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2028.8ms","level":"info","span":"d82c6109ac8f2094","trace":"b178730f82259f9df3a34a9a0b3c8e0d"}
-{"@timestamp":"2026-06-26T16:26:21.037+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2047.4ms, med: 2044.0ms, 90th: 2071.7ms, 99th: 2071.7ms, 99.9th: 2071.7ms","level":"stat"}
-{"@timestamp":"2026-06-26T16:26:24.926+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2062.5ms)","duration":"2062.5ms","level":"slow","span":"ac79368d3e6beda7","trace":"d1759b2dbe3d954e8c89dd4707996894"}
-{"@timestamp":"2026-06-26T16:26:24.926+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2062.5ms","level":"info","span":"ac79368d3e6beda7","trace":"d1759b2dbe3d954e8c89dd4707996894"}
-{"@timestamp":"2026-06-26T16:26:29.962+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.8ms)","duration":"2027.8ms","level":"slow","span":"6cf3ead48119dfae","trace":"a558faed2537438f9826209fd04ef7f4"}
-{"@timestamp":"2026-06-26T16:26:29.962+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.8ms","level":"info","span":"6cf3ead48119dfae","trace":"a558faed2537438f9826209fd04ef7f4"}
-{"@timestamp":"2026-06-26T16:26:35.003+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2036.7ms)","duration":"2036.7ms","level":"slow","span":"3163d4951482a534","trace":"3cb0c6fa8851a36879a64c07fd557213"}
-{"@timestamp":"2026-06-26T16:26:35.003+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2036.7ms","level":"info","span":"3163d4951482a534","trace":"3cb0c6fa8851a36879a64c07fd557213"}
-{"@timestamp":"2026-06-26T16:26:40.036+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.8ms)","duration":"2029.8ms","level":"slow","span":"c4bbcd43d2babad7","trace":"9106323ff8089d70e0ae520af215e5e7"}
-{"@timestamp":"2026-06-26T16:26:40.036+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.8ms","level":"info","span":"c4bbcd43d2babad7","trace":"9106323ff8089d70e0ae520af215e5e7"}
-{"@timestamp":"2026-06-26T16:26:45.077+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2037.8ms)","duration":"2037.8ms","level":"slow","span":"6f91d343dd49ecf3","trace":"56309ed5c724fd6d58183d3eff2d4232"}
-{"@timestamp":"2026-06-26T16:26:45.077+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2037.8ms","level":"info","span":"6f91d343dd49ecf3","trace":"56309ed5c724fd6d58183d3eff2d4232"}
-{"@timestamp":"2026-06-26T16:26:50.146+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2065.4ms)","duration":"2065.4ms","level":"slow","span":"f7b47613ffadb6df","trace":"c2ccbd218648f1911ea66650e23a67eb"}
-{"@timestamp":"2026-06-26T16:26:50.146+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2065.4ms","level":"info","span":"f7b47613ffadb6df","trace":"c2ccbd218648f1911ea66650e23a67eb"}
-{"@timestamp":"2026-06-26T16:26:55.210+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2059.1ms)","duration":"2059.1ms","level":"slow","span":"45d7a990e69d48f2","trace":"0f9b72b452a579278f5ab5c923320fd3"}
-{"@timestamp":"2026-06-26T16:26:55.210+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2059.1ms","level":"info","span":"45d7a990e69d48f2","trace":"0f9b72b452a579278f5ab5c923320fd3"}
-{"@timestamp":"2026-06-26T16:27:00.259+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.0ms)","duration":"2045.0ms","level":"slow","span":"dc91a9ff6ea44678","trace":"43e085c156b750eaa35f1717572560c0"}
-{"@timestamp":"2026-06-26T16:27:00.259+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.0ms","level":"info","span":"dc91a9ff6ea44678","trace":"43e085c156b750eaa35f1717572560c0"}
-{"@timestamp":"2026-06-26T16:27:05.289+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.9ms)","duration":"2026.9ms","level":"slow","span":"d4ca8217bc585c14","trace":"ab8d47faecc7bae3ce740d3100eaa8cf"}
-{"@timestamp":"2026-06-26T16:27:05.289+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.9ms","level":"info","span":"d4ca8217bc585c14","trace":"ab8d47faecc7bae3ce740d3100eaa8cf"}
-{"@timestamp":"2026-06-26T16:27:10.330+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2037.0ms)","duration":"2037.0ms","level":"slow","span":"6aae64f427f86ff7","trace":"00da45983e36443e9f777f30a585b4b1"}
-{"@timestamp":"2026-06-26T16:27:10.330+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2037.0ms","level":"info","span":"6aae64f427f86ff7","trace":"00da45983e36443e9f777f30a585b4b1"}
-{"@timestamp":"2026-06-26T16:27:15.378+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.0ms)","duration":"2045.0ms","level":"slow","span":"6b8087400d46fdbe","trace":"e88440e4c5d40115ab778df7f891b63b"}
-{"@timestamp":"2026-06-26T16:27:15.379+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.0ms","level":"info","span":"6b8087400d46fdbe","trace":"e88440e4c5d40115ab778df7f891b63b"}
-{"@timestamp":"2026-06-26T16:27:18.403+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=96.5Mi, Sys=19.6Mi, NumGC=53","level":"stat"}
-{"@timestamp":"2026-06-26T16:27:18.419+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T16:27:20.419+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2036.3ms)","duration":"2036.3ms","level":"slow","span":"2d12fc9931648644","trace":"fe8666bd3a3e3e655c6d80294372f311"}
-{"@timestamp":"2026-06-26T16:27:20.419+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2036.3ms","level":"info","span":"2d12fc9931648644","trace":"fe8666bd3a3e3e655c6d80294372f311"}
-{"@timestamp":"2026-06-26T16:27:21.034+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2042.3ms, med: 2037.8ms, 90th: 2065.4ms, 99th: 2065.4ms, 99.9th: 2065.4ms","level":"stat"}
-{"@timestamp":"2026-06-26T16:27:25.455+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2033.2ms)","duration":"2033.2ms","level":"slow","span":"760feb25bd3bc2e2","trace":"0a145688b7836383ea043b7fdd5818ed"}
-{"@timestamp":"2026-06-26T16:27:25.455+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2033.2ms","level":"info","span":"760feb25bd3bc2e2","trace":"0a145688b7836383ea043b7fdd5818ed"}
-{"@timestamp":"2026-06-26T16:27:30.475+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.0ms)","duration":"2016.0ms","level":"slow","span":"7f65be47a6d0c437","trace":"7e41cb3a1262ee545e81941d1aa9dd42"}
-{"@timestamp":"2026-06-26T16:27:30.475+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.0ms","level":"info","span":"7f65be47a6d0c437","trace":"7e41cb3a1262ee545e81941d1aa9dd42"}
-{"@timestamp":"2026-06-26T16:27:35.525+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2047.9ms)","duration":"2047.9ms","level":"slow","span":"142274e2384ded08","trace":"ce3021eac420f8466f3e9179012898aa"}
-{"@timestamp":"2026-06-26T16:27:35.526+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2047.9ms","level":"info","span":"142274e2384ded08","trace":"ce3021eac420f8466f3e9179012898aa"}
-{"@timestamp":"2026-06-26T16:27:40.574+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.6ms)","duration":"2044.6ms","level":"slow","span":"383ad6e47a9fbfa1","trace":"37b6f4974db7f834dde97c6b979eb0d4"}
-{"@timestamp":"2026-06-26T16:27:40.574+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.6ms","level":"info","span":"383ad6e47a9fbfa1","trace":"37b6f4974db7f834dde97c6b979eb0d4"}
-{"@timestamp":"2026-06-26T16:27:45.613+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2037.2ms)","duration":"2037.2ms","level":"slow","span":"4b21fe84bfed6f22","trace":"28bece1d7394b13e3bb81ea26a944b41"}
-{"@timestamp":"2026-06-26T16:27:45.613+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2037.2ms","level":"info","span":"4b21fe84bfed6f22","trace":"28bece1d7394b13e3bb81ea26a944b41"}
-{"@timestamp":"2026-06-26T16:27:50.652+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.9ms)","duration":"2035.9ms","level":"slow","span":"df2138e6ecb5407e","trace":"aaf91c8994056711c08e7e9bd526e173"}
-{"@timestamp":"2026-06-26T16:27:50.652+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.9ms","level":"info","span":"df2138e6ecb5407e","trace":"aaf91c8994056711c08e7e9bd526e173"}
-{"@timestamp":"2026-06-26T16:27:55.712+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2058.2ms)","duration":"2058.2ms","level":"slow","span":"ecd6626e7214afb1","trace":"342aaae8b0f4ef04835db9908bff2ab0"}
-{"@timestamp":"2026-06-26T16:27:55.712+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2058.2ms","level":"info","span":"ecd6626e7214afb1","trace":"342aaae8b0f4ef04835db9908bff2ab0"}
-{"@timestamp":"2026-06-26T16:28:00.802+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2086.8ms)","duration":"2086.8ms","level":"slow","span":"9d1ad654a864bd63","trace":"b196f7ee03ea176c4cbb375506642c3b"}
-{"@timestamp":"2026-06-26T16:28:00.803+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2086.8ms","level":"info","span":"9d1ad654a864bd63","trace":"b196f7ee03ea176c4cbb375506642c3b"}
-{"@timestamp":"2026-06-26T16:28:05.840+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2033.4ms)","duration":"2033.4ms","level":"slow","span":"46be56f159994912","trace":"28aaa235d351114c9473d8ea858ee909"}
-{"@timestamp":"2026-06-26T16:28:05.840+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2033.4ms","level":"info","span":"46be56f159994912","trace":"28aaa235d351114c9473d8ea858ee909"}
-{"@timestamp":"2026-06-26T16:28:10.868+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2026.4ms)","duration":"2026.4ms","level":"slow","span":"038cc73a29d3dee3","trace":"64c161f9357e0d714e329f25f0776f8e"}
-{"@timestamp":"2026-06-26T16:28:10.868+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2026.4ms","level":"info","span":"038cc73a29d3dee3","trace":"64c161f9357e0d714e329f25f0776f8e"}
-{"@timestamp":"2026-06-26T16:28:15.953+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2081.4ms)","duration":"2081.4ms","level":"slow","span":"f9dd1cc3c1d0076b","trace":"1a91ca859c41d75c0b2eb8c2939282f0"}
-{"@timestamp":"2026-06-26T16:28:15.954+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2081.4ms","level":"info","span":"f9dd1cc3c1d0076b","trace":"1a91ca859c41d75c0b2eb8c2939282f0"}
-{"@timestamp":"2026-06-26T16:28:18.398+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=96.6Mi, Sys=19.6Mi, NumGC=53","level":"stat"}
-{"@timestamp":"2026-06-26T16:28:18.416+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T16:28:20.992+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.0ms)","duration":"2035.0ms","level":"slow","span":"b12127d5a8aa91c4","trace":"fcc2f7077c8d5aa107372855e8f6fc32"}
-{"@timestamp":"2026-06-26T16:28:20.992+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.0ms","level":"info","span":"b12127d5a8aa91c4","trace":"fcc2f7077c8d5aa107372855e8f6fc32"}
-{"@timestamp":"2026-06-26T16:28:21.030+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2044.5ms, med: 2037.1ms, 90th: 2086.7ms, 99th: 2086.7ms, 99.9th: 2086.7ms","level":"stat"}
-{"@timestamp":"2026-06-26T16:28:26.046+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2050.7ms)","duration":"2050.7ms","level":"slow","span":"169a68512d5dfdd2","trace":"c138d8807c20b554279cf948641a864f"}
-{"@timestamp":"2026-06-26T16:28:26.046+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2050.7ms","level":"info","span":"169a68512d5dfdd2","trace":"c138d8807c20b554279cf948641a864f"}
-{"@timestamp":"2026-06-26T16:28:31.145+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2095.6ms)","duration":"2095.6ms","level":"slow","span":"cea862b633ead120","trace":"c167136eb24f5e6ff2f8eea9b100b6f3"}
-{"@timestamp":"2026-06-26T16:28:31.145+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2095.6ms","level":"info","span":"cea862b633ead120","trace":"c167136eb24f5e6ff2f8eea9b100b6f3"}
-{"@timestamp":"2026-06-26T16:28:36.222+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2071.6ms)","duration":"2071.6ms","level":"slow","span":"983a32a6855daab7","trace":"4a2ddb22d5250efde27ac7eda9fdaa68"}
-{"@timestamp":"2026-06-26T16:28:36.222+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2071.6ms","level":"info","span":"983a32a6855daab7","trace":"4a2ddb22d5250efde27ac7eda9fdaa68"}
-{"@timestamp":"2026-06-26T16:28:41.297+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2073.1ms)","duration":"2073.1ms","level":"slow","span":"64691003a79c80aa","trace":"cd90dc075cfa88bf6aaf51e09c857e57"}
-{"@timestamp":"2026-06-26T16:28:41.297+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2073.1ms","level":"info","span":"64691003a79c80aa","trace":"cd90dc075cfa88bf6aaf51e09c857e57"}
-{"@timestamp":"2026-06-26T16:28:46.370+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2069.1ms)","duration":"2069.1ms","level":"slow","span":"6bb55e72dd6af6a4","trace":"bd76d0b2b64ef1f4079cfeb59eebaa67"}
-{"@timestamp":"2026-06-26T16:28:46.370+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2069.1ms","level":"info","span":"6bb55e72dd6af6a4","trace":"bd76d0b2b64ef1f4079cfeb59eebaa67"}
-{"@timestamp":"2026-06-26T16:28:51.438+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2064.0ms)","duration":"2064.0ms","level":"slow","span":"9b346fb0611e3156","trace":"db1fd71a47fbea9a0748a83097464c31"}
-{"@timestamp":"2026-06-26T16:28:51.439+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2064.0ms","level":"info","span":"9b346fb0611e3156","trace":"db1fd71a47fbea9a0748a83097464c31"}
-{"@timestamp":"2026-06-26T16:28:56.525+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2083.4ms)","duration":"2083.4ms","level":"slow","span":"91b1316259153ed0","trace":"06e8b6b8818dba37f0c448755b022e88"}
-{"@timestamp":"2026-06-26T16:28:56.525+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2083.4ms","level":"info","span":"91b1316259153ed0","trace":"06e8b6b8818dba37f0c448755b022e88"}
-{"@timestamp":"2026-06-26T16:29:01.562+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2033.5ms)","duration":"2033.5ms","level":"slow","span":"e46d9d1f129d2cd8","trace":"6cfc3ab84f534b0508819fa1040b2299"}
-{"@timestamp":"2026-06-26T16:29:01.562+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2033.5ms","level":"info","span":"e46d9d1f129d2cd8","trace":"6cfc3ab84f534b0508819fa1040b2299"}
-{"@timestamp":"2026-06-26T16:29:06.621+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2055.5ms)","duration":"2055.5ms","level":"slow","span":"c7605a8ffecd22f2","trace":"76bf2b90f0df51d8596a9b4fc9a0f447"}
-{"@timestamp":"2026-06-26T16:29:06.621+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2055.5ms","level":"info","span":"c7605a8ffecd22f2","trace":"76bf2b90f0df51d8596a9b4fc9a0f447"}
-{"@timestamp":"2026-06-26T16:29:11.704+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2081.0ms)","duration":"2081.0ms","level":"slow","span":"9f808bed70b55a92","trace":"384457950382faea29809b7db134cb2b"}
-{"@timestamp":"2026-06-26T16:29:11.704+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2081.0ms","level":"info","span":"9f808bed70b55a92","trace":"384457950382faea29809b7db134cb2b"}
-{"@timestamp":"2026-06-26T16:29:16.783+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2075.1ms)","duration":"2075.1ms","level":"slow","span":"1643f4c3ca322912","trace":"a019e08b4b107293d38e853e108e58d7"}
-{"@timestamp":"2026-06-26T16:29:16.784+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2075.1ms","level":"info","span":"1643f4c3ca322912","trace":"a019e08b4b107293d38e853e108e58d7"}
-{"@timestamp":"2026-06-26T16:29:18.396+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=97.2Mi, Sys=19.6Mi, NumGC=54","level":"stat"}
-{"@timestamp":"2026-06-26T16:29:18.412+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T16:29:21.026+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2068.3ms, med: 2073.0ms, 90th: 2095.4ms, 99th: 2095.4ms, 99.9th: 2095.4ms","level":"stat"}
-{"@timestamp":"2026-06-26T16:29:21.816+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.0ms)","duration":"2027.0ms","level":"slow","span":"4bf63e40ffe72914","trace":"8e30c569f54a16a274a8fa7bc92dea6d"}
-{"@timestamp":"2026-06-26T16:29:21.816+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.0ms","level":"info","span":"4bf63e40ffe72914","trace":"8e30c569f54a16a274a8fa7bc92dea6d"}
-{"@timestamp":"2026-06-26T16:29:26.890+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2069.0ms)","duration":"2069.0ms","level":"slow","span":"167f7f7f6d92bfa5","trace":"407ee544c479c129b0cf3c781a5dec8e"}
-{"@timestamp":"2026-06-26T16:29:26.891+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2069.0ms","level":"info","span":"167f7f7f6d92bfa5","trace":"407ee544c479c129b0cf3c781a5dec8e"}
-{"@timestamp":"2026-06-26T16:29:31.933+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2039.8ms)","duration":"2039.8ms","level":"slow","span":"ce19415e846a6f32","trace":"03625bdc33b0693a369c98f5841b5d34"}
-{"@timestamp":"2026-06-26T16:29:31.933+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2039.8ms","level":"info","span":"ce19415e846a6f32","trace":"03625bdc33b0693a369c98f5841b5d34"}
-{"@timestamp":"2026-06-26T16:29:36.989+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2053.1ms)","duration":"2053.1ms","level":"slow","span":"5cb39908b73dabed","trace":"e3b9d563e8b54d7a297b39b31fb77e79"}
-{"@timestamp":"2026-06-26T16:29:36.989+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2053.1ms","level":"info","span":"5cb39908b73dabed","trace":"e3b9d563e8b54d7a297b39b31fb77e79"}
-{"@timestamp":"2026-06-26T16:29:42.074+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2081.4ms)","duration":"2081.4ms","level":"slow","span":"b75d2534f57064a6","trace":"3eec4fb36af3b7b28451e595a82b1543"}
-{"@timestamp":"2026-06-26T16:29:42.074+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2081.4ms","level":"info","span":"b75d2534f57064a6","trace":"3eec4fb36af3b7b28451e595a82b1543"}
-{"@timestamp":"2026-06-26T16:29:47.114+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.6ms)","duration":"2035.6ms","level":"slow","span":"aa0c83a7c874817a","trace":"57ff16f8719e8b27d1a1b120f071cbb7"}
-{"@timestamp":"2026-06-26T16:29:47.114+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.6ms","level":"info","span":"aa0c83a7c874817a","trace":"57ff16f8719e8b27d1a1b120f071cbb7"}
-{"@timestamp":"2026-06-26T16:29:52.219+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2100.0ms)","duration":"2100.0ms","level":"slow","span":"958b76938b2a1288","trace":"b00ea6b015c769e83a8257db6ae2849b"}
-{"@timestamp":"2026-06-26T16:29:52.219+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2100.0ms","level":"info","span":"958b76938b2a1288","trace":"b00ea6b015c769e83a8257db6ae2849b"}
-{"@timestamp":"2026-06-26T16:29:57.300+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2076.3ms)","duration":"2076.3ms","level":"slow","span":"fc1e10c4b643541b","trace":"b83ea9fa1b4c45a31c87f7308aff2507"}
-{"@timestamp":"2026-06-26T16:29:57.300+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2076.3ms","level":"info","span":"fc1e10c4b643541b","trace":"b83ea9fa1b4c45a31c87f7308aff2507"}
-{"@timestamp":"2026-06-26T16:30:02.395+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2090.7ms)","duration":"2090.7ms","level":"slow","span":"72853ca4ad4d4c13","trace":"9cbe19d5badb15cf28f03fafba1d8ed5"}
-{"@timestamp":"2026-06-26T16:30:02.395+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2090.7ms","level":"info","span":"72853ca4ad4d4c13","trace":"9cbe19d5badb15cf28f03fafba1d8ed5"}
-{"@timestamp":"2026-06-26T16:30:07.436+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2039.3ms)","duration":"2039.3ms","level":"slow","span":"351cc75e58ef560a","trace":"3b49cd7601de9b91214fe0c4f3b9400b"}
-{"@timestamp":"2026-06-26T16:30:07.437+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2039.3ms","level":"info","span":"351cc75e58ef560a","trace":"3b49cd7601de9b91214fe0c4f3b9400b"}
-{"@timestamp":"2026-06-26T16:30:12.481+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2041.2ms)","duration":"2041.2ms","level":"slow","span":"fe14bd7edacc123b","trace":"75c43a72f9c6c57b75b33a88e4536355"}
-{"@timestamp":"2026-06-26T16:30:12.481+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2041.2ms","level":"info","span":"fe14bd7edacc123b","trace":"75c43a72f9c6c57b75b33a88e4536355"}
-{"@timestamp":"2026-06-26T16:30:17.552+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2066.0ms)","duration":"2066.0ms","level":"slow","span":"1fc3cefb2140e0b3","trace":"137ae67ca6af96f4e399327e41f07555"}
-{"@timestamp":"2026-06-26T16:30:17.552+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2066.0ms","level":"info","span":"1fc3cefb2140e0b3","trace":"137ae67ca6af96f4e399327e41f07555"}
-{"@timestamp":"2026-06-26T16:30:18.392+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=97.4Mi, Sys=19.6Mi, NumGC=54","level":"stat"}
-{"@timestamp":"2026-06-26T16:30:18.408+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T16:30:21.023+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2059.8ms, med: 2065.9ms, 90th: 2099.9ms, 99th: 2099.9ms, 99.9th: 2099.9ms","level":"stat"}
-{"@timestamp":"2026-06-26T16:30:22.602+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.3ms)","duration":"2045.3ms","level":"slow","span":"74145824038985db","trace":"ed5c5994f8eb85b23cb1314ce9a31200"}
-{"@timestamp":"2026-06-26T16:30:22.602+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.3ms","level":"info","span":"74145824038985db","trace":"ed5c5994f8eb85b23cb1314ce9a31200"}
-{"@timestamp":"2026-06-26T16:30:27.615+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2008.6ms)","duration":"2008.6ms","level":"slow","span":"7ba37cc211faa83a","trace":"48329f5b4d5c1e59b900c06df6a7e0dd"}
-{"@timestamp":"2026-06-26T16:30:27.615+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2008.6ms","level":"info","span":"7ba37cc211faa83a","trace":"48329f5b4d5c1e59b900c06df6a7e0dd"}
-{"@timestamp":"2026-06-26T16:30:32.700+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2081.6ms)","duration":"2081.6ms","level":"slow","span":"b39dc365701b2356","trace":"6aa6a6ffec5f98286e27ef65e9b611d6"}
-{"@timestamp":"2026-06-26T16:30:32.700+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2081.6ms","level":"info","span":"b39dc365701b2356","trace":"6aa6a6ffec5f98286e27ef65e9b611d6"}
-{"@timestamp":"2026-06-26T16:30:37.756+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2051.4ms)","duration":"2051.4ms","level":"slow","span":"726427d14ef96459","trace":"c5e170d25301e6d49282f50da9b1415a"}
-{"@timestamp":"2026-06-26T16:30:37.756+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2051.4ms","level":"info","span":"726427d14ef96459","trace":"c5e170d25301e6d49282f50da9b1415a"}
-{"@timestamp":"2026-06-26T16:30:42.837+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2076.8ms)","duration":"2076.8ms","level":"slow","span":"9c8205fe7715d036","trace":"d2c5600e0621725f81d2c9386df2181d"}
-{"@timestamp":"2026-06-26T16:30:42.837+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2076.8ms","level":"info","span":"9c8205fe7715d036","trace":"d2c5600e0621725f81d2c9386df2181d"}
-{"@timestamp":"2026-06-26T16:30:47.876+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2035.7ms)","duration":"2035.7ms","level":"slow","span":"43b86448a33fb1f9","trace":"6979a3cff2dbcafdb7975d6537a923f2"}
-{"@timestamp":"2026-06-26T16:30:47.876+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2035.7ms","level":"info","span":"43b86448a33fb1f9","trace":"6979a3cff2dbcafdb7975d6537a923f2"}
-{"@timestamp":"2026-06-26T16:30:52.899+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2018.9ms)","duration":"2018.9ms","level":"slow","span":"c4c246dc53319ebc","trace":"8f27fbf814bc03243c84404e9a195754"}
-{"@timestamp":"2026-06-26T16:30:52.900+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2018.9ms","level":"info","span":"c4c246dc53319ebc","trace":"8f27fbf814bc03243c84404e9a195754"}
-{"@timestamp":"2026-06-26T16:30:57.971+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2066.3ms)","duration":"2066.3ms","level":"slow","span":"b445e096fbf91005","trace":"031b80d7bfcdf25d7fb1137bb6cd8c97"}
-{"@timestamp":"2026-06-26T16:30:57.971+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2066.3ms","level":"info","span":"b445e096fbf91005","trace":"031b80d7bfcdf25d7fb1137bb6cd8c97"}
-{"@timestamp":"2026-06-26T16:31:03.035+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2059.9ms)","duration":"2059.9ms","level":"slow","span":"2d7b3a8f7e16a7f0","trace":"6a1cab8a1700763c491e09b98b9ae18d"}
-{"@timestamp":"2026-06-26T16:31:03.035+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2059.9ms","level":"info","span":"2d7b3a8f7e16a7f0","trace":"6a1cab8a1700763c491e09b98b9ae18d"}
-{"@timestamp":"2026-06-26T16:31:08.111+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2073.5ms)","duration":"2073.5ms","level":"slow","span":"ab45af367d074c9c","trace":"a313fc2c441d9cdee836c0fd37b8dd95"}
-{"@timestamp":"2026-06-26T16:31:08.111+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2073.5ms","level":"info","span":"ab45af367d074c9c","trace":"a313fc2c441d9cdee836c0fd37b8dd95"}
-{"@timestamp":"2026-06-26T16:31:13.185+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2071.5ms)","duration":"2071.5ms","level":"slow","span":"38fb7bac65b71630","trace":"23c405a00c7b2c02772bc1a3e96052f0"}
-{"@timestamp":"2026-06-26T16:31:13.185+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2071.5ms","level":"info","span":"38fb7bac65b71630","trace":"23c405a00c7b2c02772bc1a3e96052f0"}
-{"@timestamp":"2026-06-26T16:31:18.270+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2080.8ms)","duration":"2080.8ms","level":"slow","span":"3f02ba91ae4101f3","trace":"99b86ff94f764f311e8ba4a1ba94ca76"}
-{"@timestamp":"2026-06-26T16:31:18.270+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2080.8ms","level":"info","span":"3f02ba91ae4101f3","trace":"99b86ff94f764f311e8ba4a1ba94ca76"}
-{"@timestamp":"2026-06-26T16:31:18.388+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=97.9Mi, Sys=19.6Mi, NumGC=55","level":"stat"}
-{"@timestamp":"2026-06-26T16:31:18.405+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T16:31:21.019+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2055.7ms, med: 2066.1ms, 90th: 2081.5ms, 99th: 2081.5ms, 99.9th: 2081.5ms","level":"stat"}
-{"@timestamp":"2026-06-26T16:31:23.533+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2260.0ms)","duration":"2260.0ms","level":"slow","span":"c7e372c368d355a5","trace":"a904d4779306896b3d1e41fb0057c82f"}
-{"@timestamp":"2026-06-26T16:31:23.534+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2260.0ms","level":"info","span":"c7e372c368d355a5","trace":"a904d4779306896b3d1e41fb0057c82f"}
-{"@timestamp":"2026-06-26T16:31:28.744+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2206.2ms)","duration":"2206.2ms","level":"slow","span":"592ef905ca7324f2","trace":"cdfbbd30e0704fccd3962d4ee31cf606"}
-{"@timestamp":"2026-06-26T16:31:28.744+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2206.2ms","level":"info","span":"592ef905ca7324f2","trace":"cdfbbd30e0704fccd3962d4ee31cf606"}
-{"@timestamp":"2026-06-26T16:31:33.943+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2194.2ms)","duration":"2194.2ms","level":"slow","span":"6af2bc0cd1e8f439","trace":"6258eae5fe6f5257e88240f5fbfcc5d6"}
-{"@timestamp":"2026-06-26T16:31:33.943+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2194.2ms","level":"info","span":"6af2bc0cd1e8f439","trace":"6258eae5fe6f5257e88240f5fbfcc5d6"}
-{"@timestamp":"2026-06-26T16:31:39.153+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2206.0ms)","duration":"2206.0ms","level":"slow","span":"c9f5b387b51edae0","trace":"323b23118e2533b1e36b55b42c974b23"}
-{"@timestamp":"2026-06-26T16:31:39.153+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2206.0ms","level":"info","span":"c9f5b387b51edae0","trace":"323b23118e2533b1e36b55b42c974b23"}
-{"@timestamp":"2026-06-26T16:31:44.351+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2194.2ms)","duration":"2194.2ms","level":"slow","span":"d7e030f8237d186a","trace":"09103848f5feb4eea0f35e1381926ab2"}
-{"@timestamp":"2026-06-26T16:31:44.351+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2194.2ms","level":"info","span":"d7e030f8237d186a","trace":"09103848f5feb4eea0f35e1381926ab2"}
-{"@timestamp":"2026-06-26T16:31:49.399+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2045.3ms)","duration":"2045.3ms","level":"slow","span":"10054041b1c606af","trace":"f387cb84cb712de12bf111d4ff4010dc"}
-{"@timestamp":"2026-06-26T16:31:49.399+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2045.3ms","level":"info","span":"10054041b1c606af","trace":"f387cb84cb712de12bf111d4ff4010dc"}
-{"@timestamp":"2026-06-26T16:31:54.436+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.1ms)","duration":"2034.1ms","level":"slow","span":"4b6d3156bba6a48e","trace":"b4ee23577054d262476d403575d64532"}
-{"@timestamp":"2026-06-26T16:31:54.436+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.1ms","level":"info","span":"4b6d3156bba6a48e","trace":"b4ee23577054d262476d403575d64532"}
-{"@timestamp":"2026-06-26T16:31:59.476+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.5ms)","duration":"2034.5ms","level":"slow","span":"dbf6d062ca709f7c","trace":"aa6f776078319322f684ae8a2bf2edc1"}
-{"@timestamp":"2026-06-26T16:31:59.476+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.5ms","level":"info","span":"dbf6d062ca709f7c","trace":"aa6f776078319322f684ae8a2bf2edc1"}
-{"@timestamp":"2026-06-26T16:32:04.569+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2086.8ms)","duration":"2086.8ms","level":"slow","span":"0c82d753b1f7260a","trace":"b92a3cc449f428b6eafe60adc946e968"}
-{"@timestamp":"2026-06-26T16:32:04.569+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2086.8ms","level":"info","span":"0c82d753b1f7260a","trace":"b92a3cc449f428b6eafe60adc946e968"}
-{"@timestamp":"2026-06-26T16:32:09.633+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2060.5ms)","duration":"2060.5ms","level":"slow","span":"25eac505cef53d6e","trace":"e908481265cc593b86149ceb301f56cc"}
-{"@timestamp":"2026-06-26T16:32:09.633+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2060.5ms","level":"info","span":"25eac505cef53d6e","trace":"e908481265cc593b86149ceb301f56cc"}
-{"@timestamp":"2026-06-26T16:32:14.682+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.9ms)","duration":"2044.9ms","level":"slow","span":"74307c7851d88ba7","trace":"7dba01655b3f241d8f75b727966a9a17"}
-{"@timestamp":"2026-06-26T16:32:14.682+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.9ms","level":"info","span":"74307c7851d88ba7","trace":"7dba01655b3f241d8f75b727966a9a17"}
-{"@timestamp":"2026-06-26T16:32:18.385+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=98.0Mi, Sys=19.6Mi, NumGC=55","level":"stat"}
-{"@timestamp":"2026-06-26T16:32:18.401+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 11, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T16:32:19.745+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2059.7ms)","duration":"2059.7ms","level":"slow","span":"bef12da237b78fef","trace":"286e8c18541b89e185104dce69231ac0"}
-{"@timestamp":"2026-06-26T16:32:19.745+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2059.7ms","level":"info","span":"bef12da237b78fef","trace":"286e8c18541b89e185104dce69231ac0"}
-{"@timestamp":"2026-06-26T16:32:21.015+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2118.7ms, med: 2086.7ms, 90th: 2259.9ms, 99th: 2259.9ms, 99.9th: 2259.9ms","level":"stat"}
-{"@timestamp":"2026-06-26T16:32:24.810+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2060.3ms)","duration":"2060.3ms","level":"slow","span":"4e3b42c844b45082","trace":"8be02509dfcce05482e39e2a2eeba02a"}
-{"@timestamp":"2026-06-26T16:32:24.810+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2060.3ms","level":"info","span":"4e3b42c844b45082","trace":"8be02509dfcce05482e39e2a2eeba02a"}
-{"@timestamp":"2026-06-26T16:32:29.852+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2038.6ms)","duration":"2038.6ms","level":"slow","span":"4d1d35a9ec2d91ae","trace":"81224e0ecd11eb62f05e2865dd614520"}
-{"@timestamp":"2026-06-26T16:32:29.853+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2038.6ms","level":"info","span":"4d1d35a9ec2d91ae","trace":"81224e0ecd11eb62f05e2865dd614520"}
-{"@timestamp":"2026-06-26T16:32:34.896+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2038.7ms)","duration":"2038.7ms","level":"slow","span":"c3505f0df6603dba","trace":"53f511cc0976f5ae08764a8112a959d9"}
-{"@timestamp":"2026-06-26T16:32:34.896+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2038.7ms","level":"info","span":"c3505f0df6603dba","trace":"53f511cc0976f5ae08764a8112a959d9"}
-{"@timestamp":"2026-06-26T16:32:39.931+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.5ms)","duration":"2031.5ms","level":"slow","span":"40aed877f5912dd1","trace":"368cf73126889db6fde99b51b526484a"}
-{"@timestamp":"2026-06-26T16:32:39.931+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.5ms","level":"info","span":"40aed877f5912dd1","trace":"368cf73126889db6fde99b51b526484a"}
-{"@timestamp":"2026-06-26T16:32:44.976+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2040.7ms)","duration":"2040.7ms","level":"slow","span":"f97e97027270f045","trace":"15fe840ab3221075f9bb6fc278e63270"}
-{"@timestamp":"2026-06-26T16:32:44.976+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2040.7ms","level":"info","span":"f97e97027270f045","trace":"15fe840ab3221075f9bb6fc278e63270"}
-{"@timestamp":"2026-06-26T16:32:50.041+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2061.3ms)","duration":"2061.3ms","level":"slow","span":"4f384784c48a77a5","trace":"cb8e5954a08e76e10c0ff3f2845a4961"}
-{"@timestamp":"2026-06-26T16:32:50.042+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2061.3ms","level":"info","span":"4f384784c48a77a5","trace":"cb8e5954a08e76e10c0ff3f2845a4961"}
-{"@timestamp":"2026-06-26T16:32:55.119+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2073.9ms)","duration":"2073.9ms","level":"slow","span":"b7cce025d3acf42d","trace":"efae06cf98a58f0151797f100fb5166c"}
-{"@timestamp":"2026-06-26T16:32:55.119+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2073.9ms","level":"info","span":"b7cce025d3acf42d","trace":"efae06cf98a58f0151797f100fb5166c"}
-{"@timestamp":"2026-06-26T16:33:00.202+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2078.9ms)","duration":"2078.9ms","level":"slow","span":"d7f9e80d5f00ba66","trace":"4a1e5b0b0fbb9ed3cd05839e99b32943"}
-{"@timestamp":"2026-06-26T16:33:00.202+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2078.9ms","level":"info","span":"d7f9e80d5f00ba66","trace":"4a1e5b0b0fbb9ed3cd05839e99b32943"}
-{"@timestamp":"2026-06-26T16:33:05.241+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.6ms)","duration":"2034.6ms","level":"slow","span":"c9ae3f1afc429c51","trace":"16852fc0fec6616e28be4a61e90d5274"}
-{"@timestamp":"2026-06-26T16:33:05.241+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.6ms","level":"info","span":"c9ae3f1afc429c51","trace":"16852fc0fec6616e28be4a61e90d5274"}
-{"@timestamp":"2026-06-26T16:33:10.292+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2047.7ms)","duration":"2047.7ms","level":"slow","span":"83cb07ea9be9bba4","trace":"651d24247cfbd62bf654b65608467356"}
-{"@timestamp":"2026-06-26T16:33:10.292+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2047.7ms","level":"info","span":"83cb07ea9be9bba4","trace":"651d24247cfbd62bf654b65608467356"}
-{"@timestamp":"2026-06-26T16:33:15.367+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2070.1ms)","duration":"2070.1ms","level":"slow","span":"824d0a81c0488cbe","trace":"a3b7c0e1df8b4a3bcf92f37fa46b1c7f"}
-{"@timestamp":"2026-06-26T16:33:15.367+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2070.1ms","level":"info","span":"824d0a81c0488cbe","trace":"a3b7c0e1df8b4a3bcf92f37fa46b1c7f"}
-{"@timestamp":"2026-06-26T16:33:18.381+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.4Mi, TotalAlloc=98.6Mi, Sys=19.6Mi, NumGC=56","level":"stat"}
-{"@timestamp":"2026-06-26T16:33:18.398+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T16:33:20.405+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2034.4ms)","duration":"2034.4ms","level":"slow","span":"b4f3548245326533","trace":"503f0e05cbb457fa4e5c6397ee842338"}
-{"@timestamp":"2026-06-26T16:33:20.405+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2034.4ms","level":"info","span":"b4f3548245326533","trace":"503f0e05cbb457fa4e5c6397ee842338"}
-{"@timestamp":"2026-06-26T16:33:21.012+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2050.8ms, med: 2047.6ms, 90th: 2078.7ms, 99th: 2078.7ms, 99.9th: 2078.7ms","level":"stat"}
-{"@timestamp":"2026-06-26T16:33:25.431+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.9ms)","duration":"2021.9ms","level":"slow","span":"5097303d1b5dd758","trace":"27d4b88de9dde69e22202336c3c2638e"}
-{"@timestamp":"2026-06-26T16:33:25.432+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.9ms","level":"info","span":"5097303d1b5dd758","trace":"27d4b88de9dde69e22202336c3c2638e"}
-{"@timestamp":"2026-06-26T16:33:30.484+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2049.3ms)","duration":"2049.3ms","level":"slow","span":"6c1d32088a0205b2","trace":"d0b37caf322de61927b279a41bf3074f"}
-{"@timestamp":"2026-06-26T16:33:30.484+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2049.3ms","level":"info","span":"6c1d32088a0205b2","trace":"d0b37caf322de61927b279a41bf3074f"}
-{"@timestamp":"2026-06-26T16:33:35.548+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2061.7ms)","duration":"2061.7ms","level":"slow","span":"d98edb5f541daeee","trace":"e54242e93005a61c461da92a02f09b6b"}
-{"@timestamp":"2026-06-26T16:33:35.548+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2061.7ms","level":"info","span":"d98edb5f541daeee","trace":"e54242e93005a61c461da92a02f09b6b"}
-{"@timestamp":"2026-06-26T16:33:40.615+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2062.4ms)","duration":"2062.4ms","level":"slow","span":"a5a4a0910933f764","trace":"9bf36cb2f52e376c581e2d34759178f6"}
-{"@timestamp":"2026-06-26T16:33:40.615+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2062.4ms","level":"info","span":"a5a4a0910933f764","trace":"9bf36cb2f52e376c581e2d34759178f6"}
-{"@timestamp":"2026-06-26T16:33:45.629+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2011.4ms)","duration":"2011.4ms","level":"slow","span":"2e15a2a3f5276917","trace":"5460e12bfa6222c9c70a29e8536189ce"}
-{"@timestamp":"2026-06-26T16:33:45.629+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2011.4ms","level":"info","span":"2e15a2a3f5276917","trace":"5460e12bfa6222c9c70a29e8536189ce"}
-{"@timestamp":"2026-06-26T16:33:50.712+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2078.6ms)","duration":"2078.6ms","level":"slow","span":"fe26ed180c4f093a","trace":"079417b14db9db0d31a9459d869f4215"}
-{"@timestamp":"2026-06-26T16:33:50.712+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2078.6ms","level":"info","span":"fe26ed180c4f093a","trace":"079417b14db9db0d31a9459d869f4215"}
-{"@timestamp":"2026-06-26T16:33:55.747+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2030.5ms)","duration":"2030.5ms","level":"slow","span":"f7b333194925a8e3","trace":"5dcfbe1bb69c07b973f0eedd5fd6e093"}
-{"@timestamp":"2026-06-26T16:33:55.747+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2030.5ms","level":"info","span":"f7b333194925a8e3","trace":"5dcfbe1bb69c07b973f0eedd5fd6e093"}
-{"@timestamp":"2026-06-26T16:34:00.818+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2067.8ms)","duration":"2067.8ms","level":"slow","span":"c846967b2154d694","trace":"3c69c11221d5ae131c0e7f9e372b3ead"}
-{"@timestamp":"2026-06-26T16:34:00.818+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2067.8ms","level":"info","span":"c846967b2154d694","trace":"3c69c11221d5ae131c0e7f9e372b3ead"}
-{"@timestamp":"2026-06-26T16:34:05.850+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.9ms)","duration":"2027.9ms","level":"slow","span":"e9fda9212f9f39e9","trace":"890e1c5ef983075c364b1a481e0d3eca"}
-{"@timestamp":"2026-06-26T16:34:05.850+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.9ms","level":"info","span":"e9fda9212f9f39e9","trace":"890e1c5ef983075c364b1a481e0d3eca"}
-{"@timestamp":"2026-06-26T16:34:10.908+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2055.2ms)","duration":"2055.2ms","level":"slow","span":"4a467d1095a546e6","trace":"a5f28da5bf3c998216d53ab99954371f"}
-{"@timestamp":"2026-06-26T16:34:10.909+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2055.2ms","level":"info","span":"4a467d1095a546e6","trace":"a5f28da5bf3c998216d53ab99954371f"}
-{"@timestamp":"2026-06-26T16:34:15.970+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2057.4ms)","duration":"2057.4ms","level":"slow","span":"396b2ec4ee6c8a01","trace":"dab54e6a56774729067e5ff6eb833aa0"}
-{"@timestamp":"2026-06-26T16:34:15.971+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2057.4ms","level":"info","span":"396b2ec4ee6c8a01","trace":"dab54e6a56774729067e5ff6eb833aa0"}
-{"@timestamp":"2026-06-26T16:34:18.377+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=98.7Mi, Sys=19.6Mi, NumGC=56","level":"stat"}
-{"@timestamp":"2026-06-26T16:34:18.394+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 11, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T16:34:21.008+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2047.5ms, med: 2057.2ms, 90th: 2078.5ms, 99th: 2078.5ms, 99.9th: 2078.5ms","level":"stat"}
-{"@timestamp":"2026-06-26T16:34:21.027+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2052.8ms)","duration":"2052.8ms","level":"slow","span":"f58ee85275fffc8d","trace":"3baf52014123b4733e5fef92be32bd59"}
-{"@timestamp":"2026-06-26T16:34:21.027+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2052.8ms","level":"info","span":"f58ee85275fffc8d","trace":"3baf52014123b4733e5fef92be32bd59"}
-{"@timestamp":"2026-06-26T16:34:26.082+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2051.2ms)","duration":"2051.2ms","level":"slow","span":"658f53f1e99ae33f","trace":"7681071c8137457bdb8a6a2a7258242e"}
-{"@timestamp":"2026-06-26T16:34:26.082+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2051.2ms","level":"info","span":"658f53f1e99ae33f","trace":"7681071c8137457bdb8a6a2a7258242e"}
-{"@timestamp":"2026-06-26T16:34:31.117+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2031.1ms)","duration":"2031.1ms","level":"slow","span":"b71272fe73b9c192","trace":"7554eccc31e426fe92f4775e740742ac"}
-{"@timestamp":"2026-06-26T16:34:31.117+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2031.1ms","level":"info","span":"b71272fe73b9c192","trace":"7554eccc31e426fe92f4775e740742ac"}
-{"@timestamp":"2026-06-26T16:34:36.180+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2058.8ms)","duration":"2058.8ms","level":"slow","span":"f0a549033a7364c9","trace":"57f11b8c35aa8b446afe5151cea075ae"}
-{"@timestamp":"2026-06-26T16:34:36.181+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2058.8ms","level":"info","span":"f0a549033a7364c9","trace":"57f11b8c35aa8b446afe5151cea075ae"}
-{"@timestamp":"2026-06-26T16:34:41.237+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2052.0ms)","duration":"2052.0ms","level":"slow","span":"b9af5e61083906c7","trace":"7a215521352417575784636f6c46c9fe"}
-{"@timestamp":"2026-06-26T16:34:41.237+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2052.0ms","level":"info","span":"b9af5e61083906c7","trace":"7a215521352417575784636f6c46c9fe"}
-{"@timestamp":"2026-06-26T16:34:46.296+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2054.1ms)","duration":"2054.1ms","level":"slow","span":"3a6ec2fe1fea636b","trace":"9550f2962a01379cd02ac0cde27c227e"}
-{"@timestamp":"2026-06-26T16:34:46.296+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2054.1ms","level":"info","span":"3a6ec2fe1fea636b","trace":"9550f2962a01379cd02ac0cde27c227e"}
-{"@timestamp":"2026-06-26T16:34:51.363+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2062.4ms)","duration":"2062.4ms","level":"slow","span":"0e5966f9cf0aedba","trace":"40849dbec535d573e7a8c9fc0819f341"}
-{"@timestamp":"2026-06-26T16:34:51.363+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2062.4ms","level":"info","span":"0e5966f9cf0aedba","trace":"40849dbec535d573e7a8c9fc0819f341"}
-{"@timestamp":"2026-06-26T16:34:56.455+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2087.4ms)","duration":"2087.4ms","level":"slow","span":"f852785c23549d78","trace":"ccb0357f19282c7d8788fd35cc3b6f4e"}
-{"@timestamp":"2026-06-26T16:34:56.455+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2087.4ms","level":"info","span":"f852785c23549d78","trace":"ccb0357f19282c7d8788fd35cc3b6f4e"}
-{"@timestamp":"2026-06-26T16:35:01.514+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2056.6ms)","duration":"2056.6ms","level":"slow","span":"3d48548b73164b1e","trace":"f0caf8657ce79bc056636a9479e862e6"}
-{"@timestamp":"2026-06-26T16:35:01.515+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2056.6ms","level":"info","span":"3d48548b73164b1e","trace":"f0caf8657ce79bc056636a9479e862e6"}
-{"@timestamp":"2026-06-26T16:35:06.580+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2061.9ms)","duration":"2061.9ms","level":"slow","span":"627aff1ec96140b8","trace":"9d57c99d78d5430486b7838b31c48d1f"}
-{"@timestamp":"2026-06-26T16:35:06.580+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2061.9ms","level":"info","span":"627aff1ec96140b8","trace":"9d57c99d78d5430486b7838b31c48d1f"}
-{"@timestamp":"2026-06-26T16:35:11.655+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2071.4ms)","duration":"2071.4ms","level":"slow","span":"03d916f2c930fca3","trace":"8c3487cc080b274fff1af3c4a72a6fc9"}
-{"@timestamp":"2026-06-26T16:35:11.656+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2071.4ms","level":"info","span":"03d916f2c930fca3","trace":"8c3487cc080b274fff1af3c4a72a6fc9"}
-{"@timestamp":"2026-06-26T16:35:16.692+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2032.0ms)","duration":"2032.0ms","level":"slow","span":"ef8c556c9347c05c","trace":"f951fcb7e39057daa3816d6a42b3ed05"}
-{"@timestamp":"2026-06-26T16:35:16.692+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2032.0ms","level":"info","span":"ef8c556c9347c05c","trace":"f951fcb7e39057daa3816d6a42b3ed05"}
-{"@timestamp":"2026-06-26T16:35:18.374+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.3Mi, TotalAlloc=99.3Mi, Sys=19.6Mi, NumGC=57","level":"stat"}
-{"@timestamp":"2026-06-26T16:35:18.391+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T16:35:21.005+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2055.8ms, med: 2056.5ms, 90th: 2087.3ms, 99th: 2087.3ms, 99.9th: 2087.3ms","level":"stat"}
-{"@timestamp":"2026-06-26T16:35:21.780+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2083.3ms)","duration":"2083.3ms","level":"slow","span":"8228838464f0f37b","trace":"f131fe56eb395579afff62189aabef14"}
-{"@timestamp":"2026-06-26T16:35:21.780+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2083.3ms","level":"info","span":"8228838464f0f37b","trace":"f131fe56eb395579afff62189aabef14"}
-{"@timestamp":"2026-06-26T16:35:26.836+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2051.8ms)","duration":"2051.8ms","level":"slow","span":"2fd7dac48bff9ed1","trace":"eb2f7717cb655753f7968eee38d21844"}
-{"@timestamp":"2026-06-26T16:35:26.836+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2051.8ms","level":"info","span":"2fd7dac48bff9ed1","trace":"eb2f7717cb655753f7968eee38d21844"}
-{"@timestamp":"2026-06-26T16:35:31.911+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2070.1ms)","duration":"2070.1ms","level":"slow","span":"ef32134b8aafdbbc","trace":"8f03f660b26e2e297f9593429955f805"}
-{"@timestamp":"2026-06-26T16:35:31.912+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2070.1ms","level":"info","span":"ef32134b8aafdbbc","trace":"8f03f660b26e2e297f9593429955f805"}
-{"@timestamp":"2026-06-26T16:35:36.993+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2077.7ms)","duration":"2077.7ms","level":"slow","span":"a7e4b76e38fb60a2","trace":"d7dcb592892205de706670fa74f9941a"}
-{"@timestamp":"2026-06-26T16:35:36.993+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2077.7ms","level":"info","span":"a7e4b76e38fb60a2","trace":"d7dcb592892205de706670fa74f9941a"}
-{"@timestamp":"2026-06-26T16:35:42.076+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2079.4ms)","duration":"2079.4ms","level":"slow","span":"45e6a31b95dfe947","trace":"9853b5b9c0e27a1d4f28fd1efb806fa7"}
-{"@timestamp":"2026-06-26T16:35:42.077+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2079.4ms","level":"info","span":"45e6a31b95dfe947","trace":"9853b5b9c0e27a1d4f28fd1efb806fa7"}
-{"@timestamp":"2026-06-26T16:35:47.125+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2044.2ms)","duration":"2044.2ms","level":"slow","span":"4929e0009477bd56","trace":"8ecbcf91525054ba77bf91110e6667b7"}
-{"@timestamp":"2026-06-26T16:35:47.126+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2044.2ms","level":"info","span":"4929e0009477bd56","trace":"8ecbcf91525054ba77bf91110e6667b7"}
-{"@timestamp":"2026-06-26T16:35:52.195+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2065.2ms)","duration":"2065.2ms","level":"slow","span":"76e5bf34c95afd8a","trace":"b17fe789f4253d41e99a5aa1df566f93"}
-{"@timestamp":"2026-06-26T16:35:52.195+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2065.2ms","level":"info","span":"76e5bf34c95afd8a","trace":"b17fe789f4253d41e99a5aa1df566f93"}
-{"@timestamp":"2026-06-26T16:35:57.229+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2029.1ms)","duration":"2029.1ms","level":"slow","span":"4cc5a3000b1c2b5b","trace":"c09e1ce30c2456567044979a46988024"}
-{"@timestamp":"2026-06-26T16:35:57.229+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2029.1ms","level":"info","span":"4cc5a3000b1c2b5b","trace":"c09e1ce30c2456567044979a46988024"}
-{"@timestamp":"2026-06-26T16:36:02.326+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2093.3ms)","duration":"2093.3ms","level":"slow","span":"9b6d44a902105334","trace":"fa4e3c773b9653ae23c66876ecfbfa33"}
-{"@timestamp":"2026-06-26T16:36:02.326+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2093.3ms","level":"info","span":"9b6d44a902105334","trace":"fa4e3c773b9653ae23c66876ecfbfa33"}
-{"@timestamp":"2026-06-26T16:36:07.351+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2021.1ms)","duration":"2021.1ms","level":"slow","span":"312f17f74f4a710c","trace":"284bd1edb0e91e409806029b5098761b"}
-{"@timestamp":"2026-06-26T16:36:07.351+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2021.1ms","level":"info","span":"312f17f74f4a710c","trace":"284bd1edb0e91e409806029b5098761b"}
-{"@timestamp":"2026-06-26T16:36:12.421+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2068.0ms)","duration":"2068.0ms","level":"slow","span":"76c1bd70f675898a","trace":"117d1135a1ac87050345d16de7c37aa4"}
-{"@timestamp":"2026-06-26T16:36:12.421+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2068.0ms","level":"info","span":"76c1bd70f675898a","trace":"117d1135a1ac87050345d16de7c37aa4"}
-{"@timestamp":"2026-06-26T16:36:17.499+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2073.8ms)","duration":"2073.8ms","level":"slow","span":"7735dc2dd00128d1","trace":"5ce769ad65f630a6959f221c8fbcd4eb"}
-{"@timestamp":"2026-06-26T16:36:17.499+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2073.8ms","level":"info","span":"7735dc2dd00128d1","trace":"5ce769ad65f630a6959f221c8fbcd4eb"}
-{"@timestamp":"2026-06-26T16:36:18.370+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=99.4Mi, Sys=19.6Mi, NumGC=57","level":"stat"}
-{"@timestamp":"2026-06-26T16:36:18.387+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 12, pass: 12, drop: 0","level":"stat"}
-{"@timestamp":"2026-06-26T16:36:21.001+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 0.2/s, drops: 0, avg time: 2062.9ms, med: 2070.0ms, 90th: 2093.1ms, 99th: 2093.1ms, 99.9th: 2093.1ms","level":"stat"}
-{"@timestamp":"2026-06-26T16:36:22.558+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2054.0ms)","duration":"2054.0ms","level":"slow","span":"a463bf145aaee075","trace":"e4d45689d85fea8c81c367ba814614a7"}
-{"@timestamp":"2026-06-26T16:36:22.558+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2054.0ms","level":"info","span":"a463bf145aaee075","trace":"e4d45689d85fea8c81c367ba814614a7"}
-{"@timestamp":"2026-06-26T16:36:27.589+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.7ms)","duration":"2027.7ms","level":"slow","span":"8a70738e682d06f6","trace":"ba28e697b236d1707107beb8f48fdb42"}
-{"@timestamp":"2026-06-26T16:36:27.590+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.7ms","level":"info","span":"8a70738e682d06f6","trace":"ba28e697b236d1707107beb8f48fdb42"}
-{"@timestamp":"2026-06-26T16:36:32.618+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.7ms)","duration":"2025.7ms","level":"slow","span":"348e057e169b3225","trace":"f4d2a07fd2f4de42ea92cc8e00a1f1d0"}
-{"@timestamp":"2026-06-26T16:36:32.618+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.7ms","level":"info","span":"348e057e169b3225","trace":"f4d2a07fd2f4de42ea92cc8e00a1f1d0"}
-{"@timestamp":"2026-06-26T16:36:37.637+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2016.0ms)","duration":"2016.0ms","level":"slow","span":"b44ed843ac16733c","trace":"290804501a3746861f263fddd0399b59"}
-{"@timestamp":"2026-06-26T16:36:37.637+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2016.0ms","level":"info","span":"b44ed843ac16733c","trace":"290804501a3746861f263fddd0399b59"}
-{"@timestamp":"2026-06-26T16:36:42.668+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2027.4ms)","duration":"2027.4ms","level":"slow","span":"d10f4ae5da211974","trace":"c858658e77fb6fe2ac02d2026452f5b0"}
-{"@timestamp":"2026-06-26T16:36:42.668+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2027.4ms","level":"info","span":"d10f4ae5da211974","trace":"c858658e77fb6fe2ac02d2026452f5b0"}
-{"@timestamp":"2026-06-26T16:36:47.688+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2017.8ms)","duration":"2017.8ms","level":"slow","span":"a19f5590f8b76824","trace":"68f2690f8692afddbdaf9c233e178b00"}
-{"@timestamp":"2026-06-26T16:36:47.688+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2017.8ms","level":"info","span":"a19f5590f8b76824","trace":"68f2690f8692afddbdaf9c233e178b00"}
-{"@timestamp":"2026-06-26T16:36:52.710+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2018.9ms)","duration":"2018.9ms","level":"slow","span":"3d40c0280d4e338f","trace":"fa719cc659ac3139baa977982a430582"}
-{"@timestamp":"2026-06-26T16:36:52.710+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2018.9ms","level":"info","span":"3d40c0280d4e338f","trace":"fa719cc659ac3139baa977982a430582"}
-{"@timestamp":"2026-06-26T16:36:57.738+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node - slowcall(2025.7ms)","duration":"2025.7ms","level":"slow","span":"203ea16969150534","trace":"f67148e2946a7d2e77f3bff9d01125e0"}
-{"@timestamp":"2026-06-26T16:36:57.738+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63210 - node","duration":"2025.7ms","level":"info","span":"203ea16969150534","trace":"f67148e2946a7d2e77f3bff9d01125e0"}
diff --git a/.run/logs/web.log b/.run/logs/web.log
deleted file mode 100644
index 3ee12d0..0000000
--- a/.run/logs/web.log
+++ /dev/null
@@ -1,9 +0,0 @@
-
-> haixun-web@0.1.0 dev
-> vite
-
-
- VITE v6.4.3 ready in 187 ms
-
- ➜ Local: http://localhost:5173/
- ➜ Network: use --host to expose
diff --git a/.run/logs/worker.log b/.run/logs/worker.log
deleted file mode 100644
index 45c7031..0000000
--- a/.run/logs/worker.log
+++ /dev/null
@@ -1,1217 +0,0 @@
-
-> haixun-master@0.1.0 worker:style-8d
-> . scripts/playwright-env.sh && npx playwright install chromium && tsx haixun-backend/worker/style-8d-worker.ts
-
-[8d-worker] started id=local-style-8d-node-72804 api=http://127.0.0.1:8890
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
-[8d-worker] loop error Error: internal server error
- at api (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:89:11)
- at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
- at async main (/Users/daniel/Desktop/haixunMaster/haixun-backend/worker/style-8d-worker.ts:346:19)
diff --git a/.run/web.pid b/.run/web.pid
deleted file mode 100644
index 56f69ec..0000000
--- a/.run/web.pid
+++ /dev/null
@@ -1 +0,0 @@
-72713
diff --git a/.run/worker.pid b/.run/worker.pid
deleted file mode 100644
index 63d0b24..0000000
--- a/.run/worker.pid
+++ /dev/null
@@ -1 +0,0 @@
-72714
diff --git a/AGENTS.md b/AGENTS.md
index 76d5958..8cdad13 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -22,6 +22,7 @@
## 設計文件
- `docs/job-system-plan.md`:通用 job system 規劃,包含 template、run、schedule、Redis queue/lock、取消語意與 API 草案。
+- `docs/post-project-plan.md`:發文計畫(文案任務 Flow A 砍掉重做)— 主題發想、風格分析、矩陣產 N 篇草稿、定時發送。
- `docs/scan-placement-plan.md`:海巡獲客(流程 B)— 知識圖譜、Brave 擴展、雙軌爬取(7 天重點 / 30 天補充)、產品匹配、島民交接。
## 新增 API 流程
diff --git a/Makefile b/Makefile
index ae3fdee..d322066 100644
--- a/Makefile
+++ b/Makefile
@@ -1,200 +1,128 @@
-# 巡樓 monorepo Makefile
-# 兩種模式:
-# dev - 本機開發(docker 起 Mongo/Redis,go run / vite dev 在本機跑)
-# docker - 全棧一鍵啟動(Mongo/Redis/gateway/worker/node-worker/web 全在 docker)
-#
-# 常用:
-# make up # 一鍵:build + 啟動整套(含前後端、worker、DB)
-# make down # 停掉整套
-# make logs # 追全部日誌
-# make dev-infra # 本機開發:只起 Mongo/Redis
-# make dev-backend # 本機開發:go run gateway (:8890)
-# make dev-frontend # 本機開發:vite dev (:5173)
-
SHELL := /bin/bash
-# --- 路徑 ---
-BACKEND_DIR := backend
-FRONTEND_DIR := frontend
+BACKEND_DIR := backend
+WEB_DIR := backend/web
+INFRA_DIR := infra
+DEPLOY_DIR := deploy
-# --- docker compose(prod 全棧)---
-COMPOSE_FILE := deploy/docker-compose.yml
-ENV_FILE := deploy/.env
-COMPOSE := docker compose -f $(COMPOSE_FILE) --env-file $(ENV_FILE)
-
-# --- docker compose(本地測試;base + dev override + dev env)---
-DEV_ENV_FILE := deploy/.env.dev
-DEV_COMPOSE := docker compose -f $(COMPOSE_FILE) -f deploy/docker-compose.dev.yml --env-file $(DEV_ENV_FILE)
-# go worker 用 gateway 內嵌,dev 不啟動獨立 worker 容器
-DEV_SERVICES := mongo redis init gateway node-worker web
-
-# --- dev 用 worker secret(對應 etc/gateway.yaml 的 InternalWorker.Secret)---
-DEV_WORKER_SECRET := haixun-dev-worker-secret
-DEV_BACKEND_URL := http://127.0.0.1:8890
+INFRA_DEV_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.dev -f $(INFRA_DIR)/docker-compose.yml
+INFRA_PROD_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.prod -f $(INFRA_DIR)/docker-compose.yml
+DEPLOY_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.prod -f $(DEPLOY_DIR)/docker-compose.yml
.DEFAULT_GOAL := help
.PHONY: help
help: ## 顯示可用指令
- @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
- | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
+ @grep -E '^[a-zA-Z0-9_-]+:.*## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
-$(ENV_FILE):
- @test -f $(ENV_FILE) || (cp deploy/.env.example $(ENV_FILE) && echo "已從 deploy/.env.example 建立 $(ENV_FILE),請填入密碼與 secret 後再 make up")
-
-# ============================================================
-# DOCKER(全棧一鍵)
-# ============================================================
-
-.PHONY: up
-up: $(ENV_FILE) ## [docker] 一鍵啟動整套(build + up,背景)
- $(COMPOSE) up -d --build
- @echo ""
- @echo "=== 啟動中 ==="
- @echo " 前端: http://localhost:$$(grep -E '^WEB_PORT=' $(ENV_FILE) | cut -d= -f2)"
- @echo " 健康檢查: curl http://localhost:$$(grep -E '^WEB_PORT=' $(ENV_FILE) | cut -d= -f2)/api/v1/health"
- @echo " 看狀態: make ps 看日誌: make logs"
-
-.PHONY: down
-down: ## [docker] 停掉整套(保留資料 volume)
- $(COMPOSE) down
-
-.PHONY: down-clean
-down-clean: ## [docker] 停掉整套並刪除資料 volume(清空 Mongo/Redis)
- $(COMPOSE) down -v
-
-.PHONY: build
-build: $(ENV_FILE) ## [docker] 只 build image,不啟動
- $(COMPOSE) build
-
-.PHONY: rebuild
-rebuild: $(ENV_FILE) ## [docker] 無快取重新 build 並重啟
- $(COMPOSE) build --no-cache
- $(COMPOSE) up -d
-
-.PHONY: restart
-restart: ## [docker] 重啟所有服務
- $(COMPOSE) restart
-
-.PHONY: ps
-ps: ## [docker] 看服務狀態
- $(COMPOSE) ps
-
-.PHONY: logs
-logs: ## [docker] 追全部日誌(Ctrl-C 離開)
- $(COMPOSE) logs -f
-
-.PHONY: logs-gateway
-logs-gateway: ## [docker] 只看 gateway 日誌
- $(COMPOSE) logs -f gateway
-
-.PHONY: init
-init: $(ENV_FILE) ## [docker] 重跑一次 DB 初始化(索引/權限/admin,冪等)
- $(COMPOSE) run --rm init
-
-.PHONY: config
-config: $(ENV_FILE) ## [docker] 驗證 compose 設定
- $(COMPOSE) config >/dev/null && echo "docker compose config OK"
-
-# ============================================================
-# DEV(本地測試,全走 docker)
-# ============================================================
-
-.PHONY: dev-up
-dev-up: ## [dev] 一鍵:本地 docker 測試(gateway 開 :8890,web 開 :8080,go worker 內嵌)
- $(DEV_COMPOSE) up -d --build $(DEV_SERVICES)
- @echo ""
- @echo "=== 本地測試啟動中 ==="
- @echo " 前端: http://localhost:$$(grep -E '^WEB_PORT=' $(DEV_ENV_FILE) | cut -d= -f2)"
- @echo " API: http://localhost:8890/api/v1/health"
- @echo " DevHeaderFallback 已開(可用 X-Tenant-ID / X-UID 模擬登入)"
-
-.PHONY: dev-down
-dev-down: ## [dev] 停掉本地 docker(保留資料)
- $(DEV_COMPOSE) down
-
-.PHONY: dev-down-clean
-dev-down-clean: ## [dev] 停掉本地 docker 並清空資料 volume
- $(DEV_COMPOSE) down -v
-
-.PHONY: dev-rebuild
-dev-rebuild: ## [dev] 改 code 後重新 build 並重啟本地 docker
- $(DEV_COMPOSE) up -d --build $(DEV_SERVICES)
-
-.PHONY: dev-restart
-dev-restart: ## [dev] 只重啟(改了 etc/*.yaml 後不用 rebuild,掛載即生效)
- $(DEV_COMPOSE) restart gateway node-worker
-
-.PHONY: dev-ps
-dev-ps: ## [dev] 看本地 docker 狀態
- $(DEV_COMPOSE) ps
-
-.PHONY: dev-logs
-dev-logs: ## [dev] 追本地 docker 日誌
- $(DEV_COMPOSE) logs -f
-
-.PHONY: dev-init
-dev-init: ## [dev] 重跑一次 DB 初始化(索引/權限/admin,冪等)
- $(DEV_COMPOSE) run --rm init
-
-.PHONY: dev
-dev: ## [dev] 本地 docker 測試說明
- @echo "本地測試(全 docker):"
- @echo " make dev-up # 一鍵起整套(前端 :8080、API :8890)"
- @echo " make dev-logs # 看日誌"
- @echo " make dev-restart # 改 etc/*.yaml 後重啟(免 rebuild)"
- @echo " make dev-rebuild # 改 Go/前端 code 後重 build"
- @echo " make dev-down # 停(保留資料)"
-
-# --- 進階:不進 docker,直接本機 go run / vite(需自備 Go/Node)---
-.PHONY: local-infra
-local-infra: ## [local] 只起 Mongo + Redis(docker),其餘本機跑
- $(DEV_COMPOSE) up -d mongo redis
- $(DEV_COMPOSE) ps
-
-.PHONY: local-backend
-local-backend: ## [local] 本機 go run gateway (:8890)
- cd $(BACKEND_DIR) && go run . -f etc/gateway.yaml
-
-.PHONY: local-worker
-local-worker: ## [local] 本機 go run Go job worker (:8891)
- cd $(BACKEND_DIR) && go run ./cmd/worker -f etc/gateway.worker.yaml
-
-.PHONY: local-node-worker
-local-node-worker: ## [local] 本機跑 Node playwright worker (style-8d)
- cd $(BACKEND_DIR)/worker && npm install && \
- HAIXUN_WORKER_SECRET=$(DEV_WORKER_SECRET) HAIXUN_BACKEND_URL=$(DEV_BACKEND_URL) npm run style-8d
-
-.PHONY: local-frontend
-local-frontend: ## [local] 本機跑前端 dev server (:5173)
- cd $(FRONTEND_DIR) && npm install && npm run dev
-
-.PHONY: local-init
-local-init: ## [local] 本機初始化 DB(索引/權限)並建立 admin
- cd $(BACKEND_DIR) && go run ./cmd/tool init -f etc/gateway.yaml
-
-# ============================================================
-# 驗證 / 維護
-# ============================================================
-
-.PHONY: tidy
-tidy: ## go mod tidy
- cd $(BACKEND_DIR) && go mod tidy
+.PHONY: setup-env
+setup-env: ## 互動式產生 deploy/.env.prod(自動 URL-encode MongoDB 密碼)
+ @$(DEPLOY_DIR)/setup.sh
.PHONY: gen-api
-gen-api: ## 用 goctl 從 .api 重新產生 handler/logic/types/routes
- cd $(BACKEND_DIR) && goctl api go -api generate/api/gateway.api -dir . -home generate/goctl -style go_zero
+gen-api: ## 重新產生後端 API handler / logic / types
+ $(MAKE) -C $(BACKEND_DIR) gen-api
.PHONY: fmt
-fmt: ## gofmt 後端
- cd $(BACKEND_DIR) && gofmt -w .
+fmt: ## 格式化後端 Go 程式
+ $(MAKE) -C $(BACKEND_DIR) fmt
.PHONY: test
-test: ## 跑後端測試
- cd $(BACKEND_DIR) && go test ./...
+test: ## 執行後端測試
+ $(MAKE) -C $(BACKEND_DIR) test
+
+.PHONY: init
+init: ## 初始化系統(建索引、種權限、建立 admin,讀 deploy/.env.prod)
+ $(MAKE) -C $(BACKEND_DIR) ENV_FILE=../deploy/.env.prod init
+
+.PHONY: run
+run: ## 啟動後端 API
+ $(MAKE) -C $(BACKEND_DIR) run
+
+.PHONY: dev-all
+dev-all: ## 啟動本機完整 dev stack(API、Go workers、Node workers、web)
+ ./scripts/dev-all.sh
+
+.PHONY: dev-all-stop
+dev-all-stop: ## 停止本機 dev stack(釋放 8890 / 9101 / 9102 / web 等埠)
+ ./scripts/dev-all-stop.sh
+
+.PHONY: web-dev
+web-dev: ## 啟動正式前端 dev server
+ cd $(WEB_DIR) && npm install && npm run dev
+
+.PHONY: web-build
+web-build: ## 建置正式前端
+ cd $(WEB_DIR) && npm install && npm run build
.PHONY: verify
-verify: $(ENV_FILE) ## 後端 build/test + 前端 build + compose 語法
- cd $(BACKEND_DIR) && go build ./... && go test ./...
- cd $(FRONTEND_DIR) && npm ci && npm run build
- $(COMPOSE) config >/dev/null && echo "docker compose config OK"
+verify: test web-build ## 後端測試與前端建置
+
+.PHONY: dev-infra
+dev-infra: ## 啟動本機 dev 資料服務(Mongo / Redis 容器,讀 deploy/.env.dev)
+ @test -f $(DEPLOY_DIR)/.env.dev || cp $(DEPLOY_DIR)/.env.dev.example $(DEPLOY_DIR)/.env.dev
+ @echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.dev 再執行後續指令" >&2
+ $(INFRA_DEV_COMPOSE) up -d
+ $(INFRA_DEV_COMPOSE) ps
+
+.PHONY: dev-infra-down
+dev-infra-down: ## 關閉本機 dev 資料服務(保留資料 volume)
+ $(INFRA_DEV_COMPOSE) down
+
+.PHONY: dev-infra-recreate
+dev-infra-recreate: ## 清除 dev 資料 volume 並重啟(Mongo / Redis 全新狀態)
+ $(INFRA_DEV_COMPOSE) down -v
+ $(INFRA_DEV_COMPOSE) up -d
+ $(INFRA_DEV_COMPOSE) ps
+
+.PHONY: prod-infra
+prod-infra: ## 啟動 prod 資料服務(Mongo / Redis,讀 deploy/.env.prod)
+ @test -f $(DEPLOY_DIR)/.env.prod || cp $(DEPLOY_DIR)/.env.prod.example $(DEPLOY_DIR)/.env.prod
+ @echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.prod 再執行後續指令" >&2
+ $(INFRA_PROD_COMPOSE) up -d
+ $(INFRA_PROD_COMPOSE) ps
+
+.PHONY: prod-infra-down
+prod-infra-down: ## 關閉 prod 資料服務(保留資料 volume)
+ $(INFRA_PROD_COMPOSE) down
+
+.PHONY: prod-infra-recreate
+prod-infra-recreate: ## 清除 prod 資料 volume 並重啟(Mongo / Redis 全新狀態)
+ $(INFRA_PROD_COMPOSE) down -v
+ $(INFRA_PROD_COMPOSE) up -d
+ $(INFRA_PROD_COMPOSE) ps
+
+.PHONY: prod-install
+prod-install: ## 一鍵部署 prod:複製 .env.prod、build 本地 image、啟動 app、init(需先啟動 infra)
+ @test -f $(DEPLOY_DIR)/.env.prod || cp $(DEPLOY_DIR)/.env.prod.example $(DEPLOY_DIR)/.env.prod
+ @echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.prod 再執行後續指令" >&2
+ @echo ">>> 請確認 infra 已啟動(make prod-infra)" >&2
+ $(DEPLOY_COMPOSE) build
+ $(DEPLOY_COMPOSE) up -d
+ $(DEPLOY_COMPOSE) --profile init run --rm init
+ $(DEPLOY_COMPOSE) ps
+
+.PHONY: prod-rebuild
+prod-rebuild: ## 重新 build 本地 image 並重建 prod app stack(infra 不影響)
+ $(DEPLOY_COMPOSE) build
+ $(DEPLOY_COMPOSE) up -d
+ $(DEPLOY_COMPOSE) ps
+
+.PHONY: prod-stop
+prod-stop: ## 關閉 prod app stack(保留 infra 與資料)
+ $(DEPLOY_COMPOSE) down
+
+.PHONY: prod-stop-all
+prod-stop-all: ## 關閉 prod app + infra(保留資料 volume)
+ $(DEPLOY_COMPOSE) down
+ $(INFRA_PROD_COMPOSE) down
+
+.PHONY: prod-recreate
+prod-recreate: ## 完整清除 prod 全部資料 volume + 重建啟動(app + infra + init)
+ $(DEPLOY_COMPOSE) down -v
+ $(INFRA_PROD_COMPOSE) down -v
+ $(INFRA_PROD_COMPOSE) up -d
+ $(DEPLOY_COMPOSE) up -d
+ $(DEPLOY_COMPOSE) --profile init run --rm init
+ $(DEPLOY_COMPOSE) ps
diff --git a/ai_threads_auto_account_ops.md b/ai_threads_auto_account_ops.md
new file mode 100644
index 0000000..45d2a89
--- /dev/null
+++ b/ai_threads_auto_account_ops.md
@@ -0,0 +1,1678 @@
+# AI 自動經營 Threads 帳號系統規格書
+
+> 目標:建立一套可以自動經營 Threads 帳號的 AI 系統。
+> 系統不是單純產文,而是能夠「找話題、判斷切角、生成內容、檢查品質、發布排程、回收成效、分析原因、更新策略」的閉環內容營運系統。
+
+---
+
+## 0. 核心概念
+
+這套系統的核心不是「AI 幫我寫一篇文」,而是:
+
+```text
+AI 自動經營 Threads 帳號 =
+話題雷達
++ 帳號大腦
++ 內容公式池
++ 切角推理
++ 品質閘門
++ 自動排程/發布
++ 成效回收
++ 策略自我更新
+```
+
+系統要做到:
+
+1. 自己發現新話題。
+2. 自己判斷話題適不適合帳號。
+3. 自己選擇內容任務,例如漲粉、互動、收藏、信任、人設。
+4. 自己從公式池選擇適合的開頭、中段、結尾、CTA。
+5. 自己產出多版本草稿。
+6. 自己檢查是否太 AI、太公式化、太像競品、太高風險。
+7. 自己排程或進入人工審核。
+8. 自己回收發文後的成效。
+9. 自己分析哪裡有效、哪裡沒效。
+10. 自己更新下一輪發文策略。
+
+---
+
+## 1. 系統總流程
+
+```mermaid
+flowchart TD
+ A[資料來源收集] --> B[資料清洗與解析]
+ B --> C[帳號大腦 Brand Brain]
+ C --> D[話題雷達 Topic Radar]
+ D --> E[話題評分與分類]
+ E --> F[內容策略決策器]
+ F --> G[公式池與切角引擎]
+ G --> H[AI 產文工廠]
+ H --> I[品質閘門 Gatekeeper]
+ I --> J{是否可發布}
+ J -->|高信心| K[自動排程/發布]
+ J -->|中信心| L[人工審核]
+ J -->|低信心| M[退回重寫]
+ L --> K
+ M --> H
+ K --> N[成效回收]
+ N --> O[成效分析]
+ O --> P[策略自我更新]
+ P --> D
+```
+
+---
+
+## 2. 系統模組拆分
+
+```mermaid
+flowchart LR
+ A[Ingestion Service] --> B[Knowledge Brain Service]
+ B --> C[Topic Radar Service]
+ C --> D[Strategy Planner]
+ D --> E[Formula Engine]
+ E --> F[Content Generator]
+ F --> G[Quality Gatekeeper]
+ G --> H[Scheduler & Publisher]
+ H --> I[Insight Collector]
+ I --> J[Learning Engine]
+ J --> B
+```
+
+### 2.1 Ingestion Service
+
+負責收集資料。
+
+資料來源包含:
+
+| 來源 | 用途 | 優先級 |
+|---|---|---:|
+| 使用者上傳 Excel / Markdown / PDF | 建立帳號知識庫與風格資料 | 高 |
+| 自己歷史貼文 | 學習帳號語氣、主題、成效 | 高 |
+| Threads 關鍵字搜尋 | 發現新話題 | 高 |
+| 指定競品帳號 | 拆解內容 DNA,不直接複製 | 高 |
+| Brave Search / Google Search | 補充外部資料與查證 | 中 |
+| RSS / 官方網站 | 專業帳號資料來源 | 中 |
+| 手動輸入筆記 | 補充人設、想法、禁忌 | 中 |
+| 留言與回覆 | 找粉絲真正關心的問題 | 高 |
+
+### 2.2 Knowledge Brain Service
+
+負責維護帳號大腦。
+
+包含:
+
+1. Brand Memory:帳號設定、人設、語氣、禁用規則。
+2. Knowledge Memory:文件、主題、知識點、可引用資料。
+3. Style Memory:句型、開頭、結尾、CTA、語氣節奏。
+4. Formula Memory:不同任務對應的內容公式。
+5. Feedback Memory:成效、成功模式、失敗模式、人工評分。
+
+### 2.3 Topic Radar Service
+
+負責找話題、去重、分類、評分。
+
+### 2.4 Strategy Planner
+
+負責決定:
+
+1. 今天要發什麼主題。
+2. 這篇文的任務是什麼。
+3. 要用什麼切角。
+4. 要用什麼情緒槓桿。
+5. 要不要進人工審核。
+
+### 2.5 Formula Engine
+
+負責從公式池中挑出:
+
+1. 開頭方式。
+2. 中段結構。
+3. 情緒槓桿。
+4. 結尾方式。
+5. CTA 類型。
+
+### 2.6 Content Generator
+
+負責產出多版本草稿,並根據帳號語氣改寫。
+
+### 2.7 Quality Gatekeeper
+
+負責檢查:
+
+1. AI 感。
+2. 公式感。
+3. 相似度。
+4. 事實風險。
+5. 醫療、金融、法律等高風險內容。
+6. 帳號一致性。
+7. 發文頻率與疲乏度。
+
+### 2.8 Scheduler & Publisher
+
+負責排程與發布。
+
+支援:
+
+1. Threads API 發布。
+2. Web Intent 半自動發文。
+3. 一鍵複製手動發文。
+
+### 2.9 Insight Collector
+
+負責回收成效。
+
+收集:
+
+1. impressions。
+2. likes。
+3. replies。
+4. reposts。
+5. quotes。
+6. follows gained。
+7. profile visits。
+8. reply sentiment。
+9. manual rating。
+
+### 2.10 Learning Engine
+
+負責分析成效並更新策略。
+
+---
+
+## 3. 資料收集流程
+
+```mermaid
+flowchart TD
+ A[資料來源] --> B[Raw Data Pool]
+ B --> C[清洗 HTML / 雜訊]
+ C --> D[切 Chunk]
+ D --> E[抽取主題 Topic]
+ D --> F[抽取實體 Entity]
+ D --> G[抽取觀點 Claim]
+ D --> H[抽取語氣 Style]
+ D --> I[抽取公式 Pattern]
+ E --> J[Knowledge Brain]
+ F --> J
+ G --> J
+ H --> J
+ I --> J
+ J --> K[向量索引]
+ J --> L[關係資料]
+```
+
+### 3.1 匯入文件
+
+支援格式:
+
+```text
+.xlsx
+.csv
+.md
+.txt
+.pdf
+.docx
+.zip, for Obsidian vault
+```
+
+### 3.2 Excel 匯入邏輯
+
+如果使用者上傳類似「小日子長大中.xlsx」這種內容檔案,可以這樣解析:
+
+| Sheet 類型 | 解析用途 |
+|---|---|
+| 發文 | 當作歷史貼文範例、語氣樣本、主題樣本 |
+| 產圖 | 當作 IG 輪播與視覺規格 |
+| 脆文 | 當作 Threads prompt 與內容風格樣本 |
+
+每一列轉成:
+
+```json
+{
+ "source_type": "excel",
+ "sheet_name": "發文",
+ "title": "備孕搜尋紀錄",
+ "content": "開始備孕後,Google 或 AI 可能比我本人更了解我的身體狀況...",
+ "topics": ["備孕", "焦慮", "搜尋", "共鳴"],
+ "content_type": "情緒共鳴文",
+ "style_tags": ["溫柔", "真實", "口語", "不焦慮推進"]
+}
+```
+
+### 3.3 Obsidian 匯入邏輯
+
+Obsidian 不要當核心資料庫,只當匯入與輸出入口。
+
+解析內容:
+
+```text
+frontmatter
+# headings
+[[internal links]]
+#tags
+tables
+paragraphs
+attachments references
+```
+
+轉成:
+
+```json
+{
+ "note_title": "第一次看生殖門診",
+ "tags": ["備孕", "生殖門診"],
+ "links": ["AMH", "FSH", "試管流程"],
+ "chunks": [],
+ "topics": [],
+ "claims": [],
+ "source_path": "/vault/備孕/第一次看生殖門診.md"
+}
+```
+
+---
+
+## 4. 帳號大腦 Brand Brain
+
+```mermaid
+flowchart TD
+ A[帳號基本設定] --> F[Brand Brain]
+ B[歷史貼文] --> F
+ C[上傳文件] --> F
+ D[競品分析] --> F
+ E[成效回饋] --> F
+ F --> G[Brand Memory]
+ F --> H[Knowledge Memory]
+ F --> I[Style Memory]
+ F --> J[Formula Memory]
+ F --> K[Feedback Memory]
+```
+
+### 4.1 Brand Memory
+
+記住帳號是誰。
+
+```json
+{
+ "account_name": "小日子長大中",
+ "persona": "拾日",
+ "positioning": "備孕、試管、孕育日常心境分享",
+ "tone": ["溫柔", "真實", "陪伴", "不焦慮推進"],
+ "avoid": ["太AI", "太醫療", "太雞湯", "保證效果", "過度正能量"],
+ "content_pillars": [
+ "備孕心情",
+ "試管流程",
+ "生殖門診",
+ "伴侶陪伴",
+ "等待焦慮",
+ "幽默共鳴"
+ ],
+ "signature_lines": [
+ "不是只有成功那天,這段路才算數",
+ "把這些小小的日子,一點一點拾起來"
+ ]
+}
+```
+
+### 4.2 Knowledge Memory
+
+記住專業知識與資料來源。
+
+```json
+{
+ "topic": "第一次看生殖門診",
+ "knowledge_points": [
+ "可先整理月經週期",
+ "可準備過去檢查報告",
+ "可列出想問醫生的問題"
+ ],
+ "risk_level": "medium",
+ "requires_fact_check": true,
+ "source_ids": ["doc_001", "doc_002"]
+}
+```
+
+### 4.3 Style Memory
+
+記住帳號怎麼說話。
+
+```json
+{
+ "opening_patterns": ["其實我一直覺得", "有時候不是", "今天突然想到"],
+ "sentence_style": {
+ "short_sentence_ratio": 0.65,
+ "paragraph_spacing": "loose",
+ "emoji_usage": "low"
+ },
+ "common_endings": ["今天先這樣也沒關係", "慢慢來也算有在往前"],
+ "avoid_phrases": ["在這個快節奏的時代", "總而言之", "讓我們一起"]
+}
+```
+
+### 4.4 Formula Memory
+
+記住不同內容任務的公式。
+
+```json
+{
+ "mission": "情緒共鳴文",
+ "formula": {
+ "opening": "真實情緒開場",
+ "body": "生活細節堆疊",
+ "emotion": "被理解感",
+ "ending": "溫柔但不雞湯",
+ "cta": "輕提問"
+ },
+ "use_case": ["等待焦慮", "月經來", "看到別人懷孕"],
+ "avoid": ["不要寫成衛教文", "不要硬塞解方"]
+}
+```
+
+### 4.5 Feedback Memory
+
+記住什麼有效。
+
+```json
+{
+ "insight": "反差開場的 reply rate 高於帳號平均 42%",
+ "action": "提高反差開場權重",
+ "confidence": 0.78,
+ "applied_to_strategy_version": "v3"
+}
+```
+
+---
+
+## 5. 話題雷達 Topic Radar
+
+```mermaid
+flowchart TD
+ A[每日定時掃描] --> B[收集候選話題]
+ B --> C[去重]
+ C --> D[分類]
+ D --> E[熱度分數]
+ D --> F[帳號適配分數]
+ D --> G[互動潛力]
+ D --> H[可延伸性]
+ D --> I[風險分數]
+ E --> J[Topic Score]
+ F --> J
+ G --> J
+ H --> J
+ I --> J
+ J --> K[候選題庫]
+```
+
+### 5.1 話題評分公式
+
+```text
+Topic Score =
+熱度分數 * 0.25
++ 帳號適配度 * 0.30
++ 可延伸性 * 0.15
++ 互動潛力 * 0.15
++ 資料可信度 * 0.10
+- 風險分數 * 0.20
+```
+
+### 5.2 話題分類
+
+| 類型 | 用途 | 範例 |
+|---|---|---|
+| 熱點話題 | 跟上討論,提高曝光 | 最近大家在討論 AMH |
+| 長尾知識 | 建立專業與收藏 | 第一次看生殖門診準備 |
+| 情緒共鳴 | 增加轉發與留言 | 等待開獎真的很累 |
+| 互動提問 | 養留言 | 大家第一次看診最怕什麼 |
+| 人設生活 | 讓帳號像真人 | 今天只是好好吃飯 |
+| 系列主題 | 長期內容資產 | 備孕小字典系列 |
+
+### 5.3 Candidate Topic Schema
+
+```json
+{
+ "id": "topic_001",
+ "account_id": "account_001",
+ "name": "第一次看生殖門診,可以準備什麼?",
+ "source": "keyword_search",
+ "category": "長尾知識",
+ "heat_score": 72,
+ "fit_score": 94,
+ "interaction_score": 68,
+ "extend_score": 85,
+ "risk_score": 35,
+ "final_score": 78.5,
+ "recommended_missions": ["收藏文", "互動文", "情緒共鳴文"]
+}
+```
+
+---
+
+## 6. 內容策略決策器 Strategy Planner
+
+系統每次產文前,要先決定「這篇文的任務」。
+
+```mermaid
+flowchart TD
+ A[候選話題] --> B[讀取帳號策略]
+ B --> C[讀取最近貼文]
+ C --> D[讀取成效記憶]
+ D --> E[決定內容任務]
+ E --> F[決定切角]
+ F --> G[產生 Content Plan]
+```
+
+### 6.1 內容任務分類
+
+| 任務 | 目的 | 適合公式 |
+|---|---|---|
+| 漲粉文 | 擴散、追蹤 | 共鳴、反差、痛點、金句 |
+| 互動文 | 引留言 | 提問、二選一、經驗募集 |
+| 收藏文 | 提高保存 | 清單、步驟、懶人包 |
+| 信任文 | 建立專業與真實感 | 經驗、觀察、案例 |
+| 人設文 | 讓帳號有個性 | 生活、價值觀、吐槽 |
+| 轉換文 | 引導私訊/連結/資源 | 模板、工具、服務 |
+| 系列文 | 長期經營 | 連載、週更、固定單元 |
+
+### 6.2 Content Plan Schema
+
+```json
+{
+ "topic": "第一次看生殖門診",
+ "mission": "互動文",
+ "target_audience": "準備第一次去生殖門診的備孕女性",
+ "angle": "診間會腦袋空白,所以先問大家都準備什麼",
+ "opening_type": "具體問題開場",
+ "body_type": "列出 3 個自己想到但不確定的問題",
+ "emotion": "緊張但想準備好",
+ "ending_type": "邀請補充",
+ "cta_type": "經驗募集型",
+ "risk_level": "medium",
+ "requires_human_review": true,
+ "avoid": [
+ "不要寫成衛教文",
+ "不要太完整,保留留言空間",
+ "不要溫柔金句收尾",
+ "不要使用最近 7 天重複用過的開頭"
+ ]
+}
+```
+
+---
+
+## 7. 公式池與切角引擎 Formula Engine
+
+```mermaid
+flowchart TD
+ A[Content Plan] --> B[選擇開頭公式]
+ A --> C[選擇中段結構]
+ A --> D[選擇情緒槓桿]
+ A --> E[選擇結尾方式]
+ A --> F[選擇 CTA]
+ B --> G[組成產文指令]
+ C --> G
+ D --> G
+ E --> G
+ F --> G
+```
+
+### 7.1 開頭公式池
+
+| 類型 | 用途 | 範例 |
+|---|---|---|
+| 情緒直球 | 快速共鳴 | 其實我一直覺得,備孕最累的是等待。 |
+| 反差開場 | 提高停留 | 以前以為看診最難,後來發現最難的是不知道要問什麼。 |
+| 場景開場 | 增加真人感 | 今天在診間等叫號的時候,腦袋突然一片空白。 |
+| 衝突觀點 | 建立觀點 | 我不太喜歡別人說「放輕鬆就好」。 |
+| 問題開場 | 引互動 | 第一次看生殖門診,大家最怕的是什麼? |
+| 自嘲開場 | 增加親近感 | 備孕後,我的 Google 搜尋紀錄比我本人還像醫生。 |
+
+### 7.2 中段結構池
+
+| 類型 | 用途 |
+|---|---|
+| 生活細節堆疊 | 情緒共鳴文 |
+| 三點整理 | 收藏文、知識文 |
+| 自問自答 | 觀點文、焦慮文 |
+| 故事推進 | 人設文、信任文 |
+| 對比以前和現在 | 成長型內容 |
+| 錯誤觀念澄清 | 專業文 |
+| 故意不講滿 | 互動文 |
+
+### 7.3 情緒槓桿池
+
+| 情緒 | 適合內容 |
+|---|---|
+| 被理解感 | 共鳴文 |
+| 小小委屈 | 備孕、職場、生活 |
+| 無奈但不崩潰 | Threads 短文 |
+| 自嘲 | 反差、幽默 |
+| 溫柔補償 | 陪伴帳號 |
+| 不想說破的疲憊 | 深夜文 |
+| 終於有人講出來 | 轉發型內容 |
+
+### 7.4 結尾公式池
+
+| 類型 | 用途 |
+|---|---|
+| 溫柔陪伴 | 情緒文 |
+| 反諷補刀 | 幽默文 |
+| 開放提問 | 互動文 |
+| 收藏提醒 | 知識文 |
+| 留白不收 | 日記文 |
+| 系列預告 | 系列內容 |
+
+### 7.5 CTA 公式池
+
+| CTA 類型 | 範例 |
+|---|---|
+| 共鳴型 | 你也有這種感覺嗎? |
+| 經驗募集型 | 第一次看診你最慶幸自己有準備什麼? |
+| 補充型 | 有漏掉的也歡迎補充。 |
+| 收藏型 | 可以先存起來,下次看診前翻一下。 |
+| 轉發型 | 轉給那個一直叫你放輕鬆的人。 |
+| 無 CTA | 讓文章自然停在情緒裡。 |
+
+---
+
+## 8. 防膩系統 Anti-Fatigue Engine
+
+避免每篇都長一樣。
+
+```mermaid
+flowchart TD
+ A[新草稿] --> B[讀取最近 7/14/30 天貼文]
+ B --> C[檢查開頭重複]
+ B --> D[檢查結尾重複]
+ B --> E[檢查 CTA 重複]
+ B --> F[檢查任務比例]
+ B --> G[檢查句型相似度]
+ B --> H[計算膩感分數]
+ H --> I{是否過高}
+ I -->|是| J[退回重寫]
+ I -->|否| K[通過]
+```
+
+### 8.1 防重複規則
+
+| 檢查項目 | 規則 |
+|---|---|
+| 開頭類型 | 連續 3 篇不能同類型 |
+| 結尾類型 | 連續 3 篇不能都是溫柔金句 |
+| CTA | 不能每篇都「你也會這樣嗎」 |
+| 文章長度 | 長短文要交錯 |
+| 情緒走向 | 不能每天都是焦慮到安慰 |
+| 內容任務 | 不能連續 5 篇都是共鳴文 |
+| 句型相似度 | 超過閾值退回重寫 |
+| 主題密度 | 同一主題設定冷卻期 |
+
+### 8.2 Fatigue Score
+
+```text
+Fatigue Score =
+Opening Similarity * 0.20
++ Ending Similarity * 0.20
++ CTA Similarity * 0.15
++ Structure Similarity * 0.25
++ Topic Repetition * 0.20
+```
+
+建議門檻:
+
+```text
+0 - 40:安全
+41 - 70:需要微調
+71 - 100:退回重寫
+```
+
+---
+
+## 9. AI 產文工廠 Content Generator
+
+```mermaid
+flowchart TD
+ A[Content Plan] --> B[生成初稿 A]
+ A --> C[生成初稿 B]
+ A --> D[生成初稿 C]
+ B --> E[帳號語氣改寫]
+ C --> E
+ D --> E
+ E --> F[真人感修稿]
+ F --> G[Threads 格式優化]
+ G --> H[版本評分]
+ H --> I[選出最佳版本]
+```
+
+### 9.1 生成版本
+
+每次至少產 3 個版本:
+
+| 版本 | 目的 |
+|---|---|
+| A 版 | 穩定帳號風格 |
+| B 版 | 更有共鳴 |
+| C 版 | 更容易互動 |
+| D 版 optional | 更短、更 Threads |
+| E 版 optional | 更幽默、更反差 |
+
+### 9.2 產文指令格式
+
+```text
+請產出一篇 Threads 文。
+
+帳號:小日子長大中
+人設:拾日
+語氣:溫柔、真實、陪伴、不焦慮推進
+主題:第一次看生殖門診
+內容任務:互動文
+切角:診間會腦袋空白,所以先問大家都準備什麼
+開頭方式:具體問題開場
+中段方式:列出 3 個自己想到但不確定的問題
+情緒:緊張但想準備好
+結尾方式:邀請補充
+CTA:經驗募集型
+
+禁止:
+- 不要寫成衛教文
+- 不要太像 AI
+- 不要使用「在這個快節奏的時代」
+- 不要溫柔金句收尾
+- 不要超過 500 字
+- 不要直接給醫療建議
+```
+
+---
+
+## 10. 品質閘門 Quality Gatekeeper
+
+```mermaid
+flowchart TD
+ A[草稿] --> B[AI 感檢查]
+ A --> C[公式感檢查]
+ A --> D[競品相似度檢查]
+ A --> E[事實查證]
+ A --> F[風險檢查]
+ A --> G[帳號一致性檢查]
+ A --> H[發布頻率檢查]
+ B --> I[總分]
+ C --> I
+ D --> I
+ E --> I
+ F --> I
+ G --> I
+ H --> I
+ I --> J{決策}
+ J -->|高分| K[可自動排程]
+ J -->|中分| L[人工審核]
+ J -->|低分| M[退回重寫]
+```
+
+### 10.1 檢查分數
+
+| 分數 | 說明 |
+|---|---|
+| ai_score | 越高代表越像 AI,需降低 |
+| formula_score | 越高代表公式感越重 |
+| brand_fit_score | 越高代表越符合帳號 |
+| risk_score | 越高代表越危險 |
+| similarity_score | 越高代表越像競品或過去貼文 |
+| engagement_potential | 預測互動潛力 |
+| freshness_score | 新鮮感 |
+
+### 10.2 發布決策
+
+```text
+總分 >= 85 且 risk_score < 30:可自動排程
+總分 70 - 84:進人工審核
+總分 < 70:退回重寫
+risk_score >= 60:強制人工審核
+similarity_score >= 75:退回重寫
+ai_score >= 70:退回真人感修稿
+```
+
+### 10.3 高風險內容
+
+以下內容預設不能全自動發布:
+
+1. 醫療建議。
+2. 金融投資建議。
+3. 法律判斷。
+4. 政治爭議。
+5. 指控個人或公司。
+6. 可能造成恐慌的資訊。
+7. 未查證的數據。
+
+---
+
+## 11. 排程與發布 Scheduler & Publisher
+
+```mermaid
+flowchart TD
+ A[通過品質閘門] --> B[選擇發布時間]
+ B --> C[檢查頻率限制]
+ C --> D[建立排程]
+ D --> E{發布方式}
+ E -->|API| F[Threads API 發布]
+ E -->|Web Intent| G[開啟半自動發文]
+ E -->|Manual| H[一鍵複製]
+ F --> I[記錄 post_id]
+ G --> I
+ H --> I
+ I --> J[等待成效回收]
+```
+
+### 11.1 發布策略
+
+| 模式 | 用途 |
+|---|---|
+| API 自動發 | 權限完整、低風險內容 |
+| Web Intent | API 權限不足時 |
+| 一鍵複製 | 初期 MVP 或人工操作 |
+
+### 11.2 排程策略
+
+系統需要根據過去成效找出最佳時間。
+
+第一版可先用簡單設定:
+
+```json
+{
+ "posting_windows": ["12:00-13:30", "20:30-23:00"],
+ "max_posts_per_day": 3,
+ "min_gap_minutes": 180,
+ "high_risk_requires_review": true
+}
+```
+
+---
+
+## 12. 成效回收 Insight Collector
+
+```mermaid
+flowchart TD
+ A[已發布貼文] --> B[1 小時成效]
+ A --> C[6 小時成效]
+ A --> D[24 小時成效]
+ A --> E[72 小時成效]
+ B --> F[Performance DB]
+ C --> F
+ D --> F
+ E --> F
+ F --> G[成效分析器]
+```
+
+### 12.1 回收時間點
+
+| 時間 | 用途 |
+|---|---|
+| 1 小時 | 初速判斷 |
+| 6 小時 | 當日表現 |
+| 24 小時 | 主要成效 |
+| 72 小時 | 長尾成效 |
+| 7 天 optional | 長期資料 |
+
+### 12.2 指標
+
+```json
+{
+ "post_id": "post_001",
+ "collected_at": "2026-07-02T21:00:00+08:00",
+ "impressions": 12000,
+ "likes": 580,
+ "replies": 72,
+ "reposts": 31,
+ "quotes": 8,
+ "follows_gained": 45,
+ "profile_visits": 320,
+ "engagement_rate": 0.057,
+ "reply_sentiment": "positive"
+}
+```
+
+---
+
+## 13. 成效分析 Performance Analyzer
+
+```mermaid
+flowchart TD
+ A[Performance DB] --> B[按主題分析]
+ A --> C[按公式分析]
+ A --> D[按開頭分析]
+ A --> E[按 CTA 分析]
+ A --> F[按情緒分析]
+ A --> G[按發布時間分析]
+ B --> H[洞察]
+ C --> H
+ D --> H
+ E --> H
+ F --> H
+ G --> H
+```
+
+### 13.1 分析問題
+
+系統每次分析要回答:
+
+1. 哪個主題表現最好?
+2. 哪種開頭最容易讓人回覆?
+3. 哪種 CTA 真的有效?
+4. 哪種情緒最容易被轉發?
+5. 哪種內容最容易漲粉?
+6. 哪種內容收藏價值最高?
+7. 哪個發文時間效果最好?
+8. 哪些內容應該減少?
+9. 哪些內容可以延伸成系列?
+
+### 13.2 分析結果範例
+
+```json
+{
+ "account_id": "account_001",
+ "period": "last_14_days",
+ "insights": [
+ {
+ "type": "opening_pattern",
+ "finding": "反差開場的平均 reply rate 比帳號平均高 42%",
+ "recommendation": "下週提高反差開場權重,但避免連續使用",
+ "confidence": 0.82
+ },
+ {
+ "type": "content_mission",
+ "finding": "收藏文的 follows gained 較低,但 profile visits 較高",
+ "recommendation": "收藏文結尾加入更明確的人設介紹或系列預告",
+ "confidence": 0.74
+ }
+ ]
+}
+```
+
+---
+
+## 14. 策略自我更新 Learning Engine
+
+```mermaid
+flowchart TD
+ A[成效分析] --> B[更新主題權重]
+ A --> C[更新公式權重]
+ A --> D[更新 CTA 權重]
+ A --> E[更新發布時間]
+ A --> F[更新禁用規則]
+ A --> G[更新內容比例]
+ B --> H[Strategy Version]
+ C --> H
+ D --> H
+ E --> H
+ F --> H
+ G --> H
+ H --> I[下一輪產文使用]
+```
+
+### 14.1 策略權重更新
+
+第一版不要 fine-tune,先更新權重。
+
+```json
+{
+ "strategy_version": "v3",
+ "content_mix": {
+ "emotion_posts": 30,
+ "knowledge_posts": 30,
+ "interaction_posts": 20,
+ "life_posts": 10,
+ "conversion_posts": 10
+ },
+ "opening_weights": {
+ "反差開場": 35,
+ "情緒直球": 25,
+ "問題開場": 20,
+ "場景開場": 15,
+ "金句開場": 5
+ },
+ "cta_weights": {
+ "經驗募集型": 35,
+ "收藏型": 25,
+ "共鳴型": 20,
+ "轉發型": 10,
+ "無CTA": 10
+ }
+}
+```
+
+### 14.2 策略版本化
+
+每次更新都要記錄原因。
+
+```json
+{
+ "from_version": "v2",
+ "to_version": "v3",
+ "changes": [
+ {
+ "field": "content_mix.interaction_posts",
+ "from": 15,
+ "to": 20,
+ "reason": "過去 14 天互動文 reply rate 高於帳號平均 38%"
+ },
+ {
+ "field": "opening_weights.金句開場",
+ "from": 15,
+ "to": 5,
+ "reason": "金句開場連續三週互動下降,且 AI 感評分偏高"
+ }
+ ]
+}
+```
+
+---
+
+## 15. Dashboard 設計
+
+首頁應該像「AI 經營戰情室」。
+
+### 15.1 首頁區塊
+
+```text
+今日建議發文
+- 推薦主題
+- 推薦任務
+- 推薦切角
+- 推薦時間
+- 風險提醒
+
+草稿工廠
+- 待審核
+- 可排程
+- 需重寫
+- 已發布
+
+昨日成效
+- 最佳貼文
+- 最差貼文
+- 主要原因
+- 下一步建議
+
+策略更新
+- 本週提高什麼比例
+- 本週降低什麼比例
+- 新增禁用規則
+```
+
+### 15.2 單篇貼文分析頁
+
+```text
+貼文內容
+內容任務:互動文
+主題:第一次看生殖門診
+開頭類型:問題開場
+中段類型:故意不講滿
+CTA:經驗募集型
+
+成效:
+- impressions
+- likes
+- replies
+- reposts
+- follows gained
+
+AI 分析:
+- 為什麼有效
+- 哪裡可以更好
+- 是否適合延伸成系列
+- 下次建議用什麼變奏
+```
+
+---
+
+## 16. DB Schema 草案
+
+### 16.1 accounts
+
+```sql
+CREATE TABLE accounts (
+ id UUID PRIMARY KEY,
+ name TEXT NOT NULL,
+ platform TEXT NOT NULL DEFAULT 'threads',
+ persona TEXT,
+ positioning TEXT,
+ tone_config JSONB,
+ avoid_config JSONB,
+ content_pillars JSONB,
+ status TEXT DEFAULT 'active',
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+);
+```
+
+### 16.2 sources
+
+```sql
+CREATE TABLE sources (
+ id UUID PRIMARY KEY,
+ account_id UUID REFERENCES accounts(id),
+ type TEXT NOT NULL,
+ title TEXT,
+ url TEXT,
+ file_path TEXT,
+ raw_content TEXT,
+ parsed_status TEXT DEFAULT 'pending',
+ metadata JSONB,
+ created_at TIMESTAMP DEFAULT NOW()
+);
+```
+
+### 16.3 knowledge_chunks
+
+```sql
+CREATE TABLE knowledge_chunks (
+ id UUID PRIMARY KEY,
+ account_id UUID REFERENCES accounts(id),
+ source_id UUID REFERENCES sources(id),
+ content TEXT NOT NULL,
+ topics JSONB,
+ entities JSONB,
+ claims JSONB,
+ style_tags JSONB,
+ risk_level TEXT,
+ embedding VECTOR,
+ created_at TIMESTAMP DEFAULT NOW()
+);
+```
+
+### 16.4 topics
+
+```sql
+CREATE TABLE topics (
+ id UUID PRIMARY KEY,
+ account_id UUID REFERENCES accounts(id),
+ name TEXT NOT NULL,
+ source TEXT,
+ category TEXT,
+ heat_score NUMERIC,
+ fit_score NUMERIC,
+ interaction_score NUMERIC,
+ extend_score NUMERIC,
+ risk_score NUMERIC,
+ final_score NUMERIC,
+ status TEXT DEFAULT 'candidate',
+ created_at TIMESTAMP DEFAULT NOW()
+);
+```
+
+### 16.5 formula_pools
+
+```sql
+CREATE TABLE formula_pools (
+ id UUID PRIMARY KEY,
+ account_id UUID REFERENCES accounts(id),
+ type TEXT NOT NULL,
+ name TEXT NOT NULL,
+ pattern TEXT,
+ use_case JSONB,
+ avoid JSONB,
+ weight NUMERIC DEFAULT 1,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+);
+```
+
+### 16.6 content_plans
+
+```sql
+CREATE TABLE content_plans (
+ id UUID PRIMARY KEY,
+ account_id UUID REFERENCES accounts(id),
+ topic_id UUID REFERENCES topics(id),
+ mission TEXT NOT NULL,
+ target_audience TEXT,
+ angle TEXT,
+ opening_type TEXT,
+ body_type TEXT,
+ emotion TEXT,
+ ending_type TEXT,
+ cta_type TEXT,
+ risk_level TEXT,
+ requires_human_review BOOLEAN DEFAULT FALSE,
+ avoid JSONB,
+ status TEXT DEFAULT 'planned',
+ created_at TIMESTAMP DEFAULT NOW()
+);
+```
+
+### 16.7 drafts
+
+```sql
+CREATE TABLE drafts (
+ id UUID PRIMARY KEY,
+ account_id UUID REFERENCES accounts(id),
+ content_plan_id UUID REFERENCES content_plans(id),
+ content TEXT NOT NULL,
+ version_name TEXT,
+ ai_score NUMERIC,
+ formula_score NUMERIC,
+ brand_fit_score NUMERIC,
+ risk_score NUMERIC,
+ similarity_score NUMERIC,
+ freshness_score NUMERIC,
+ status TEXT DEFAULT 'draft',
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+);
+```
+
+### 16.8 published_posts
+
+```sql
+CREATE TABLE published_posts (
+ id UUID PRIMARY KEY,
+ account_id UUID REFERENCES accounts(id),
+ draft_id UUID REFERENCES drafts(id),
+ platform_post_id TEXT,
+ published_at TIMESTAMP,
+ publish_mode TEXT,
+ status TEXT DEFAULT 'published',
+ created_at TIMESTAMP DEFAULT NOW()
+);
+```
+
+### 16.9 post_metrics
+
+```sql
+CREATE TABLE post_metrics (
+ id UUID PRIMARY KEY,
+ post_id UUID REFERENCES published_posts(id),
+ collected_at TIMESTAMP NOT NULL,
+ impressions INT,
+ likes INT,
+ replies INT,
+ reposts INT,
+ quotes INT,
+ follows_gained INT,
+ profile_visits INT,
+ engagement_rate NUMERIC,
+ metadata JSONB,
+ created_at TIMESTAMP DEFAULT NOW()
+);
+```
+
+### 16.10 strategy_versions
+
+```sql
+CREATE TABLE strategy_versions (
+ id UUID PRIMARY KEY,
+ account_id UUID REFERENCES accounts(id),
+ version TEXT NOT NULL,
+ content_mix JSONB,
+ opening_weights JSONB,
+ cta_weights JSONB,
+ formula_weights JSONB,
+ posting_windows JSONB,
+ change_reason JSONB,
+ is_active BOOLEAN DEFAULT FALSE,
+ created_at TIMESTAMP DEFAULT NOW()
+);
+```
+
+### 16.11 learning_events
+
+```sql
+CREATE TABLE learning_events (
+ id UUID PRIMARY KEY,
+ account_id UUID REFERENCES accounts(id),
+ post_id UUID REFERENCES published_posts(id),
+ insight_type TEXT,
+ finding TEXT,
+ recommendation TEXT,
+ confidence NUMERIC,
+ applied_status TEXT DEFAULT 'pending',
+ created_at TIMESTAMP DEFAULT NOW()
+);
+```
+
+---
+
+## 17. API Endpoint 草案
+
+### 17.1 Account
+
+```text
+POST /api/accounts
+GET /api/accounts/:id
+PATCH /api/accounts/:id
+GET /api/accounts/:id/brain
+POST /api/accounts/:id/rebuild-brain
+```
+
+### 17.2 Source / Ingestion
+
+```text
+POST /api/accounts/:id/sources/upload
+POST /api/accounts/:id/sources/url
+POST /api/accounts/:id/sources/obsidian-import
+GET /api/accounts/:id/sources
+POST /api/sources/:source_id/parse
+```
+
+### 17.3 Topic Radar
+
+```text
+POST /api/accounts/:id/topic-radar/scan
+GET /api/accounts/:id/topics
+PATCH /api/topics/:topic_id
+POST /api/topics/:topic_id/create-plan
+```
+
+### 17.4 Content Plan / Draft
+
+```text
+POST /api/accounts/:id/content-plans
+GET /api/accounts/:id/content-plans
+POST /api/content-plans/:id/generate-drafts
+GET /api/content-plans/:id/drafts
+PATCH /api/drafts/:id
+POST /api/drafts/:id/quality-check
+POST /api/drafts/:id/approve
+POST /api/drafts/:id/reject
+```
+
+### 17.5 Publishing
+
+```text
+POST /api/drafts/:id/schedule
+POST /api/drafts/:id/publish
+GET /api/accounts/:id/calendar
+```
+
+### 17.6 Insights / Learning
+
+```text
+POST /api/published-posts/:id/collect-metrics
+GET /api/accounts/:id/performance
+POST /api/accounts/:id/analyze-performance
+POST /api/accounts/:id/update-strategy
+GET /api/accounts/:id/strategy-versions
+```
+
+---
+
+## 18. Worker / Cron Jobs
+
+### 18.1 每日 Job
+
+```text
+每日 08:00
+- 掃描話題
+- 更新候選題庫
+- 產生今日內容建議
+```
+
+```text
+每日 10:00
+- 根據今日內容建議產生草稿
+- 執行品質檢查
+- 高信心進排程,中信心進審核
+```
+
+```text
+每日 12:00 / 21:00
+- 執行排程發文
+```
+
+### 18.2 成效回收 Job
+
+```text
+每小時
+- 找出已發布但未收 1h / 6h / 24h / 72h 成效的貼文
+- 呼叫平台 API 收集 insights
+- 寫入 post_metrics
+```
+
+### 18.3 每週學習 Job
+
+```text
+每週一 09:00
+- 分析過去 7 / 14 天成效
+- 產生 learning_events
+- 產生 strategy_version draft
+- 若 confidence 高,自動套用低風險策略更新
+- 若 confidence 中,進人工確認
+```
+
+---
+
+## 19. MVP 開發階段
+
+### MVP 1:AI 社群助理
+
+目標:先證明「找題 + 產文 + 檢查」有價值。
+
+功能:
+
+1. 建立帳號。
+2. 上傳 Excel / Markdown。
+3. 建立 Brand Brain。
+4. 手動輸入關鍵字。
+5. AI 產生候選話題。
+6. AI 產生 Content Plan。
+7. AI 產 3 版草稿。
+8. AI 感與公式感檢查。
+9. 人工核准。
+10. 一鍵複製發文。
+
+驗收:
+
+```text
+使用者可以在 10 分鐘內:
+建立帳號 → 匯入資料 → 產出 5 篇可用 Threads 草稿。
+```
+
+### MVP 2:半自動營運
+
+目標:開始排程與成效回收。
+
+功能:
+
+1. Topic Radar 每日自動掃描。
+2. 自動產生每日 3-5 篇候選草稿。
+3. 人工審核。
+4. 排程發文。
+5. 手動或 API 回收成效。
+6. 產生週報。
+
+驗收:
+
+```text
+使用者每週只要審核草稿,系統能自動維持一週發文節奏。
+```
+
+### MVP 3:低風險自動發布
+
+目標:低風險內容自動發,高風險內容人工審。
+
+功能:
+
+1. 自動發布低風險草稿。
+2. 高風險草稿強制人工審核。
+3. 發文後自動回收成效。
+4. 自動更新部分策略權重。
+
+驗收:
+
+```text
+系統能連續 14 天自動經營帳號,且不出現高風險錯誤內容。
+```
+
+### MVP 4:自我改善閉環
+
+目標:讓系統真的越跑越準。
+
+功能:
+
+1. 每週自動分析成效。
+2. 自動調整內容比例。
+3. 自動調整公式權重。
+4. 自動產生內容節奏表。
+5. 生成策略版本與變更原因。
+
+驗收:
+
+```text
+系統能說明:
+這週為什麼提高互動文比例,
+為什麼降低某種開頭,
+下週策略和上週有何不同。
+```
+
+---
+
+## 20. Coding Agent 開發任務建議
+
+### Phase 1:資料模型與帳號大腦
+
+```text
+1. 建立 DB schema。
+2. 建立 Account CRUD。
+3. 建立 Source upload API。
+4. 實作 Excel parser。
+5. 實作 Markdown parser。
+6. 建立 Brand Brain builder。
+7. 將 parsed content 寫入 knowledge_chunks。
+```
+
+### Phase 2:話題與策略
+
+```text
+1. 建立 Topic model。
+2. 實作手動 keyword topic import。
+3. 實作 Topic Score。
+4. 建立 Content Plan generator。
+5. 建立 Formula Pool seed data。
+```
+
+### Phase 3:產文與品質檢查
+
+```text
+1. 建立 Draft generator。
+2. 每個 Content Plan 產出 3 版草稿。
+3. 實作 AI 感檢查。
+4. 實作公式感檢查。
+5. 實作相似度檢查。
+6. 實作人工 approve / reject。
+```
+
+### Phase 4:排程與發布
+
+```text
+1. 建立 Schedule table。
+2. 實作草稿排程。
+3. 實作一鍵複製模式。
+4. 實作 Web Intent fallback。
+5. 預留 Threads API publisher adapter。
+```
+
+### Phase 5:成效與學習
+
+```text
+1. 建立 Published Posts。
+2. 建立 Post Metrics。
+3. 實作手動成效輸入。
+4. 實作 performance analyzer。
+5. 產生 learning_events。
+6. 建立 strategy_versions。
+```
+
+---
+
+## 21. 產品原則
+
+### 21.1 不要做洗稿工具
+
+系統可以分析競品,但不能直接複製競品內容。
+
+允許:
+
+```text
+分析主題
+分析切角
+分析公式
+分析情緒槓桿
+分析 CTA
+分析互動方式
+```
+
+禁止:
+
+```text
+直接複製原文
+高度相似改寫
+批量搬運他人貼文
+規避平台規範的自動化抓取
+```
+
+### 21.2 人設一致,但形式要變
+
+```text
+語氣一致
+主題一致
+價值觀一致
+
+但:
+開頭要變
+結尾要變
+CTA 要變
+長短要變
+內容任務要變
+```
+
+### 21.3 高風險內容要保守
+
+醫療、金融、法律類內容,預設要查證與人工審核。
+
+### 21.4 自我改善要可解釋
+
+AI 每次調整策略,都要說明:
+
+```text
+調整了什麼
+為什麼調整
+依據哪段成效
+信心分數多少
+是否需要人工確認
+```
+
+---
+
+## 22. 最終產品定位
+
+這不是一般 AI 發文工具。
+
+更精準的定位是:
+
+```text
+AI Threads Account Operator
+```
+
+或:
+
+```text
+AI Content Brain for Threads
+```
+
+一句話描述:
+
+```text
+讓 AI 像內容編輯一樣,替每個 Threads 帳號找題、選切角、產文、排程、分析成效,並根據結果持續調整經營策略。
+```
+
+產品真正的價值不是「會寫文」,而是:
+
+```text
+知道為什麼要發這篇,
+知道這篇打中誰,
+知道發完好不好,
+知道下一篇要怎麼改。
+```
+
+---
+
+## 23. 最終閉環摘要
+
+```mermaid
+flowchart TD
+ A[輸入帳號資料與知識] --> B[建立帳號大腦]
+ B --> C[每日找話題]
+ C --> D[挑選適合帳號的主題]
+ D --> E[決定內容任務]
+ E --> F[選公式與切角]
+ F --> G[產生多版本草稿]
+ G --> H[品質檢查]
+ H --> I[排程或人工審核]
+ I --> J[發布]
+ J --> K[回收成效]
+ K --> L[分析有效原因]
+ L --> M[更新策略權重]
+ M --> C
+```
+
+---
+
+## 24. 建議第一版畫面
+
+### 24.1 帳號大腦頁
+
+```text
+帳號名稱
+人設
+定位
+語氣
+禁用語
+主題池
+公式池
+知識來源
+歷史貼文
+成效記憶
+```
+
+### 24.2 今日話題雷達
+
+```text
+今日推薦主題
+- 熱度
+- 帳號適配度
+- 互動潛力
+- 風險分數
+- 推薦內容任務
+```
+
+### 24.3 草稿工廠
+
+```text
+草稿列表
+- 內容任務
+- 主題
+- 公式組合
+- AI 感分數
+- 公式感分數
+- 風險分數
+- 狀態
+```
+
+### 24.4 成效分析頁
+
+```text
+已發布貼文
+成效數據
+AI 分析
+下次建議
+是否延伸系列
+```
+
+### 24.5 策略版本頁
+
+```text
+目前策略版本
+內容比例
+公式權重
+CTA 權重
+發布時間
+本次調整原因
+```
+
+---
+
+## 25. 開發時的最小可交付結果
+
+第一個可交付版本只需要做到:
+
+```text
+1. 建立一個帳號。
+2. 上傳一份 Excel。
+3. 系統解析出帳號大腦。
+4. 手動輸入一個主題。
+5. 系統產出 Content Plan。
+6. 系統產出 3 篇 Threads 草稿。
+7. 系統檢查 AI 感與公式感。
+8. 使用者可以 approve / reject。
+9. 系統記住這次人工回饋。
+```
+
+這樣就可以開始驗證核心價值。
+
+不要一開始就把所有 API、圖譜、全自動發布都做完。
+先讓 AI 真的能寫出「像這個帳號」的內容,再讓它慢慢接管營運。
diff --git a/backend/.DS_Store b/backend/.DS_Store
deleted file mode 100644
index 3397521..0000000
Binary files a/backend/.DS_Store and /dev/null differ
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..883b3ab
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,24 @@
+FROM golang:1.24-alpine AS build
+
+WORKDIR /src
+RUN apk add --no-cache ca-certificates git
+COPY backend/go.mod backend/go.sum ./
+RUN go mod download
+COPY backend/ ./
+RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/gateway ./gateway.go \
+ && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/worker ./cmd/worker \
+ && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/tool ./cmd/tool
+
+FROM alpine:3.20
+
+RUN apk add --no-cache ca-certificates tzdata \
+ && addgroup -S haixun \
+ && adduser -S -G haixun -h /app haixun
+WORKDIR /app
+COPY --from=build /out/gateway /app/gateway
+COPY --from=build /out/worker /app/worker
+COPY --from=build /out/tool /app/tool
+COPY backend/etc /app/etc
+USER haixun
+EXPOSE 8890
+CMD ["/app/gateway", "-f", "/app/etc/gateway.prod.yaml"]
diff --git a/backend/Makefile b/backend/Makefile
new file mode 100644
index 0000000..d9ca395
--- /dev/null
+++ b/backend/Makefile
@@ -0,0 +1,47 @@
+.PHONY: gen-api fmt test run web-dev web-build
+
+API_FILE := generate/api/gateway.api
+GOCTL_HOME := generate/goctl
+GOCTL ?= $(shell go env GOPATH)/bin/goctl
+
+ENV_FILE ?= ../deploy/.env.dev
+
+ifneq ($(wildcard $(ENV_FILE)),)
+# 把 deploy/.env.dev 的值 export 出來(給 make run / native binary 用;dev-all.sh 自行 source 故不走這條)
+include $(ENV_FILE)
+export HAIXUN_MONGO_URI HAIXUN_MONGO_DB HAIXUN_REDIS_ADDR HAIXUN_REDIS_PASSWORD
+export HAIXUN_JWT_ACCESS_SECRET HAIXUN_JWT_REFRESH_SECRET HAIXUN_WORKER_SECRET
+export HAIXUN_SECRETS_KEY HAIXUN_BACKEND_URL
+export HAIXUN_WORKER_POLL_MS HAIXUN_8D_TARGET_SAMPLES PLAYWRIGHT_HEADLESS
+export INIT_TENANT_ID INIT_ADMIN_EMAIL INIT_ADMIN_PASSWORD INIT_ADMIN_DISPLAY_NAME
+endif
+
+export HAIXUN_JOB_WORKER_TYPE ?= go-demo
+export HAIXUN_JOB_WORKER_ID ?= local-go-demo
+
+# 修改 generate/api/*.api 後執行,重新產生 routes / types / handler(logic 已存在則保留實作)
+gen-api:
+ $(GOCTL) api go -api $(API_FILE) -dir . -style go_zero -home $(GOCTL_HOME)
+
+fmt:
+ go fmt ./...
+
+test:
+ go test ./...
+
+run:
+ mkdir -p bin
+ go build -o bin/gateway gateway.go
+ ./bin/gateway -f etc/gateway.yaml
+
+run-dev:
+ go run gateway.go -f etc/gateway.yaml
+
+init:
+ go run ./cmd/tool/main.go init -f etc/gateway.yaml
+
+web-dev:
+ cd web && npm install && npm run dev
+
+web-build:
+ cd web && npm install && npm run build
diff --git a/backend/README.md b/backend/README.md
index b77593e..96879cf 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -12,6 +12,7 @@
- `auth`:native email/password 登入、JWT access/refresh token、logout revoke。
- `member`:目前登入會員的 profile 讀寫。
- `permission`:permission catalog 與目前會員權限查詢。
+- `threads automation`:Threads 帳號、OAuth/API 診斷、發文 queue、補庫存、智慧時段、頻率護欄、成效追蹤與語調庫。
暫時不包含 template-monorepo 裡較重的 OAuth / OTP / MFA / Zitadel 整合,也不包含 notification、Playwright worker。這些之後要接時再按服務邊界新增。
@@ -23,6 +24,13 @@ go mod download
make run
```
+正式前端:
+
+```bash
+make web-dev # Vite dev server :5173,proxy 到 :8890
+make web-build # TypeScript + Vite build
+```
+
預設服務:
```text
@@ -61,10 +69,10 @@ HAIXUN_8D_MIN_SAMPLES=1 # 驗證期預設 1;要嚴格一點可調高
前端在人設詳情頁按「開始 8D 分析」後,任務會進入:
```text
-確認連線 -> 抓取樣本 -> AI 8D -> 儲存策略
+準備爬蟲 -> 抓取公開樣本 -> AI 8D -> 儲存策略
```
-目前 Node worker 先用 Playwright 抓 Threads 公開頁樣本並產生可驗證的 8D 結構;若公開頁無法讀到足夠樣本,job 會標記為 `failed` 並顯示原因,不會停在等待狀態。
+人設 8D 分析**不需** Chrome extension session;Node worker 以匿名 Playwright 讀取對標帳號的公開個人頁。若公開頁無法讀到足夠樣本,job 會標記為 `failed` 並顯示原因,不會停在等待狀態。AI 分析使用會員設定頁的研究用 provider / API key(有選定經營帳號時沿用該帳號設定)。
## 專案結構
@@ -72,6 +80,8 @@ HAIXUN_8D_MIN_SAMPLES=1 # 驗證期預設 1;要嚴格一點可調高
haixun-backend/
gateway.go # go-zero server 入口
Makefile # gen-api / fmt / test / run
+ web/ # 正式巡樓 Console(Vite + React + TypeScript)
+ dev-console/ # 本機診斷用最小開發面板,不是產品 UI
etc/ # runtime config
generate/
api/ # goctl .api 定義
@@ -87,6 +97,11 @@ haixun-backend/
auth/ # JWT token issue/refresh/logout + Redis revoke store
member/ # Native member profile + password hash
permission/ # Permission catalog + role permission mapping
+ publish_queue/ # Threads 發文 queue、guarded transition、retry/missed
+ publish_inventory/ # 自動補庫存 policy
+ publish_guard/ # 發文頻率護欄與 pause/resume
+ publish_queue_event/ # Queue 狀態轉移與告警事件
+ style_preset/ # 人設語調 preset、CTA、禁用詞
worker/ # 常駐背景 worker / scheduler / reaper
library/ # 最小 runtime library
response/ # 統一 JSON response envelope
diff --git a/backend/cmd/tool/main.go b/backend/cmd/tool/main.go
index 837a0dc..c6b8c35 100644
--- a/backend/cmd/tool/main.go
+++ b/backend/cmd/tool/main.go
@@ -15,6 +15,8 @@ import (
)
func main() {
+ bootstrap.LoadInfraEnv()
+
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
@@ -37,7 +39,7 @@ func runInit(args []string) error {
configFile := fs.String("f", "etc/gateway.yaml", "config file")
tenantID := fs.String("tenant", envOr("INIT_TENANT_ID", "default"), "tenant id for admin and role permissions")
email := fs.String("email", envOr("INIT_ADMIN_EMAIL", "admin@30cm.net"), "bootstrap admin email")
- password := fs.String("password", envOr("INIT_ADMIN_PASSWORD", "Fafafa54088"), "bootstrap admin password")
+ password := fs.String("password", envOr("INIT_ADMIN_PASSWORD", ""), "bootstrap admin password")
displayName := fs.String("display-name", envOr("INIT_ADMIN_DISPLAY_NAME", "Admin"), "bootstrap admin display name")
if err := fs.Parse(args); err != nil {
return err
diff --git a/backend/cmd/worker/main.go b/backend/cmd/worker/main.go
index e80b632..d54f0a0 100644
--- a/backend/cmd/worker/main.go
+++ b/backend/cmd/worker/main.go
@@ -8,6 +8,7 @@ import (
"os/signal"
"syscall"
+ "haixun-backend/internal/bootstrap"
"haixun-backend/internal/config"
"haixun-backend/internal/svc"
@@ -18,11 +19,12 @@ var configFile = flag.String("f", "etc/gateway.worker.yaml", "config file")
func main() {
flag.Parse()
+ bootstrap.LoadInfraEnv()
var c config.Config
conf.MustLoad(*configFile, &c, conf.UseEnv())
- if !c.JobWorker.Enabled {
- fmt.Fprintln(os.Stderr, "[worker] JobWorker.Enabled must be true")
+ if !c.JobWorker.Enabled && !c.JobScheduler.Enabled && !c.JobReaper.Enabled && !c.ThreadsTokenRefresh.Enabled && !c.ThreadsPublishQueue.Enabled {
+ fmt.Fprintln(os.Stderr, "[worker] at least one worker, scheduler, reaper, token sweeper, or publish queue sweeper must be enabled")
os.Exit(1)
}
@@ -30,10 +32,13 @@ func main() {
defer sc.Close(context.Background())
fmt.Printf(
- "[worker] started type=%s (scheduler=%v reaper=%v)\n",
+ "[worker] started type=%s (job=%v scheduler=%v reaper=%v token_sweeper=%v publish_sweeper=%v)\n",
c.JobWorker.WorkerType,
+ c.JobWorker.Enabled,
c.JobScheduler.Enabled,
c.JobReaper.Enabled,
+ c.ThreadsTokenRefresh.Enabled,
+ c.ThreadsPublishQueue.Enabled,
)
ch := make(chan os.Signal, 1)
diff --git a/backend/dev-console/README.md b/backend/dev-console/README.md
new file mode 100644
index 0000000..3ff7126
--- /dev/null
+++ b/backend/dev-console/README.md
@@ -0,0 +1,37 @@
+# 巡樓 Dev Console
+
+重做正式前端前的**最小開發面板**:登入、人設 CRUD、8D 公開爬蟲任務與 job 輪詢。
+
+## 啟動
+
+```bash
+# 1. 後端 API(另開終端)
+cd backend
+go run gateway.go -f etc/gateway.yaml
+
+# 2. Node 8D worker(另開終端,人設 8D 需要)
+cd backend/worker
+npm install
+npm run style-8d
+
+# 3. Dev Console
+cd backend/dev-console
+npm install
+npm run dev
+```
+
+瀏覽器開啟 http://127.0.0.1:5173 。API 透過 Vite proxy 轉到 `http://127.0.0.1:8890`,無需處理 CORS。
+
+## 使用順序
+
+1. 註冊或登入(tenant 預設 `default`)
+2. **AI 設定**:選研究 provider → 貼 API key → **讀取模型列表** → 選 model → **儲存**
+3. 建立人設 → 點選列表中的一筆
+4. 填對標 Threads 帳號 → **開始 8D**
+5. 自動輪詢 job;完成後下方人設詳情會出現 `style_profile`
+
+## 注意
+
+- 8D 分析使用**研究用** provider / model + API key(與 Chrome session 無關)
+- 若尚無經營帳號,Dev Console 會自動建立「Dev Console」帳號(AI key 實際存會員 scope)
+- 僅供本機開發,不是產品 UI
\ No newline at end of file
diff --git a/backend/dev-console/dist/assets/index-56hNfpGK.js b/backend/dev-console/dist/assets/index-56hNfpGK.js
new file mode 100644
index 0000000..f55f5b3
--- /dev/null
+++ b/backend/dev-console/dist/assets/index-56hNfpGK.js
@@ -0,0 +1,3 @@
+(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const o of document.querySelectorAll('link[rel="modulepreload"]'))i(o);new MutationObserver(o=>{for(const r of o)if(r.type==="childList")for(const f of r.addedNodes)f.tagName==="LINK"&&f.rel==="modulepreload"&&i(f)}).observe(document,{childList:!0,subtree:!0});function s(o){const r={};return o.integrity&&(r.integrity=o.integrity),o.referrerPolicy&&(r.referrerPolicy=o.referrerPolicy),o.crossOrigin==="use-credentials"?r.credentials="include":o.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(o){if(o.ep)return;o.ep=!0;const r=s(o);fetch(o.href,r)}})();const I=102e3,v="haixun.dev-console",t=e=>document.getElementById(e),a=w();function w(){try{return JSON.parse(localStorage.getItem(v)||"{}")}catch{return{}}}function d(){localStorage.setItem(v,JSON.stringify(a))}function c(e,n){const s=t("log"),i=n===void 0?String(e):`${e} ${JSON.stringify(n,null,2)}`;s.textContent=`${new Date().toLocaleTimeString()} ${i}
+
+`+s.textContent}async function l(e,{method:n="GET",body:s,auth:i=!1}={}){const o={"Content-Type":"application/json"};i&&a.accessToken&&(o.Authorization=`Bearer ${a.accessToken}`);const r=await fetch(e,{method:n,headers:o,body:s===void 0?void 0:JSON.stringify(s)}),f=await r.text();let m;try{m=JSON.parse(f)}catch{throw new Error(`非 JSON 回應 ${e}: HTTP ${r.status}`)}if(m.code!==I)throw new Error(m.message||`API 錯誤 ${m.code}`);return m.data}function u(e,n){const s=t("authStatus");s.textContent=e,s.className="status"+(n?" ok":n===!1?" err":"")}function T(e){return`${e||"—"}`}async function $(){try{await l("/api/v1/health"),t("connStatus").textContent="API 連線正常",t("connStatus").className="status ok"}catch(e){t("connStatus").textContent="API 離線:"+e.message,t("connStatus").className="status err"}}function S(e){const n=t("personaList");if(n.innerHTML="",!(e!=null&&e.length)){n.innerHTML='
尚無人設';return}for(const s of e){const i=document.createElement("li");i.className=s.id===a.selectedPersonaId?"active":"",i.innerHTML=`${s.display_name||s.id}${s.style_benchmark?"@"+s.style_benchmark:"未分析"}`,i.onclick=()=>g(s.id),n.appendChild(i)}}async function h(){const e=await l("/api/v1/personas",{auth:!0});a.personas=e.list||[],S(a.personas),d(),c("人設列表已更新",{count:a.personas.length})}function g(e){a.selectedPersonaId=e,d(),S(a.personas||[]),t("btnStart8d").disabled=!e,t("btnReloadPersona").disabled=!e,y()}async function y(){const e=a.selectedPersonaId;if(!e)return;const n=await l(`/api/v1/personas/${encodeURIComponent(e)}`,{auth:!0});t("personaMeta").textContent=`${n.display_name||e} · benchmark @${n.style_benchmark||"—"}`,t("personaDetail").value=JSON.stringify(n,null,2),c("人設詳情",{id:e,style_benchmark:n.style_benchmark})}async function C(){const e=await l("/api/v1/auth/login",{method:"POST",body:{tenant_id:t("tenantId").value.trim(),email:t("email").value.trim(),password:t("password").value}});a.accessToken=e.access_token,a.refreshToken=e.refresh_token,a.uid=e.uid,d(),u(`已登入 uid=${e.uid}`,!0),c("登入成功",{uid:e.uid}),await h(),a.selectedPersonaId&&g(a.selectedPersonaId)}async function N(){const e=await l("/api/v1/auth/register",{method:"POST",body:{tenant_id:t("tenantId").value.trim(),email:t("email").value.trim(),password:t("password").value,display_name:t("email").value.split("@")[0]}});a.accessToken=e.access_token,a.refreshToken=e.refresh_token,a.uid=e.uid,d(),u(`註冊並登入 uid=${e.uid}`,!0),c("註冊成功",{uid:e.uid}),await h()}function O(){a.accessToken="",a.refreshToken="",a.uid="",d(),u("已登出",!1),t("personaList").innerHTML="",t("personaDetail").value="",t("btnStart8d").disabled=!0,t("btnReloadPersona").disabled=!0,c("已登出")}async function _(){const e=t("displayName").value.trim()||"新人設",n=await l("/api/v1/personas",{method:"POST",auth:!0,body:{display_name:e}});t("displayName").value="",await h(),g(n.id),c("建立人設",{id:n.id})}async function L(){const e=a.selectedPersonaId,n=t("benchmark").value.trim().replace(/^@/,"");if(!e||!n)throw new Error("請選人設並填對標帳號");const s=await l(`/api/v1/personas/${encodeURIComponent(e)}/style-analysis`,{method:"POST",auth:!0,body:{benchmark_username:n}});t("jobId").value=s.job_id,t("jobHint").textContent=s.message||"任務已建立",t("jobHint").className="status ok",c("8D 任務已建立",s),await b(),k()}function x(e){var s,i,o;if(!e)return;const n=((s=e.progress)==null?void 0:s.steps)||[];t("jobSteps").innerHTML=n.map(r=>`${T(r.status)} ${r.id} — ${r.message||""}
`).join(""),t("jobDetail").textContent=JSON.stringify({id:e.id,status:e.status,phase:e.phase,percentage:(i=e.progress)==null?void 0:i.percentage,summary:(o=e.progress)==null?void 0:o.summary,error:e.error,result:e.result},null,2),["succeeded","completed","failed","cancelled"].includes(e.status)&&(P(),(e.status==="succeeded"||e.status==="completed")&&y().catch(()=>{}))}async function b(){const e=t("jobId").value.trim();if(!e)return;const n=await l(`/api/v1/jobs/${encodeURIComponent(e)}`,{auth:!0});return x(n),c("Job 狀態",{id:n.id,status:n.status,phase:n.phase}),n}let p=null;function k(){P(),p=setInterval(()=>b().catch(e=>c("輪詢失敗",e.message)),3e3),t("btnAutoPoll").textContent="停止輪詢"}function P(){p&&clearInterval(p),p=null,t("btnAutoPoll").textContent="自動輪詢"}t("btnLogin").onclick=()=>C().catch(e=>{u(e.message,!1),c("登入失敗",e.message)});t("btnRegister").onclick=()=>N().catch(e=>{u(e.message,!1),c("註冊失敗",e.message)});t("btnLogout").onclick=O;t("btnCreatePersona").onclick=()=>_().catch(e=>c("建立失敗",e.message));t("btnRefreshPersonas").onclick=()=>h().catch(e=>c("載入失敗",e.message));t("btnReloadPersona").onclick=()=>y().catch(e=>c("載入失敗",e.message));t("btnStart8d").onclick=()=>L().catch(e=>{t("jobHint").textContent=e.message,t("jobHint").className="status err",c("8D 失敗",e.message)});t("btnPollJob").onclick=()=>b().catch(e=>c("查詢失敗",e.message));t("btnAutoPoll").onclick=()=>p?P():k();a.tenantId&&(t("tenantId").value=a.tenantId);t("tenantId").onchange=()=>{a.tenantId=t("tenantId").value,d()};a.accessToken&&(u(`token 已還原 uid=${a.uid||"?"}`,!0),h().then(()=>{a.selectedPersonaId&&g(a.selectedPersonaId)}).catch(()=>u("token 可能過期,請重新登入",!1)));$();
diff --git a/backend/dev-console/dist/index.html b/backend/dev-console/dist/index.html
new file mode 100644
index 0000000..29c0d8d
--- /dev/null
+++ b/backend/dev-console/dist/index.html
@@ -0,0 +1,225 @@
+
+
+
+
+
+ 巡樓 Dev Console
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 4. 人設詳情
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/dev-console/index.html b/backend/dev-console/index.html
new file mode 100644
index 0000000..7607a45
--- /dev/null
+++ b/backend/dev-console/index.html
@@ -0,0 +1,2188 @@
+
+
+
+
+
+ 巡樓 Dev Console
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
貼文成效追蹤
+
瀏覽、互動與帳號洞察一次看完。數字來自 Threads Graph API,需帳號已 OAuth 連線。
+
+
+
+
+ 篇
+
+
+
+
+
+
+
+
+
+ 各 API scope 能追蹤什麼(展開)
+
+
+
+
+
+
+
+ 3. 發文佇列
+ 排程或立即發文;到期由後端 sweeper 透過 Threads API 發佈,並自動排 +1h / +24h / +7d 成效檢查。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 4. 發文健康度
+ 已發佈貼文與成效 checkpoint 彙總(來自 publish_queue + publish_analytics)。
+
+
+
+
+
+
+
+
+
+ 2b. Threads API 手動測試台
+ 針對選中帳號逐項呼叫 Graph API;寫入類(發文/刪文)會真的改 Threads 上的內容。
+
+
+
個人資料 /me
+
threads_basic
+
+
+
+
我的貼文列表
+
threads_basic
+
+
+
+
關鍵字搜尋
+
threads_keyword_search
+
+
+
+
+
帳號查詢
+
threads_profile_discovery
+
+
+
+
+
提及 mentions
+
threads_manage_mentions
+
+
+
+
帳號洞察 insights
+
threads_manage_insights
+
+
+
+
地點搜尋
+
threads_location_tagging
+
+
+
+
+
貼文統計
+
threads_manage_insights · GET /{media-id}/insights
+
+
+
+
+
貼文洞察 insights
+
threads_manage_insights
+
+
+
+
+
貼文留言
+
threads_read_replies
+
+
+
+
+
發文(真的會發)
+
threads_content_publish
+
+
+
+
+
回覆留言(真的會發)
+
threads_content_publish
+
+
+
+
+
+
刪除貼文
+
threads_delete
+
+
+
+
+
隱藏 / 顯示留言
+
threads_manage_replies
+
+
+
+
+
+
同步到 Instagram
+
threads_share_to_instagram
+
+
+
+
+
刷新 long-lived token
+
token refresh
+
+
+
+ 結果會顯示在下方;成功後可把 media_id 複製到其他卡片繼續測。
+ (尚無結果)
+
+
+
+ 3. AI 設定(8D 研究用)
+ 登入後自動載入經營帳號與 AI 設定
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 8D 分析使用研究用 provider / model。Key 存於後端(會員 scope),Dev Console 不會把完整 key 留在 localStorage。
+
+
+
+
+
+ 5. 8D 風格分析(公開爬蟲,不需 session)
+
+
+
+
+
+ 需先完成上方 AI 設定,並啟動 Go API + Node worker(style-8d)。
+ (尚無任務狀態)
+
+
+
+
+
+
+
+
+ (尚無任務)
+
+
+
+ 6. 人設詳情
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/dev-console/package-lock.json b/backend/dev-console/package-lock.json
new file mode 100644
index 0000000..915a3d4
--- /dev/null
+++ b/backend/dev-console/package-lock.json
@@ -0,0 +1,1141 @@
+{
+ "name": "haixun-dev-console",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "haixun-dev-console",
+ "devDependencies": {
+ "vite": "^6.2.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.15",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.16",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
+ "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/backend/dev-console/package.json b/backend/dev-console/package.json
new file mode 100644
index 0000000..65989f4
--- /dev/null
+++ b/backend/dev-console/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "haixun-dev-console",
+ "private": true,
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "vite": "^6.2.0"
+ }
+}
\ No newline at end of file
diff --git a/frontend/vite.config.ts b/backend/dev-console/vite.config.ts
similarity index 63%
rename from frontend/vite.config.ts
rename to backend/dev-console/vite.config.ts
index 857218e..c753b7c 100644
--- a/frontend/vite.config.ts
+++ b/backend/dev-console/vite.config.ts
@@ -1,10 +1,8 @@
import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
-import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
- plugins: [react(), tailwindcss()],
server: {
+ host: '127.0.0.1',
port: 5173,
proxy: {
'/api': {
diff --git a/backend/docs/phase1-methods.md b/backend/docs/phase1-methods.md
deleted file mode 100644
index a91ebe4..0000000
--- a/backend/docs/phase1-methods.md
+++ /dev/null
@@ -1,176 +0,0 @@
-# 全 Phase 新增功能說明
-
-## Phase 1 — TA 精準度
-
-### 1. Semantic Embedding Reranker
-
-**ScanPost entity 新增欄位**(`internal/model/scan_post/domain/entity/post.go`):
-
-| 欄位 | 型別 | 用途 |
-|------|------|------|
-| `Embedding` | `[]float32` | OpenAI text-embedding-3-small 向量 |
-| `SemanticScore` | `int` | 語意相似度分數(0–100) |
-| `EngagementPredicted` | `int` | 預測互動率 |
-| `AudienceQualityScore` | `int` | 受眾品質分數(0–100) |
-| `AuthorID` | `string` | 作者 ID(Threads user ID) |
-| `AuthorAvatar` | `string` | 作者頭像 URL |
-| `AuthorFollowers` | `int` | 作者粉絲數 |
-
-`ScanCandidate` struct 同步新增對應欄位(`internal/library/placement/dual_track.go`)。
-
-### 2. Embedding Client
-
-```go
-// internal/library/embedding/client.go
-func NewClient(apiKey, model string) *Client
-func (c *Client) Generate(ctx context.Context, text string) ([]float32, error)
-```
-
-- 預設使用 OpenAI `text-embedding-3-small`(可降級 local BGE-M3)
-- Scorer: `CosineSimilarity(a, b []float32) float64`
-
-### 3. Placement Score V2
-
-```go
-// internal/library/placement/dual_track.go
-func computePlacementScore(post *ScanCandidate, member productContext, brand productMeta,
- semanticScore, predictedEngagement, audienceQuality *int) int
-```
-
-整合三項 pointer 參數(nil 表示欄位不存在,跳過該項):
-
-- **語意相似度**(`SemanticScore`):Embedding cosine → 0–100
-- **預測互動率**(`EngagementPredicted`):like+reply → 對數正規化 0–100
-- **受眾品質**(`AudienceQualityScore`):`computeAudienceQuality()` heuristic
-
-權重:語意 20% / 互動 30% / 品質 10% / 舊有 productFit + solvedBy + recency 40%。
-
-```go
-func computeAudienceQuality(followers int) int
-```
-
-Heuristic:followers 在 500–50k 之間品質最佳,過低或過高遞減。
-
-### 4. Creator Lookalike
-
-**端點**: `POST /api/v1/scan-post/lookalike`(需 JWT)
-
-```json
-{ "source_post_id": "uuid", "brand_id": "...", "limit": 10 }
-
-→ { "list": [{ "post_id", "author_id", "author_name", "similarity", ... }] }
-```
-
-**演算法**: cosine similarity(門檻 ≥0.3),同 brand 內找 embedding 最接近的貼文回傳。
-
-| 檔案 | |
-|------|--|
-| Handler | `internal/handler/scan_post/lookalike_handler.go` |
-| Logic | `internal/logic/scan_post/lookalike_logic.go` |
-| `cosineSimilarity` | `internal/logic/scan_post/lookalike_logic.go` — 全 Go 實作 |
-
----
-
-## Phase 2 — 營運流程
-
-### 2.1 Creator CRM
-
-**完整四層 model** `crm_contact`:
-
-```
-internal/model/crm_contact/
- domain/entity/entity.go
- domain/repository/repository.go
- domain/usecase/usecase.go
- usecase/usecase.go
- repository/mongo.go
-```
-
-**端點**: `/api/v1/crm/contacts`(需 JWT)
-
-| Method | Path | 用途 |
-|--------|------|------|
-| GET | `/` | List(`page`/`pageSize`/`brand_id`/`status`/`tag`) |
-| POST | `/` | 新增聯絡人(`author_id` + `author_name`) |
-| GET | `/:id` | 取得單筆 |
-| PATCH | `/:id` | 更新(`notes`/`tags`/`status`) |
-| DELETE | `/:id` | 刪除 |
-
-**CRMContact 狀態機**: `lead` → `contacted` → `responded` → `converted` / `archived`
-
-### 2.2 Publish Analytics
-
-**完整四層 model** `publish_analytics`:
-
-**Checkpoint 常數**(`internal/model/publish_analytics/usecase/usecase.go`): `+1h`、`+24h`、`+7d`
-
-**方法**:
-```go
-RecordCheckpoint(ctx, tenantID, ownerUID, summary) (*PublishAnalyticsSummary, error)
-ListByMedia(ctx, tenantID, ownerUID, mediaID) ([]PublishAnalyticsSummary, error)
-```
-
-**Worker handler** `publish-analytics`(step `check`):
-
-呼叫 Threads API `GET /{media-id}?fields=like_count,reply_count,repost_count,quote_count` → 存入 `publish_analytics`。
-
-### 2.3 Threads API Token 刷新
-
-**底層 API**(`internal/library/threadsapi/media.go`):
-```go
-RefreshAccessToken(ctx, currentToken) (*TokenRefreshResult, error)
-// GET /v1.0/refresh_access_token?grant_type=th_refresh_token
-```
-
-**儲存 Token**:
-```go
-// Repository
-SaveAPIAccessToken(ctx, accountID, accessToken, expiresAt) (*entity.Secrets, error)
-// Usecase
-SaveAPIAccessToken(ctx, accountID, accessToken, expiresAt) (*SecretsData, error)
-```
-
-**Worker handler** `refresh-threads-token`(step `refresh`):讀取 secrets → refresh → 儲存。
-
-### 2.4 Rate Limiter
-
-```go
-// internal/library/ratelimit/token_bucket.go
-func NewTokenBucket(rate float64, burst int) *TokenBucket
-func (tb *TokenBucket) Allow() bool
-func (tb *TokenBucket) AllowN(n int) bool
-```
-
-- In-memory token bucket(非 Redis),thread-safe
-- `rate` = tokens/sec,`burst` = 最大 burst
-
----
-
-## Infrastructure
-
-### Job Templates
-
-`internal/model/job/usecase/usecase.go` 新增兩個 template constants + 對應 `Ensure*` methods + `template()` functions:
-
-| Template Type | Steps | Timeout | Retry |
-|---------------|-------|---------|-------|
-| `refresh-threads-token` | `refresh`(go) | 120s | 3 次(30/120/300s) |
-| `publish-analytics` | `check`(go) | 120s | 2 次(60s) |
-
-### Worker Handlers 註冊
-
-`internal/svc/service_context.go` 中註冊:
-
-```go
-jobworker.RegisterRefreshThreadsTokenHandler(runner, RefreshThreadsTokenDeps{...})
-jobworker.RegisterPublishAnalyticsHandler(runner, PublishAnalyticsDeps{...})
-```
-
-### Worker Helper
-
-```go
-// internal/worker/job/expand_graph.go
-func int64Field(payload map[string]any, key string) int64
-```
-
-從 job payload 讀 `int64`,支援 `float64` / `int64` / `int` / `json.Number`。
diff --git a/backend/docs/scan-placement-plan.md b/backend/docs/scan-placement-plan.md
deleted file mode 100644
index c38f09b..0000000
--- a/backend/docs/scan-placement-plan.md
+++ /dev/null
@@ -1,512 +0,0 @@
-# 海巡獲客計畫:知識圖譜 + 雙軌爬取 + 島民交接
-
-> 在既有「背景 Job + 島民交接」上,新增 Topic Knowledge Graph(Brave 驅動)與**雙維度 Tag**(相關 + 近期)+ **雙軌海巡**,強化流程 B 的痛點發現、關鍵字精準度與**產品-痛點匹配**驗證。
-
-## 北極星
-
-海巡找到的貼文/留言,**你的產品是否真的解得了那個問題**(可置入、可回覆、可追蹤)。
-
-流程 B 主線:
-
-```text
-Brave 知識圖譜擴散(周邊延伸)
- → 衍生雙維度搜尋 tag(相關詞 + 近期求助詞)
- → 使用者手動勾選
- → 每個 tag 雙軌爬 Threads(相關軌 + 近期軌,7d 優先 / 30d 補充)
- → productFitScore 篩選
- → 島民協助撰寫獲客留言
-```
-
-## 已拍板決策
-
-| 項目 | 決策 |
-|------|------|
-| 圖譜深度 | **3 層、範圍廣**:核心 → 成因/症狀 → 相鄰情境 |
-| 進海巡的 tag | **使用者手動勾選**(圖譜 UI 多選;島民可 toggle,不預設全選) |
-| 近期窗口 | **7 天內為重點**;不足時補充至 **30 天**;超過 30 天排除 |
-| Brave 預算 | 中等,每輪知識擴展 **10–15 次查詢**(不足可 supplemental 1 輪) |
-| 痛點 tag 候選 | 圖譜衍生 **≥12 候選**,其中痛點/求助類 **≥8** |
-| 流程 A | 保留 `style-8d` 捷徑;matrix + 留言收集疊加於 Phase 2 |
-
----
-
-## 根因診斷(舊系統痛點少、關鍵字不準)
-
-對照舊 Next.js([`lib/ai/prompts/research-map-placement.ts`](../../lib/ai/prompts/research-map-placement.ts)、[`lib/services/scan-tasks.ts`](../../lib/services/scan-tasks.ts)、[`lib/ai/analyze-topic.ts`](../../lib/ai/analyze-topic.ts)):
-
-| 現象 | 根因 | 新系統對策 |
-|------|------|------------|
-| 痛點只抓到 1–2 個 | Placement 壓 `suggestedTags` 至 **2~4**;`PLACEMENT_QUERY_MAX = 8` | 圖譜衍生 ≥8 痛點 tag + supplemental 補充迴圈 |
-| 關鍵字不夠精準 | AI 憑種子詞推測,無外部知識 | Brave `knowledge_expand` 建 TKG,節點附 `evidence[]` |
-| 只有「最相關」 | Recency 只是加分,無獨立近期軌 | **Tag 層**分 `relevance` / `recency`;**Crawl 層**雙軌必跑 |
-| 沒有周邊延伸 | Brave 只做 `site:threads.net` | `knowledge_expand` 做領域知識(成因、懷孕、換季…)再衍生 tag |
-
-新後端原則見 [`AGENTS.md`](../AGENTS.md):**複製模式,不複製舊業務**——移植 Playwright/過濾規則,用 Mongo + Job 重建。
-
----
-
-## 架構總覽
-
-```mermaid
-flowchart TB
- subgraph input [輸入]
- Seed["種子詞"]
- ProductBrief["product_brief"]
- Persona["人設"]
- end
-
- subgraph tkg [知識圖譜]
- ExpandJob["expand-graph job"]
- BraveK["Brave knowledge_expand"]
- TKG["topic_knowledge_graphs"]
- end
-
- subgraph derive [Tag衍生]
- DeriveFn["deriveSearchTagsFromGraph"]
- RelQ["relevanceQueries"]
- RecQ["recencyQueries"]
- end
-
- subgraph select [使用者選擇]
- GraphUI["圖譜 UI 勾選節點/tag"]
- end
-
- subgraph scan [海巡]
- ScanJob["scan job 每tag雙軌"]
- Posts["scan_posts"]
- end
-
- subgraph outcome [驗收]
- Fit["productFitScore"]
- Outreach["outreach + 島民留言"]
- end
-
- Seed --> ExpandJob
- ProductBrief --> ExpandJob
- Persona --> ExpandJob
- ExpandJob --> BraveK --> TKG
- TKG --> DeriveFn
- DeriveFn --> RelQ
- DeriveFn --> RecQ
- RelQ --> GraphUI
- RecQ --> GraphUI
- GraphUI --> ScanJob --> Posts --> Fit --> Outreach
-```
-
----
-
-## Tag 產生完整流水線
-
-Tag **不是** AI 一次吐 2~4 個,而是五段流水線產出:
-
-```mermaid
-flowchart LR
- S["1 種子詞+brief"] --> A["2 AI核心地圖"]
- A --> B["3 Brave knowledge_expand"]
- B --> G["4 合成TKG三層"]
- G --> D["5 deriveSearchTagsFromGraph"]
- D --> R["relevanceQueries"]
- D --> C["recencyQueries"]
-```
-
-| 步驟 | 做什麼 | 產出 |
-|------|--------|------|
-| 1 | 讀 `seed_query`、`product_brief`、`target_audience` | 輸入包 |
-| 2 | AI 產核心 questions/pillars/exclusions | 研究地圖骨架 |
-| 3 | Brave 10–15 次**一般網搜**(非 threadsOnly) | snippets → 候選節點 |
-| 4 | AI 合成 TKG(L0/L1/L2)+ `productFitScore` + `evidence[]` | `topic_knowledge_graphs` |
-| 5 | 每節點壓成 2~8 字真人搜尋詞,分兩套 | `derivedTags` |
-
-### 雙維度 Tag(相關 + 近期都要)
-
-每個圖譜節點衍生:
-
-| 維度 | 用途 | 寫法範例 |
-|------|------|----------|
-| **`relevanceQueries`** | 相關軌:短詞、高命中 | `敏感肌`、`屏障受損` |
-| **`recencyQueries`** | 近期軌:求助語境 + 時間窗 | `敏感肌 請問`、`換季泛紅 推薦` |
-
-- `recencyQueries` 在 Brave `threads_discover` 時加 `after:{7天前日期}`(參考舊 [`scan-web-discover.ts`](../../lib/services/scan-web-discover.ts) `buildPlacementKeywordQueries`)
-- 候選總量:**≥12 tag**(痛點/求助類 **≥8**);**使用者勾選後才 crawl**
-
----
-
-## 痛點 Tag 保底機制
-
-解決「只抓到一兩個痛點」:
-
-```text
-expand-graph 完成 → deriveSearchTagsFromGraph
- IF 痛點/求助類 tag 數 < 8:
- → supplemental_round(最多 1 次,Brave +5 查詢)
- → 追加查詢例:{seed} 困擾、{seed} 求助、{L2節點} 請問、{seed} 推薦
- → AI 補節點 + 補 derivedTags
- IF 仍 < 8:
- → job 標 warning;UI + 島民提示「可重跑 expand 或手動加種子詞」
-```
-
-| 指標 | 舊系統 | 新系統 |
-|------|--------|--------|
-| Placement suggestedTags | 2~4 | 不沿用此上限 |
-| 搜尋任務上限 | 8 | 候選 ≥12,實 crawl = 勾選數 |
-| 痛點類最低 | 無保證 | **≥8**(含 supplemental) |
-
----
-
-## Topic Knowledge Graph(TKG)
-
-### Mongo collection:`topic_knowledge_graphs`
-
-綁 `persona_id` + `seed_query`。
-
-```json
-{
- "seed": "敏感肌",
- "nodes": [
- {
- "id": "n1",
- "label": "敏感肌",
- "nodeKind": "pain",
- "type": "core",
- "layer": 0,
- "placementValue": "high",
- "productFitScore": 95,
- "selectedForScan": false,
- "evidence": [],
- "derivedTags": {
- "relevance": ["敏感肌"],
- "recency": ["敏感肌 請問", "敏感肌 推薦"]
- }
- },
- {
- "id": "n2",
- "label": "懷孕嗅覺敏感",
- "nodeKind": "cause",
- "type": "cause",
- "layer": 2,
- "relation": "可能成因",
- "placementValue": "medium",
- "productFitScore": 40,
- "selectedForScan": false,
- "evidence": [{ "url": "...", "snippet": "..." }],
- "derivedTags": {
- "relevance": ["懷孕皮膚癢", "嗅覺敏感"],
- "recency": ["懷孕 皮膚 癢 請益"]
- }
- },
- {
- "id": "n3",
- "label": "屏障修復原理",
- "nodeKind": "knowledge",
- "type": "mechanism",
- "layer": 1,
- "productFitScore": 70,
- "selectedForScan": false,
- "derivedTags": {
- "relevance": ["屏障受損"],
- "recency": ["屏障受損 怎麼辦"]
- }
- }
- ],
- "edges": [
- { "from": "n1", "to": "n2", "relation": "可能因" },
- { "from": "n1", "to": "n3", "relation": "機制" }
- ],
- "braveSources": [{ "query": "敏感肌 懷孕 原因", "snippet": "...", "url": "..." }],
- "painTagCount": 9,
- "generatedAt": 0
-}
-```
-
-### 三層擴散
-
-```text
-L0 核心:敏感肌
-L1 直接相關:屏障受損、換季泛紅、刺癢
-L2 周邊情境:懷孕荷爾蒙、嗅覺敏感、壓力熬夜、換洗臉產品過敏 …
-```
-
-### 節點語意
-
-| 欄位 | 說明 |
-|------|------|
-| `nodeKind` | `pain`(痛點/求助)、`knowledge`(科普延伸)、`cause`、`symptom` |
-| `placementValue` | 建議優先級,**不決定是否海巡** |
-| `selectedForScan` | 使用者勾選後 `true`,才進 `scan` payload |
-| `productFitScore` | 依 `product_brief`:產品解不解得了 |
-| `derivedTags` | `relevance` + `recency` 兩套查詢詞 |
-| `evidence[]` | L1/L2 必填(Brave snippet 可追溯) |
-
-- `knowledge` 節點:延伸話題/科普靈感,**預設不勾選**;若 snippet 含求助語境可升級為 `pain`
-- `knowledge` 不強制進 placement crawl,除非使用者勾選且 `productFitScore` 達標
-
----
-
-## Brave 雙模式
-
-| 模式 | `threadsOnly` | 用途 |
-|------|---------------|------|
-| `knowledge_expand` | `false` | 建 TKG,找成因/周邊/知識 |
-| `threads_discover` | `true` | 海巡時找 Threads 貼文 |
-
-### L0/L1 查詢模板(plan_queries,上限 15/輪)
-
-```text
-{seed} 常見原因
-{seed} 什麼情況會
-{seed} 初期 症狀
-{seed} 怎麼改善 困擾
-{seed} 求助 推薦
-```
-
-### L2 周邊擴散查詢池(從 brief/受眾推導)
-
-```text
-{seed} 懷孕 相關
-{seed} 壓力 熬夜
-{seed} 換產品 過敏
-{seed} 與 {受眾場景} 的關係
-{L1節點} 原因
-{L1節點} 困擾
-```
-
-Brave 回傳 title/snippet/url → AI 萃取節點與邊 → 寫入 TKG。實作:`internal/library/knowledge/` + Brave adapter(`BRAVE_SEARCH_API_KEY`;參考舊 [`lib/services/web-search.ts`](../../lib/services/web-search.ts))。
-
----
-
-## 完整範例:敏感肌 Walkthrough
-
-**輸入**
-
-- 種子詞:`敏感肌`
-- product_brief:溫和修護、無香料、適合敏感/屏障受損肌
-
-**Brave knowledge_expand(節錄)**
-
-| 查詢 | snippet 線索 | 圖譜節點 |
-|------|--------------|----------|
-| `敏感肌 常見原因` | 屏障受損、過度清潔 | L1 symptom `屏障受損` |
-| `敏感肌 懷孕` | 荷爾蒙、嗅覺/皮膚變敏感 | L2 cause `懷孕嗅覺敏感` |
-| `換季 皮膚 泛紅` | 季節性刺激 | L1 symptom `換季泛紅` |
-
-**衍生 tag(候選,勾選前不 crawl)**
-
-| 節點 | relevanceQuery | recencyQuery | productFit |
-|------|----------------|--------------|------------|
-| 敏感肌 | `敏感肌` | `敏感肌 請問` | 95 |
-| 屏障受損 | `屏障受損` | `屏障受損 推薦` | 90 |
-| 換季泛紅 | `換季泛紅` | `換季泛紅 請問` | 88 |
-| 懷孕皮膚癢 | `懷孕皮膚癢` | `懷孕 皮膚 癢 請益` | 視產品而定 |
-
-**使用者**:勾選 productFit 高的 4 個節點(可不勾懷孕若產品不適用)
-
-**startScan**:每個勾選節點的 relevance + recency 詞都跑雙軌
-
-| 軌道 | 行為 | 本例預期 |
-|------|------|----------|
-| 相關軌 | sort=relevance, limit≈12 | 高互動痛點貼文 |
-| 近期軌 | 7d 優先,不足補 30d | 一週內求助帖 |
-
-**合併** → gold / recent / relevant → `productFitScore` → 獲客台 → 島民 `generateOutreachReply` + fill
-
----
-
-## 近期窗口
-
-| 窗口 | 天數 | 行為 |
-|------|------|------|
-| **重點** | 7 天內 | 優先爬取、優先顯示、排序最高 |
-| **補充** | 8~30 天 | 7 天內不足時才補,排序較低 |
-| **排除** | >30 天 | 不進海巡與獲客清單 |
-
-策略:
-
-1. 每個勾選 tag 的**近期軌**先抓滿 7 天名額
-2. 全輪痛點貼文不足目標時,自動放寬至 30 天
-3. 獲客台預設篩「7 天內」,可切「含 30 天內補充」
-
----
-
-## 雙軌海巡(Tag + Crawl + UI 三層對齊)
-
-**近期軌不是相關軌的副產品**——每個勾選 tag 的 relevance 與 recency 查詢都**必跑**。
-
-| 層級 | 相關 | 近期 |
-|------|------|------|
-| **Tag** | `derivedTags.relevance` 短詞高命中 | `derivedTags.recency` 求助語境 + after 日期 |
-| **Crawl** | 相關軌 sort=relevance, limit≈12 | 近期軌 7d 滿額 → 30d 補 |
-| **UI** | 可篩 `priority=relevant` | 預設 7d + `priority=gold` 置頂 |
-
-合併優先級:
-
-1. 兩軌皆有 → `gold`
-2. 僅近期軌 → `recent`
-3. 僅相關軌 → `relevant`
-
-過濾:移植 `hasPlacementIntent`、`looksLikeCasualChat`(舊 [`lib/topic-anchor.ts`](../../lib/topic-anchor.ts)、[`lib/scan-recency.ts`](../../lib/scan-recency.ts))。
-
-### scan_posts 擴充欄位
-
-- `placement_score`、`priority`(gold/recent/relevant)
-- `product_fit_score`、`solved_by_product`
-- `posted_at`、`search_tag`、`query_dimension`(relevance/recency)
-- `graph_node_id`
-- `replies[]`(可選,`scrape_replies: true`)
-
----
-
-## 產品匹配驗收
-
-每篇海巡結果:
-
-- **`productFitScore`**:痛點 vs `product_brief`
-- **`solvedByProduct`**:獲客留言是否對應產品能力(生成時強制檢查)
-
-獲客台 UI:
-
-- 預設排序:7 天內 + 產品能解決
-- 標示:可置入 / 需人工 / 超出產品範圍
-- 獲客留言:**島民 fill 全文,不自動送出**
-
----
-
-## 島民交接
-
-### job.result.handoff
-
-```json
-{
- "handoff": {
- "flow": "placement",
- "persona_id": "...",
- "pain_tag_count": 9,
- "summary": "12 候選 tag → 勾選 6 節點 → 38 篇;痛點 10(核心 6 + 周邊 4);7 天內 8 篇",
- "pain_breakdown": { "core": 6, "peripheral": 4, "recent_7d": 8 },
- "top_peripheral_hits": ["懷孕皮膚癢", "換季泛紅"],
- "next_route": "/personas/:id/outreach",
- "needs_supplemental_expand": false,
- "connection_required": false
- }
-}
-```
-
-JobMonitor → `islanderHandoffStore`;[`buildIslanderContext`](../web/src/lib/islander/buildIslanderContext.ts) 注入【近期海巡交接】。
-
-### Custom actions
-
-| Action | 用途 |
-|--------|------|
-| `expandKnowledgeGraph` | 觸發 `expand-graph`;`supplemental=true` 補充迴圈 |
-| `toggleGraphNode` | 勾選/取消節點 |
-| `startScan` | `dual_track=true`,只爬 `selectedForScan` 節點 |
-| `generateOutreachReply` | 產獲客留言 |
-| `applyDraft` | fill 留言欄位 |
-
-### 對話路徑(流程 B)
-
-```text
-「幫我找敏感肌的痛點」
- → expand-graph(Brave knowledge_expand + AI 合成 TKG)
- → IF pain_tag_count < 8 → 島民:「要再補一輪 Brave 嗎?」→ supplemental_round
- → 研究頁:圖譜 + 雙維度 tag + productFitScore
- → 使用者手動勾選節點
- → startScan(每詞雙軌:相關 + 近期,7d/30d)
- → outreach → highlight gold/recent → generateOutreachReply + fill
-```
-
----
-
-## Job 模板
-
-| Template | Steps | worker |
-|----------|-------|--------|
-| `expand-graph` | plan_queries → brave_knowledge → ai_synth → derive_tags → [supplemental?] → persist_tkg | go |
-| `scan` | session → crawl_dual_track → replies? → store → filter → ai_fit → persist | node + go |
-| `style-8d` | (既有) | node + go |
-
-執行順序:**expand-graph → 勾選 tag → scan**。
-
----
-
-## API 草案
-
-```text
-POST /api/v1/personas/:id/knowledge-graph/expand # ?supplemental=true
-GET /api/v1/personas/:id/knowledge-graph
-PATCH /api/v1/personas/:id/knowledge-graph/nodes # selectedForScan
-
-POST /api/v1/personas/:id/scan-jobs # graph_id, selected_node_ids, dual_track
-GET /api/v1/personas/:id/scan-posts # recent_7d, product_fit_min, priority
-
-POST /api/v1/personas/:id/outreach-drafts/generate
-```
-
-Internal worker:`POST /workers/scan-posts/batch`、Brave/AI 內部端點。
-
----
-
-## 前端頁面
-
-| 路徑 | 用途 | 島民 label |
-|------|------|------------|
-| `/personas/:id/research` | 圖譜、雙維度 tag、勾選 | 加入海巡、Brave 再擴展 |
-| `/personas/:id/outreach` | 獲客貼文 + 留言 | 獲客留言、標記已處理 |
-| `/personas/:id/matrix` | 流程 A(Phase 2) | 草稿內容 |
-
-研究頁每節點展示:`relevanceQueries`、`recencyQueries`、`productFitScore`、勾選框、上次命中數。
-
----
-
-## 實作分期
-
-### Phase 0a — 知識圖譜 + Tag 流水線
-
-- [ ] Go Brave adapter(`knowledge_expand` / `threads_discover`)
-- [ ] `expand-graph` job:plan_queries → brave → ai_synth → derive_tags
-- [ ] `supplemental_round`(痛點 tag < 8)
-- [ ] Mongo `topic_knowledge_graphs`(含 `derivedTags`、`painTagCount`)
-- [ ] `deriveSearchTagsFromGraph`(relevance + recency 雙陣列)
-- [ ] API expand / get / patch nodes
-
-### Phase 0b — 島民 handoff
-
-- [ ] handoff(含 `pain_tag_count`、`needs_supplemental_expand`)
-- [ ] JobMonitor bridge
-- [ ] custom actions + `ai.islander.system.md` 海巡專章
-
-### Phase 1 — 雙軌 scan + 流程 B
-
-- [ ] Node `crawl_dual_track`(每 tag 相關+近期,7d/30d)
-- [ ] `productFitScore` + outreach UI
-- [ ] **驗收**:敏感肌 → L2(懷孕等)→ 候選痛點 tag ≥8 → 勾選後貼文痛點 ≥8 → 7d 內 ≥5
-
-### Phase 2 — 流程 A
-
-- [ ] matrix + 留言收集 + 島民 fill
-
-### Phase 3 — 自動化
-
-- [ ] job_schedules、Brave 熔斷、Meta API 發留言
-
----
-
-## 風險
-
-| 議題 | 對策 |
-|------|------|
-| Brave 幻覺 | 節點必須有 `evidence[]` |
-| 圖譜跑題 | exclusions + `productFitScore` |
-| 查詢爆炸 | Brave ≤15/輪;supplemental ≤5;衍生 ≤20;只爬勾選 |
-| 醫療敏感 | `disclaimer`;留言不自動發 |
-| 周邊節點產品不符 | 低 productFit 預設不勾;獲客台標 ✗ |
-
----
-
-## 參考
-
-- 舊海巡:[`lib/services/scan.ts`](../../lib/services/scan.ts)
-- 舊網搜:[`lib/services/scan-web-discover.ts`](../../lib/services/scan-web-discover.ts)
-- 舊研究地圖:[`lib/ai/analyze-topic.ts`](../../lib/ai/analyze-topic.ts)
-- Job 系統:[`docs/job-system-plan.md`](./job-system-plan.md)
-- 島民:[`internal/library/prompt/files/ai.islander.system.md`](../internal/library/prompt/files/ai.islander.system.md)
-- 既有 8D:[`worker/style-8d-worker.ts`](../worker/style-8d-worker.ts)
\ No newline at end of file
diff --git a/backend/docs/similar-accounts-ta-plan.md b/backend/docs/similar-accounts-ta-plan.md
deleted file mode 100644
index bb51acf..0000000
--- a/backend/docs/similar-accounts-ta-plan.md
+++ /dev/null
@@ -1,307 +0,0 @@
-# 相似帳號 / 對標帳號 / 找 TA — 改進計畫
-
-## ⚠ 換手進度(2026-06-28)
-
-### ✅ Phase 1 已完成(全部測試通過)
-`make fmt && make test` 全 ok;`make build-frontend` 通過。`go build ./...` 通過。
-
-已完成項目:
-- **1.1 名額放寬**:`MaxSimilarAccounts` 5 → 10(`backend/internal/library/viral/discover_accounts.go:15`)。
-- **1.2 停止清空**:`analyze_copy_mission.go` 不再 `SimilarAccounts = nil`,改用 `similarAccountsFromSummary(mission.ResearchMap.SimilarAccounts)` 保留前次(`backend/internal/worker/job/analyze_copy_mission.go:128`)。
-- **1.3 合併取代覆蓋**:`scan_viral.go` 改 `libviral.MergeSimilarAccounts(prev, new)`(`backend/internal/worker/job/scan_viral.go:195`)。
-- **1.4 排序權重抽出**:`reference_accounts.go` 新增 `ReferenceRankWeights` + `DefaultReferenceRankWeights` + `rankScore`(log-scale follower bucket 1k/10k/100k/1M、topicHits 加權),排序用加權和;`postTopicRelevant` 改為 `topicTopicHits` 回傳 hits 並用 `normalisedTopicTerms` lower-case 取代 `topicTerms`。
-- **1.5 web search fallback**:`scan_viral.go:170` `BuildReferenceAccountsFromScan` 後若 `len(referenceAccounts) < MaxSimilarAccounts` 且 `placement.WebSearchAvailable(research)` → 呼 `supplementSimilarAccountsFromWeb`(`scan_viral.go:369`),透過 `websearch.New(memberCtx.WebSearchConfig())` 建 client → `DiscoverSimilarAccounts` → `MergeSimilarAccounts` 串接。失敗安全:web search 失敗不讓 job fail。
-- **1.6 ProfileURL helper**:新檔 `backend/internal/library/threadsapi/url.go` `ProfileURLFromPermalink(permalink, username)`,優先用 permalink 反推、fallback `https://www.threads.com/@`(`.com` 為 canonical host)。`reference_accounts.go:137`、`enrich_accounts.go`、`discover_accounts.go` 已改用。
-- **1.7 Brave StartPublishedDate**:`brave.SearchOptions` 加 `StartPublishedDate string` 欄位(forward-compat,目前 brave client 尚未傳給 API);`websearch/client.go` braveAdapter 把 `opts.StartPublishedDate` 塞進 libbrave.SearchOptions。Exa 原本就有支援。
-- **1.8 前端顯示**:`frontend/src/types/copyMission.ts` `CopySimilarAccountData` 加 `matched_source?: string[]` + `similarAccountSourceTier`/`similarAccountSourceLabel` helper;`frontend/src/lib/viralSignals.ts` 加 `similarAccountTone`;`CopyMissionDetailPage.tsx:837-878` row 加「來源」badge(scan+web/sky/success、web/sky、scan/brand)、「置信度」小字。
-- **schema**:`missionentity.SimilarAccount` 加 `MatchedSource []string`(`backend/internal/model/copy_mission/domain/entity/mission.go:27`);`domusecase.SimilarAccountSummary` 同步;`usecase.toSummary` 帶 matched;`types.CopySimilarAccountData` 同步;`logic/copy_mission/mapper.go` entity↔API 兩處對映帶 matched;`generate/api/copy_mission.api` 的 `CopySimilarAccountData` 同步(手動補,**未跑 `make gen-api`**——必要時下一手可跑 gen-api 重新產生以對齊)。
-- 新檔:
- - `backend/internal/library/viral/merge_accounts.go` `MergeSimilarAccounts` + `unionMatchedSource`。
- - `backend/internal/library/threadsapi/url.go` `ProfileURLFromPermalink`。
- - `backend/internal/worker/job/similar_accounts.go` `similarAccountsFromSummary`(summary→entity 轉換 helper,給 worker 跨 rerun 保留 Previous)。
-
-### 未做、給下一手
-- **Phase 2**:相似帳號變一等公民(`Status`/`TopicRelevance`/`LastSeenAt` schema + PATCH API + worker 尊重 excluded/pinned + 前端釘選/排除按鈕 + Placement 維持不動)。詳見下方 Phase 2 章節。
-- **Phase 3**:TA 樣本(留言作者採樣,`AudienceSample` + `audience.go` + worker 接入 + PATCH API + 前端「TA 樣本」區)。詳見下方 Phase 3 章節。
-- **Phase 4**:跨任務記憶(後續,未規劃細節)。
-- **重新 gen-api**:`generate/api/copy_mission.api` 只有 `CopySimilarAccountData` 加 `matched_source` 一處,types.go 已手動同步。若下一手要擴 API schema(Phase 2),建議跑 `make gen-api` 重新產 handler/logic/types 確保對齊。
-- **新測試**:Phase 1 沒額外加新 test case。建議下一手補:`viral/merge_accounts_test.go`(`MergeSimilarAccounts` merges、preserves prev、truncates to limit、unionMatchedSource dedup)、`viral/reference_accounts_test.go` 新排序 case(topicHits 加權、log-scale follower bucket)、`worker/job/scan_viral_test.go` web fallback case(mock websearch client)。這些 test 是 Phase 1 的「補強」層,不阻塞 Phase 2 進行。
-
-### 已動的檔案清單(Phase 1)
-**Go 後端**
-- `backend/internal/library/viral/discover_accounts.go`(常數、ProfileURL helper、accountCandidate.permalink)
-- `backend/internal/library/viral/reference_accounts.go`(ReferenceRankWeights、topicHits、MatchedSource、ProfileURL helper、刪 topicTerms)
-- `backend/internal/library/viral/enrich_accounts.go`(MatchedSource、ProfileURL helper、authorScore.permalink)
-- `backend/internal/library/viral/merge_accounts.go`(**新檔**)
-- `backend/internal/library/threadsapi/url.go`(**新檔**)
-- `backend/internal/library/websearch/client.go`(braveAdapter 傳 StartPublishedDate)
-- `backend/internal/library/brave/client.go`(SearchOptions 加 StartPublishedDate)
-- `backend/internal/worker/job/scan_viral.go`(web fallback + MergeSimilarAccounts + supplementSimilarAccountsFromWeb)
-- `backend/internal/worker/job/analyze_copy_mission.go`(停止清空 SimilarAccounts)
-- `backend/internal/worker/job/similar_accounts.go`(**新檔**:similarAccountsFromSummary)
-- `backend/internal/model/copy_mission/domain/entity/mission.go`(SimilarAccount 加 MatchedSource)
-- `backend/internal/model/copy_mission/domain/usecase/usecase.go`(SimilarAccountSummary 加 MatchedSource)
-- `backend/internal/model/copy_mission/usecase/usecase.go`(toSummary 帶 matched)
-- `backend/internal/types/types.go`(CopySimilarAccountData 加 matched_source)
-- `backend/generate/api/copy_mission.api`(CopySimilarAccountData 加 matched_source)
-- `backend/internal/logic/copy_mission/mapper.go`(兩處對映帶 matched)
-
-**前端**
-- `frontend/src/types/copyMission.ts`(CopySimilarAccountData 加 matched_source + helper)
-- `frontend/src/lib/viralSignals.ts`(similarAccountTone)
-- `frontend/src/pages/CopyMissionDetailPage.tsx`(row 顯示 source badge、置信度小字)
-
-### 設計選擇備忘
-- `similarAccountsFromSummary` 只帶 summary 可見欄位(沒 `TopicRelevance/LastSeenAt/Status`,那些是 Phase 2)。Phase 1 schema 只補 `MatchedSource`,其他 Phase 2 欄位別在 Phase 1 先加,避免污染。
-- 排序權重改成 log-scale follower bucket(1k/10k/100k/1M cut),避免 mega 帳號壓過 niche 高相關作者。`ReferenceRankWeights` 結構在 `reference_accounts.go` 內部,未注入 input,未來要調整再加。
-- `ProfileURL` 從 `https://www.threads.net/@` 改 `https://www.threads.com/@`(threads.com 現在是 canonical host,前端 `frontend/src/lib/threadsLinks.ts` 用 `threadsProfileUrl(username, profileUrl)` 已能接受 `profile_url` injected,不需改)。
-- `MergeSimilarAccounts` 串接 priority:新(scan+web)> 舊(保舊不被清掉),命中兩條 source 的 username `MatchedSource` 會 union 含 scan 與 web,但 **confidence 不會自動升 high**(confidence 由 `BuildReferenceAccountsFromScan` 內 strict/relaxed gate + verified + bestEngagement 算出來)。若未來要把「跨來源」當 high confidence 的訊號,需在 `MergeSimilarAccounts` 或 mapper 後處理;目前只透過前端 `MatchedSource` badge 視覺呈現。
-- web search fallback 只在 `< MaxSimilarAccounts(=10)` 時觸發,避免每次 scan 都額外打 web 浪費 quota;也避免 web 來的帳號擠掉 scan 來的(因為 new 排前面)。
-- Brave `StartPublishedDate` 欄位加了但**沒 map 到 Brave 的 `freshness` 參數**(Brave API freshness 接受 `d/w/m/y` 而非 ISO date,要做轉換)。Phase 1 只做資訊不丟失;實際生效需要第二階段處理 Brave adapter 的 `freshness` mapping。Exa 已有支援。
-
-### 沒動的檔案(確認 boundary)
-- Placement / Persona / Brand schema 完全沒動。
-- 沒碰 `internal/handler/routes.go`、`internal/svc/service_context.go`、`internal/response`、`internal/middleware`。
-- 沒動 `internal/model/copy_mission/repository`、`internal/model/copy_mission/domain/repository`。
-
----
-
-> 範圍:僅 CopyMission flow。Placement(B 流海巡獲客)刻意維持「無對標帳號」設計,本計畫不動其 schema。
-> 上限:`MaxSimilarAccounts` 自 5 放寬到 10。
-> 遵循:`code/message/data` envelope、`page/pageSize`、`SSCCCDDD` 錯誤碼、UTC+0 nanoseconds、Redis `workerID` lock、不裸 `Update` job 狀態。
-> 驗證:`go mod tidy && make fmt && go test ./...`;前端動到另執行 `make web-build`。
-
----
-
-## 背景:threads-api-skill repo 對位
-
-參考 repo `madebypan/threads-api-skill` 是**純發布工具包**(容器→publish 30s wait、reply_to_id 串貼、catbox.moe 圖床、60 天 token refresh、Chrome 自動化 setup),**沒有「找相似帳號 / 找對標 / 找 TA」功能**。它整理的 Threads Graph API endpoint 與踩坑,可用來校正下列事實:
-
-- `me?fields=id,name,username,...` 取 self 資訊 → 目前 `threadsapi/client.go` 完全沒用,無法驗 token 對應 username。
-- `keyword_search`(TOP/RECENT,50/page,無 cursor)→ 已實作;目前沒做翻頁,召回量受限。
-- `media/{id}/replies` → 已實作,目前只當 outreach 標的,**沒把留言作者彙整成 TA 樣本**。
-- Threads Graph API **沒有 endpoint 查任意別人的 follower / 受眾輪廓** → 「找 TA」必須繞道:把留言作者/互動者彙整成潛在受眾樣本。
-
----
-
-## 目前狀態(viral 流程摸整)
-
-- 唯一活著的相似帳號產生路徑:`scan_viral.go` job → `libviral.RunDiscover`(抓 threads 貼文)→ `BuildReferenceAccountsFromScan`(by author 聚合 + verified/follower/engagement 排序,strict gate→relaxed fallback)→ 寫入 `Mission.ResearchMap.SimilarAccounts`(`reference_accounts.go:43`)。
-- `DiscoverSimilarAccounts` / `EnrichSimilarAccounts` / `ToEntitySimilarAccounts` 是 dead code(只有測試呼叫)。
-- `analyze_copy_mission.go:138` 顯式 `researchMap.SimilarAccounts = nil` → 重新跑研究地圖 job 會清空相似帳號,前端沒警告。
-- API 對相似帳號純唯讀:`UpdateCopyMissionReq`(`copy_mission.api:60-72`)沒 `similar_accounts` 欄位,使用者無法排除/釘選。
-- 「找 TA」目前只是 LLM 產出的一段文字 `AudienceSummary`,**沒有任何「找出受眾樣本帳號」的機制**,連結構都沒有。
-- 驗證缺位:`ProfileURL` 一律 `https://www.threads.net/@` 拼字串(`reference_accounts.go:137`、`enrich_accounts.go:77`、`discover_accounts.go:119`),不驗真。
-- 純 Threads API 模式下 `AuthorVerified=false, FollowerCount=0` 恆成立,排序權重失衡。
-- 排序權重寫死:`reference_accounts.go:108-119` 固定 verified→follower→engagement,沒有主題 pillar 相似度、沒有跨任務記憶、沒有使用者可調。
-- `postTopicRelevant`(`reference_accounts.go:166-180`)用裸 `strings.Contains`,中文長 label 命中精度低。
-
----
-
-## Phase 1 — 資料品質低風險修正(先做)
-
-### 1.1 名額放寬
-- `backend/internal/library/viral/discover_accounts.go:14`:`MaxSimilarAccounts = 5` → `10`。
-- `scan_viral.go:177`:`Limit: libviral.MaxSimilarAccounts` 維持引用常數。
-- 前端已用 list render,不需改。
-
-### 1.2 停止 `analyze_copy_mission` 清空相似帳號
-- `backend/internal/worker/job/analyze_copy_mission.go:138`:刪 `researchMap.SimilarAccounts = nil`。
-- 確認 `model/copy_mission/usecase/usecase.go` dot-path 更新其他 research_map 欄位時不會覆蓋 `similar_accounts`(worker 才會用 `Patch.ResearchMap` 整包,且 worker 現在會保留 SimilarAccounts)。
-
-### 1.3 scan 結果改合併而非覆蓋
-- `scan_viral.go:189-197`:`SimilarAccounts: referenceAccounts` → `SimilarAccounts: mergeSimilarAccounts(mission.ResearchMap.SimilarAccounts, referenceAccounts)`。
-- 新檔 `backend/internal/library/viral/merge_accounts.go`:by username 合併,新來的更新統計、舊有保留;Phase 1 schema 還沒 `Status`,先純保舊不被清掉。
-- `mergeSimilarAccounts(prev, new []missionentity.SimilarAccount) []missionentity.SimilarAccount`:
- - key = lowercase username;新>舊(更新統計欄位),舊沒被新覆蓋就保留。
- - 排序:保留新來秩序在前 N 個,舊的接在後面直到達 `MaxSimilarAccounts`。
-
-### 1.4 排序權重抽出 + 主題相關改用命中詞數
-- `reference_accounts.go:108-119`:抽出 `ReferenceRankWeights` struct(`VerifiedW/FollowerW/TotalEngagementW/BestEngagementW/TopicRelevanceW`,預設 `4/2/1/1/2`),排序 key = 加權和。
-- `reference_accounts.go:166-180` `postTopicRelevant`:仍維持「命中詞數 >= 1 才納入」,但額外回傳 `topicHitCount` 給排序用。
-- `referenceAuthorAgg` 加 `topicHits int`;`BuildReferenceAccountsFromScan` 內部迴圈累加。
--硼切詞暫不引入 jieba:對 CJK 用 `strings.Fields` + 拆字元組(>=2 字才當 term)即可,避免新依賴。
-
-### 1.5 web search 補洞(復活 dead code 線)
-- `scan_viral.go:172` `BuildReferenceAccountsFromScan` 之後,若 `len(referenceAccounts) < 5` 且 `memberCtx.WebSearchEnabled()`:
- ```go
- extra, err := libviral.DiscoverSimilarAccounts(ctx, websearchClient, libviral.DiscoverAccountsInput{
- SeedQuery: mission.SeedQuery,
- Brief: mission.Brief,
- Pillars: mission.ResearchMap.Pillars,
- })
- if err == nil && len(extra) > 0 {
- referenceAccounts = libviral.EnrichSimilarAccounts(referenceAccounts, nil, libviral.MaxSimilarAccounts)
- // 把 web 找到的也 by username 合併進去
- }
- ```
-- `enrich_accounts.go`:`Source` 改成集合概念:`viral.SimilarAccount` 與 `missionentity.SimilarAccount` 都加 `MatchedSource []string`,命中 web 也命中 scan = `confidence="high"`。
-- web search 失敗**不可讓 scan job 失敗**,只跳過並 log。
-
-### 1.6 threadsapi profile URL helper
-- 新檔 `backend/internal/library/threadsapi/url.go`:`ProfileURLFromPermalink(permalink, username) string`:
- - regex `threads\.(?:com|net)/@([^/]+)/post/` 從 permalink 抓 author root `https://www.threads.com/@/`。
- - fallback `https://www.threads.com/@`(注意 `www.threads.com` 而非 `www.threads.net` 較新且穩)。
-- `reference_accounts.go:137`、`enrich_accounts.go:77`、`discover_accounts.go:119` 改用此 helper。
-
-### 1.7 Brave adapter 補 StartPublishedDate
-- `backend/internal/library/websearch/client.go:114-127` Brave adapter:把 `SearchOptions.StartPublishedDate`(即便 Brave API 不支援)也讀進 `libbrave.SearchOptions`,讓未來 Brave 端支援時自動生效。實作細節:libbrave.SearchOptions 若沒對應欄位就先無效果,但頂層 client 不能丟失資訊。
-
-### 1.8 前端顯示新增資訊
-- `frontend/src/pages/CopyMissionDetailPage.tsx:837-875` row:加 `source` badge(已有 `AccountSource` 文字時顯示 `brand`/`neutral`)、`confidence` 用 `text-muted` 小字。
-- `frontend/src/lib/viralSignals.ts:42-48` `viralAccountRowClass`:依 Phase 1 schema 補 `MatchedSource` → 範含 `scan` 與 `web` 用 `--verified-hot` 樣式。
-- `frontend/src/types/copyMission.ts`:`SimilarAccount` 加 `matched_source?: string[]`(Phase 1 只補這欄位,舊資料缺少不影響渲染)。
-- 執行 `make web-build`。
-
----
-
-## Phase 2 — 相似帳號變一等公民(動 API,CopyMission only)
-
-### 2.1 schema 變更
-`backend/internal/model/copy_mission/domain/entity/mission.go:23-35` `SimilarAccount` 加:
-```go
-Status string `bson:"status,omitempty" json:"status,omitempty"` // recommended|pinned|excluded|promoted
-TopicRelevance float64 `bson:"topic_relevance,omitempty" json:"topic_relevance,omitempty"`
-LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"`
-MatchedSource []string `bson:"matched_source,omitempty" json:"matched_source,omitempty"`
-```
-`internal/types/types.go` 同步(重新 gen-api)。
-
-### 2.2 API 新增
-`backend/generate/api/copy_mission.api` 加:
-```
-@handler patchCopyMissionSimilarAccount
-patch /personas/:persona_id/copy-missions/:copy_mission_id/similar-accounts/:username (PatchSimilarAccountReq) returns (CommonRes)
-
-type PatchSimilarAccountReq {
- Status string `json:"status"`
-}
-```
-跑 `make gen-api` 重新產 handler/logic/types。logic 走 dot-path:
-```go
-out[fmt.Sprintf("research_map.similar_accounts.%s.status", username)] = strings.TrimSpace(req.Status)
-```
-錯誤碼:CopyMission 目前沒有專屬 scope,**Phase 2 用 `Facade` (10)** 暫時頂著,待新增 `CopyMission Scope=41` 後再回頭改。**禁止裸寫數字**,一律 `errs.For(code.Facade).ResInvalidState("similar account status not allowed")`。
-
-### 2.3 worker 尊重 status
-- `reference_accounts.go:54` 開頭:`if isExcluded(in.ExcludedUsernames, post.Author) { continue }`。
-- `scan_viral.go:170`:把 `mission.ResearchMap.SimilarAccounts` 中 `status=excluded` 的 username 傳進 `ReferenceAccountInput.ExcludedUsernames`。
-- `merge_accounts.go`:`status=pinned` 永遠置頂,本次沒掃到也保留。
-
-### 2.4 前端互動
-- `CopyMissionDetailPage.tsx` row 加兩按鈕「釘選」「排除」,呼 `api.patch(.../similar-accounts/:username, {status})`,本地 optimistic update。
-- 新檔 `frontend/src/lib/copyMissionSimilarAccounts.ts` 包 API client。
-- `CopyMissionResearchOverview.tsx` 加「相似帳號」子區(從 detail 頁整合進來)。
-
----
-
-## Phase 3 — TA 樣本(留言作者採樣,全新功能)
-
- Threads API 拿不到任意別人的受眾 → 繞道:用 `MediaReplies` 的 `ReplyCandidate.Author` 聚合成潛在受眾樣本。
-
-### 3.1 schema 變更
-`backend/internal/model/copy_mission/domain/entity/mission.go` `ResearchMap` 加:
-```go
-AudienceSamples []AudienceSample `bson:"audience_samples,omitempty" json:"audience_samples,omitempty"`
-```
-新 struct:
-```go
-type AudienceSample struct {
- Username string `bson:"username" json:"username"`
- SamplePostID string `bson:"sample_post_id,omitempty" json:"sample_post_id,omitempty"`
- SampleText string `bson:"sample_text,omitempty" json:"sample_text,omitempty"`
- ReplyLikeCount int `bson:"reply_like_count,omitempty" json:"reply_like_count,omitempty"`
- Appearances int `bson:"appearances,omitempty" json:"appearances,omitempty"`
- FirstSeenAt int64 `bson:"first_seen_at" json:"first_seen_at"`
- LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"`
- Status string `bson:"status,omitempty" json:"status,omitempty"`
-}
-```
-`generate/api/copy_mission.api` 在 `CopyResearchMap` 加 `AudienceSamples []CopyAudienceSampleData`;`make gen-api`。
-
-### 3.2 新 library
-`backend/internal/library/viral/audience.go`:
-```go
-func BuildAudienceSamplesFromReplies(replies []placement.ReplyCandidate, opts AudienceOpts) []missionentity.AudienceSample
-```
-- `appearance = by username.Count`
-- 過濾:自己 mission 的 author(即 SimilarAccount source)排除、純連結/數字罐頭 reply 文字過濾(`len < 5 chars` or URL-only)。
-- 排序 `appearance desc → replyLikeCount desc → postedAt desc`,前 15 筆。
-- confidence(前端呈現用,不入 struct):`appearance >= 3` high、`= 2` medium、`= 1` low。
-
-### 3.3 worker 接入
-`scan_viral.go` 在 `AttachReplies` 完成後:
-```go
-var allReplies []placement.ReplyCandidate
-for _, p := range postsWithReplies {
- allReplies = append(allReplies, p.Replies...)
-}
-samples := libviral.BuildAudienceSamplesFromReplies(allReplies, libviral.AudienceOpts{
- Max: 15,
- ExcludeAuthors: similarAccountUsernames,
-})
-researchMap.AudienceSamples = mergeAudienceSamples(prev.Samples, samples)
-```
-合併邏輯與 `merge_accounts.go` 同款。
-
-### 3.4 API
-- `GET /copy-missions/:id` 已含(在 `research_map.audience_samples`)。
-- `PATCH /copy-missions/:id/audience-samples/:username` body `{status}`,dot-path 更新 `research_map.audience_samples..status`。
-- `make gen-api` + 邏輯同 2.2。
-
-### 3.5 前端
-- `CopyMissionResearchOverview.tsx` 新區「TA 樣本」(在「相似帳號」之後):
- - 每筆 `@username`(連 `threadsProfileUrl`)、出現 N 篇、最近一次樣本文字。
- - 按鈕「收藏為長期受眾」(status=pinned)、「忽略」(status=excluded)。
-- 新檔 `frontend/src/lib/copyMissionAudience.ts` 包 API client。
-- `frontend/src/index.css` 加 `.hx-audience-samples*` token,沿用 `hx-copy-similar-accounts` 結構。
-
-### 3.6 Phase 3 驗收
-- `make web-build` 過、`go test ./...` 過。
-- E2E:開 Threads API 連線 → 跑 scan job → detail page 顯示「TA 樣本」最多 15 筆、@username 連結不 404、可釘選後重跑 scan 不消失。
-
----
-
-## Phase 4 — 跨任務記憶(後續,本計畫不實作)
-
-`ThreadsAccount` 層加 `KnownAccounts map[username]AccountProfile{ FirstSeenAt, LastSeenAt, Missions[], PersonaIds[], Tags[], AvgEngagement, Status }`,scan job 結束 upsert。同一 username 在多 mission 出現 → confidence 累積、auto-suggest 為 persona benchmark;已 excluded 跨任務繼承。
-
----
-
-## 移交檢查表
-
-### 要動的檔案(Phase 1)
-**Go 後端**
-- `internal/library/viral/discover_accounts.go`(常數、ProfileURL helper)
-- `internal/library/viral/reference_accounts.go`(排序權重、topicHits)
-- `internal/library/viral/enrich_accounts.go`(MatchedSource 集合)
-- `internal/library/viral/merge_accounts.go`(新檔)
-- `internal/library/threadsapi/url.go`(新檔,profileURL helper)
-- `internal/library/websearch/client.go`(Brave 補 StartPublishedDate)
-- `internal/worker/job/scan_viral.go`(web 補洞、merge 取代覆蓋)
-- `internal/worker/job/analyze_copy_mission.go:138`(停止清空)
-- `internal/model/copy_mission/domain/entity/mission.go`(SimilarAccount 加 MatchedSource)
-- `internal/types/types.go` 同步(gen-api 或手動加)
-
-**前端(Phase 1.8)**
-- `src/pages/CopyMissionDetailPage.tsx`(顯示 source/confidence)
-- `src/lib/viralSignals.ts`(MatchedSource badge 對應)
-- `src/types/copyMission.ts`(加 matched_source)
-
-### 要動的檔案(Phase 2-3,後續 agent)
-- `model/copy_mission/domain/entity/mission.go`(Status、TopicRelevance、LastSeenAt、AudienceSample、AudienceSamples)
-- `generate/api/copy_mission.api`(兩支 PATCH endpoint + types)
-- `internal/logic/copy_mission/patch_similar_account_logic.go` + `patch_audience_sample_logic.go`(gen 產出後手寫)
-- `internal/library/viral/audience.go`(新檔)
-- `internal/worker/job/scan_viral.go`(excluded 傳遞、audience samples、pinned 保留)
-- `frontend/src/components/CopyMissionResearchOverview.tsx`(整合區)
-- `frontend/src/lib/copyMissionSimilarAccounts.ts`、`copyMissionAudience.ts`(新)
-- `frontend/src/index.css`(`.hx-audience-samples*`)
-
-### 錯誤碼配置
-- Phase 2 patch similar 錯誤:CopyMission 無專屬 scope → 先用 `errs.For(code.Facade).ResInvalidState("similar account status not allowed")`;待新增 `CopyMission=41` 後再回頭改。**禁止裸寫數字**。
-
-### 失敗安全
-- 任何 web search 呼叫失敗不能讓 scan job fail,只跳過。
-- `mergeSimilarAccounts` 必須處理 prev=nil。
-- `AudienceSamples`(Phase 3)在無 Threads API token 時直接跳過整段,不報錯。
\ No newline at end of file
diff --git a/backend/docs/threads-automation-gap-plan.md b/backend/docs/threads-automation-gap-plan.md
new file mode 100644
index 0000000..23cd0b9
--- /dev/null
+++ b/backend/docs/threads-automation-gap-plan.md
@@ -0,0 +1,133 @@
+# Threads 自動營運補功能計畫
+
+## 目前狀態
+
+P1/P2 已落地到後端 API、worker 與正式前端:
+
+- 自動補庫存:`publish_inventory_policies` + `refill-publish-inventory` job template/worker。
+- 智慧時段:`GET /publish-slot-insights` 從 publish health / checkpoints 聚合推薦時段。
+- 草稿批量排程:Persona 與 Copy Mission 草稿可批量排到 `publish_queue`,草稿回寫 `scheduled + publish_queue_id`。
+- Retry / 告警 / 漏發:`publish_queue_events` 記錄狀態轉移;sweeper 會標記 missed;failed/cancelled/missed 可 retry。
+- 頻率護欄:`publish_guard_policies` 支援每日上限、最小間隔、連續失敗 pause / resume。
+- P2:內容日曆 date range query、多帳號 dashboard summary、style preset 語調庫、OAuth/API diagnostics。
+
+## 目標
+
+把現有「海巡找題、AI 文案、手動排程、成效追蹤」串成自動營運閉環,讓巡樓 Console 不只是一個排程工具,而是可持續補庫存、控制風險、追蹤成效的 Threads 工作台。
+
+## P1:自動補庫存
+
+### 功能
+
+- 每個 Threads account 可設定每日/每週目標篇數。
+- 可設定 weekday/time slots 與 timezone;timezone 僅用於 cron/排程解讀,儲存仍用 unix nanoseconds UTC。
+- 指定內容來源:persona、copy mission、brand scan posts、manual seed。
+- 當 `publish_queue` 未來 N 天庫存低於門檻時,自動建立 copy generation job,完成後排入 queue。
+
+### 後端草案
+
+- 新增 model:`internal/model/publish_inventory/`。
+- 新增設定 entity:
+ - `account_id`
+ - `target_daily_count`
+ - `low_stock_threshold`
+ - `lookahead_days`
+ - `timezone`
+ - `slots`
+ - `source_refs`
+ - `enabled`
+- 新增 job step:`refill_publish_inventory`。
+- 補 API:
+ - `GET /api/v1/threads-accounts/:accountId/publish-inventory-policy`
+ - `PUT /api/v1/threads-accounts/:accountId/publish-inventory-policy`
+ - `POST /api/v1/threads-accounts/:accountId/publish-inventory/refill-jobs`
+
+## P1:智慧時段
+
+### 功能
+
+- 第一版以規則為主:帳號的 weekday/time slots + 最小間隔。
+- 用 publish health 的 1h/24h/7d checkpoints 回填每個 slot 的平均互動。
+- 前端顯示「推薦時段」與「低成效時段」。
+
+### 後端草案
+
+- 新增 summary usecase,從 `publish_analytics` 聚合 account slot performance。
+- 補 API:
+ - `GET /api/v1/threads-accounts/:accountId/publish-slot-insights`
+- 回傳:
+ - `slots[]`
+ - `avg_likes_1h/24h/7d`
+ - `avg_replies_1h/24h/7d`
+ - `sample_size`
+ - `recommendation`
+
+## P1:草稿批量排程
+
+### 功能
+
+- 使用者在 copy drafts 中選多篇,一次排到未來 slots。
+- 支援根據智慧時段自動分配 `scheduled_at`。
+- draft 排入 queue 後狀態改為 `scheduled`,保留 queue item id。
+
+### 後端草案
+
+- 補 API:
+ - `POST /api/v1/personas/:personaId/copy-drafts/schedule`
+ - `POST /api/v1/personas/:personaId/copy-missions/:missionId/copy-drafts/schedule`
+- request:
+ - `account_id`
+ - `draft_ids`
+ - `start_at`
+ - `timezone`
+ - `slots`
+ - `mode`: `manual` / `recommended`
+
+## P1:發文 retry / 告警 / 漏發偵測
+
+### 功能
+
+- failed queue item 可重試,保留失敗歷史。
+- sweeper 偵測到超過 grace period 的 scheduled item 未處理,標記為 missed 或重新 enqueue。
+- 連續失敗超過門檻時 pause account publish,避免 token 或 rate limit 問題擴大。
+
+### 後端草案
+
+- `publish_queue_events` 記錄 queue 狀態轉移與錯誤分類。
+- `retry` 使用 guarded update,只允許 `failed/cancelled` 回到 `scheduled`。
+- 補 API:
+ - `POST /api/v1/threads-accounts/:accountId/publish-queue/:queueId/retry`
+ - `GET /api/v1/threads-accounts/:accountId/publish-queue/:queueId/events`
+ - `GET /api/v1/threads-accounts/:accountId/publish-alerts`
+
+## P1:頻率護欄
+
+### 功能
+
+- 每帳號每日上限。
+- 最小發文間隔。
+- 連續失敗自動 pause。
+- 手動解除 pause。
+
+### 後端草案
+
+- 擴充 Threads account connection prefs 或新增 `publish_guard_policy`。
+- sweeper dispatch 前檢查 policy;違反則延後排程,不直接發文。
+- 補 API:
+ - `GET /api/v1/threads-accounts/:accountId/publish-guard-policy`
+ - `PUT /api/v1/threads-accounts/:accountId/publish-guard-policy`
+ - `POST /api/v1/threads-accounts/:accountId/publish-guard/resume`
+
+## P2
+
+- 內容日曆:補 queue date range query,前端做週/月視圖。
+- 多帳號 dashboard:補跨帳號 summary endpoint,避免前端 N+1 requests。
+- 語調庫:把 `style_profile`、CTA、禁用詞、品牌口吻抽成 reusable preset。
+- OAuth/API 診斷:把 smoke test、token expiry、permission scope 做成標準診斷報告。
+
+## 驗證策略
+
+- 所有列表維持 `page/pageSize` 與 `pagination/list`。
+- 所有時間欄位寫入 unix nanoseconds。
+- queue/job 狀態轉移都使用 guarded update。
+- 長任務必須 heartbeat,取消走既有 cooperative cancel 語意。
diff --git a/backend/etc/gateway.dev.example.yaml b/backend/etc/gateway.dev.example.yaml
deleted file mode 100644
index 0b423bf..0000000
--- a/backend/etc/gateway.dev.example.yaml
+++ /dev/null
@@ -1,45 +0,0 @@
-Name: haixun-backend
-Host: 0.0.0.0
-Port: 8890
-Timeout: 120000
-
-# 本地「docker 測試」設定(make dev-up 使用)。
-# 連線走 compose service name(mongo / redis),密碼對齊 deploy/.env.dev。
-# 純本機 go run(不進 docker)請改用 etc/gateway.yaml(連 127.0.0.1)。
-Mongo:
- URI: mongodb://haixun:haixun-dev-pass@mongo:27017/?authSource=admin
- Database: haixun
- TimeoutSeconds: 10
-
-Redis:
- Addr: redis:6379
- Password: haixun-dev-redis
- DB: 0
-
-Auth:
- AccessSecret: haixun-dev-access-secret-change-me
- RefreshSecret: haixun-dev-refresh-secret-change-me
- AccessExpireSeconds: 900
- RefreshExpireSeconds: 2592000
- # 僅本地開發開啟:允許用 X-Tenant-ID / X-UID header 模擬登入。正式環境必須 false。
- DevHeaderFallback: true
-
-Secrets:
- # 留空 = 機敏資料不加密(本地測試方便)。正式環境用 ${HAIXUN_SECRETS_KEY}。
- EncryptionKey: ""
-
-InternalWorker:
- Secret: haixun-dev-worker-secret
-
-# dev 用 gateway 內嵌 go worker(勿另開 worker 容器,避免兩個 worker 搶 job)。
-JobWorker:
- Enabled: true
- WorkerType: go
-
-JobScheduler:
- Enabled: true
- IntervalSeconds: 60
-
-JobReaper:
- Enabled: true
- IntervalSeconds: 30
diff --git a/backend/etc/gateway.prod.yaml b/backend/etc/gateway.prod.yaml
index e6ca1e3..580f5aa 100644
--- a/backend/etc/gateway.prod.yaml
+++ b/backend/etc/gateway.prod.yaml
@@ -3,8 +3,6 @@ Host: 0.0.0.0
Port: 8890
Timeout: 120000
-# 連線字串與所有 secret 都從環境變數注入(systemd EnvironmentFile=/opt/haixun/etc/haixun.env)。
-# go-zero 以 conf.UseEnv() + os.ExpandEnv 展開 ${VAR};未設定的變數會展開為空字串並讓服務 fail fast。
Mongo:
URI: ${HAIXUN_MONGO_URI}
Database: ${HAIXUN_MONGO_DB}
@@ -30,12 +28,28 @@ InternalWorker:
JobWorker:
Enabled: false
- WorkerType: go
+ WorkerType: disabled
JobScheduler:
- Enabled: true
+ Enabled: false
IntervalSeconds: 60
JobReaper:
- Enabled: true
+ Enabled: false
IntervalSeconds: 30
+
+ThreadsOAuth:
+ AppID: "${THREADS_APP_ID}"
+ AppSecret: "${THREADS_APP_SECRET}"
+ RedirectURI: "${THREADS_REDIRECT_URI}"
+ SuccessRedirect: "${THREADS_OAUTH_SUCCESS_REDIRECT}"
+
+ThreadsTokenRefresh:
+ Enabled: false
+ IntervalSeconds: 3600
+ LeadDays: 7
+
+ThreadsPublishQueue:
+ Enabled: false
+ IntervalSeconds: 60
+ BatchSize: 20
diff --git a/backend/etc/gateway.worker.prod.yaml b/backend/etc/gateway.worker.prod.yaml
index 9491084..bd40c65 100644
--- a/backend/etc/gateway.worker.prod.yaml
+++ b/backend/etc/gateway.worker.prod.yaml
@@ -27,13 +27,30 @@ InternalWorker:
Secret: ${HAIXUN_WORKER_SECRET}
JobWorker:
- Enabled: true
- WorkerType: go
+ Enabled: ${HAIXUN_JOB_WORKER_ENABLED}
+ WorkerType: ${HAIXUN_JOB_WORKER_TYPE}
+ WorkerID: ${HAIXUN_JOB_WORKER_ID}
JobScheduler:
- Enabled: false
- IntervalSeconds: 60
+ Enabled: ${HAIXUN_JOB_SCHEDULER_ENABLED}
+ IntervalSeconds: ${HAIXUN_JOB_SCHEDULER_INTERVAL_SECONDS}
JobReaper:
- Enabled: false
- IntervalSeconds: 30
+ Enabled: ${HAIXUN_JOB_REAPER_ENABLED}
+ IntervalSeconds: ${HAIXUN_JOB_REAPER_INTERVAL_SECONDS}
+
+ThreadsOAuth:
+ AppID: "${THREADS_APP_ID}"
+ AppSecret: "${THREADS_APP_SECRET}"
+ RedirectURI: "${THREADS_REDIRECT_URI}"
+ SuccessRedirect: "${THREADS_OAUTH_SUCCESS_REDIRECT}"
+
+ThreadsTokenRefresh:
+ Enabled: ${HAIXUN_THREADS_TOKEN_REFRESH_ENABLED}
+ IntervalSeconds: ${HAIXUN_THREADS_TOKEN_REFRESH_INTERVAL_SECONDS}
+ LeadDays: ${HAIXUN_THREADS_TOKEN_REFRESH_LEAD_DAYS}
+
+ThreadsPublishQueue:
+ Enabled: ${HAIXUN_THREADS_PUBLISH_QUEUE_ENABLED}
+ IntervalSeconds: ${HAIXUN_THREADS_PUBLISH_QUEUE_INTERVAL_SECONDS}
+ BatchSize: ${HAIXUN_THREADS_PUBLISH_QUEUE_BATCH_SIZE}
diff --git a/backend/etc/gateway.worker.yaml b/backend/etc/gateway.worker.yaml
index 05dfaba..af7dcc2 100644
--- a/backend/etc/gateway.worker.yaml
+++ b/backend/etc/gateway.worker.yaml
@@ -5,31 +5,32 @@ Timeout: 120000
# 本機開發 worker 設定(go worker)。搭配 `make dev-infra` 的 Mongo/Redis。
Mongo:
- URI: mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin
- Database: haixun
+ URI: ${HAIXUN_MONGO_URI}
+ Database: ${HAIXUN_MONGO_DB}
TimeoutSeconds: 10
Redis:
- Addr: 127.0.0.1:6379
- Password: change-me-redis-pass
+ Addr: ${HAIXUN_REDIS_ADDR}
+ Password: ${HAIXUN_REDIS_PASSWORD}
DB: 0
Auth:
- AccessSecret: haixun-dev-access-secret-change-me
- RefreshSecret: haixun-dev-refresh-secret-change-me
+ AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET}
+ RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET}
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: false
Secrets:
- EncryptionKey: ""
+ EncryptionKey: ${HAIXUN_SECRETS_KEY}
InternalWorker:
- Secret: haixun-dev-worker-secret
+ Secret: ${HAIXUN_WORKER_SECRET}
JobWorker:
Enabled: true
- WorkerType: go
+ WorkerType: ${HAIXUN_JOB_WORKER_TYPE}
+ WorkerID: ${HAIXUN_JOB_WORKER_ID}
JobScheduler:
Enabled: false
@@ -38,3 +39,13 @@ JobScheduler:
JobReaper:
Enabled: false
IntervalSeconds: 30
+
+ThreadsTokenRefresh:
+ Enabled: false
+ IntervalSeconds: 3600
+ LeadDays: 7
+
+ThreadsPublishQueue:
+ Enabled: false
+ IntervalSeconds: 60
+ BatchSize: 20
diff --git a/backend/etc/gateway.yaml b/backend/etc/gateway.yaml
index 399b90b..d9858a3 100644
--- a/backend/etc/gateway.yaml
+++ b/backend/etc/gateway.yaml
@@ -3,36 +3,35 @@ Host: 0.0.0.0
Port: 8890
Timeout: 120000
-# 本機開發設定。預設搭配 `make dev-infra`(infra/docker-compose.yml)跑的 Mongo/Redis,
-# 帳密對應 infra/.env.example 的預設值。若你改了 .env 密碼,這裡也要同步。
+# 本機開發設定。預設搭配 `make dev-infra`(infra/docker-compose.yml,讀 deploy/.env.dev)跑的 Mongo/Redis。
+# 帳密對應 deploy/.env.dev 的值;若改 .env.dev 密碼,這裡不需動(${} 會重讀)。
Mongo:
- URI: mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin
- Database: haixun
+ URI: ${HAIXUN_MONGO_URI}
+ Database: ${HAIXUN_MONGO_DB}
TimeoutSeconds: 10
Redis:
- Addr: 127.0.0.1:6379
- Password: change-me-redis-pass
+ Addr: ${HAIXUN_REDIS_ADDR}
+ Password: ${HAIXUN_REDIS_PASSWORD}
DB: 0
Auth:
- AccessSecret: haixun-dev-access-secret-change-me
- RefreshSecret: haixun-dev-refresh-secret-change-me
+ AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET}
+ RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET}
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
# 僅本機開發開啟:允許用 X-Tenant-ID / X-UID header 模擬登入。正式環境必須為 false。
DevHeaderFallback: true
Secrets:
- # 留空 = 不加密(本機開發方便)。正式環境用 ${HAIXUN_SECRETS_KEY}。
- EncryptionKey: ""
+ EncryptionKey: ${HAIXUN_SECRETS_KEY}
InternalWorker:
- Secret: haixun-dev-worker-secret
+ Secret: ${HAIXUN_WORKER_SECRET}
JobWorker:
- Enabled: true
- WorkerType: go
+ Enabled: false
+ WorkerType: disabled
JobScheduler:
Enabled: true
@@ -41,3 +40,23 @@ JobScheduler:
JobReaper:
Enabled: true
IntervalSeconds: 30
+
+# Meta Threads OAuth(App Dashboard → 用戶端 OAuth 設定須登記 Redirect URI)
+ThreadsOAuth:
+ AppID: "${THREADS_APP_ID}"
+ AppSecret: "${THREADS_APP_SECRET}"
+ RedirectURI: "${THREADS_REDIRECT_URI}"
+ # OAuth 完成後瀏覽器導回(現在指向 https://threads-tool.30cm.net )
+ SuccessRedirect: "${THREADS_OAUTH_SUCCESS_REDIRECT}"
+
+# 系統任務:在 token 到期前自動 enqueue refresh-threads-token job
+ThreadsTokenRefresh:
+ Enabled: true
+ IntervalSeconds: 3600
+ LeadDays: 7
+
+# 排程發文佇列 sweeper(掃描 scheduled_at 到期項目並 PublishText)
+ThreadsPublishQueue:
+ Enabled: true
+ IntervalSeconds: 60
+ BatchSize: 20
diff --git a/backend/gateway.go b/backend/gateway.go
index 78a8bd2..130f7f7 100644
--- a/backend/gateway.go
+++ b/backend/gateway.go
@@ -5,6 +5,7 @@ import (
"flag"
"fmt"
+ "haixun-backend/internal/bootstrap"
"haixun-backend/internal/config"
"haixun-backend/internal/handler"
"haixun-backend/internal/svc"
@@ -18,6 +19,8 @@ var configFile = flag.String("f", "etc/gateway.yaml", "config file")
func main() {
flag.Parse()
+ bootstrap.LoadInfraEnv()
+
var c config.Config
conf.MustLoad(*configFile, &c, conf.UseEnv())
@@ -28,6 +31,7 @@ func main() {
defer sc.Close(context.Background())
handler.RegisterHandlers(server, sc)
+ handler.RegisterExtraHandlers(server, sc)
fmt.Printf("Starting backend backend at %s:%d...\n", c.Host, c.Port)
server.Start()
diff --git a/backend/generate/api/brand.api b/backend/generate/api/brand.api
index b4102de..d4658e1 100644
--- a/backend/generate/api/brand.api
+++ b/backend/generate/api/brand.api
@@ -36,6 +36,7 @@ type (
ID string `json:"id"`
Label string `json:"label"`
ProductContext string `json:"product_context"`
+ PlacementURL string `json:"placement_url,omitempty"`
MatchTags []string `json:"match_tags,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
@@ -65,12 +66,14 @@ type (
CreateBrandProductReq {
Label string `json:"label" validate:"required"`
ProductContext string `json:"product_context" validate:"required"`
+ PlacementURL string `json:"placement_url,optional"`
MatchTags []string `json:"match_tags,optional"`
}
UpdateBrandProductReq {
Label *string `json:"label,optional"`
ProductContext *string `json:"product_context,optional"`
+ PlacementURL string `json:"placement_url,optional"`
MatchTags []string `json:"match_tags,optional"`
}
@@ -183,6 +186,7 @@ type (
NodeIDs []string `json:"node_ids,optional"`
DualTrack bool `json:"dual_track,optional"`
PatrolMode bool `json:"patrol_mode,optional"`
+ TestPatrol bool `json:"test_patrol,optional"`
PatrolKeywords []string `json:"patrol_keywords,optional"`
}
@@ -219,6 +223,7 @@ type (
PublishedPermalink string `json:"published_permalink,omitempty"`
OutreachUpdateAt int64 `json:"outreach_update_at,omitempty"`
PostedAt string `json:"posted_at,omitempty"`
+ RecencyDays int `json:"recency_days,omitempty"`
Replies []ScanReplyData `json:"replies,omitempty"`
LatestDraft *GenerateOutreachDraftsData `json:"latest_draft,omitempty"`
CreateAt int64 `json:"create_at"`
@@ -235,6 +240,7 @@ type (
Count int `json:"count,optional"`
VoicePersonaID string `json:"voice_persona_id,optional"`
ProductID string `json:"product_id,optional"`
+ Regenerate bool `json:"regenerate,optional"`
}
OutreachDraftItemData {
@@ -258,6 +264,11 @@ type (
Confirm bool `json:"confirm"`
}
+ UpdateOutreachDraftReq {
+ DraftIndex int `json:"draft_index"`
+ Text string `json:"text" validate:"required"`
+ }
+
PublishOutreachDraftData {
ScanPostID string `json:"scan_post_id"`
ReplyID string `json:"reply_id"`
@@ -355,6 +366,12 @@ type (
PublishOutreachDraftReq
}
+ UpdateOutreachDraftHandlerReq {
+ BrandPath
+ DraftID string `path:"draftId" validate:"required"`
+ UpdateOutreachDraftReq
+ }
+
PatchScanPostOutreachHandlerReq {
BrandPath
PostID string `path:"postId"`
@@ -428,6 +445,9 @@ service gateway {
@handler publishOutreachDraft
post /:id/outreach-drafts/publish (PublishOutreachDraftHandlerReq) returns (PublishOutreachDraftData)
+ @handler updateOutreachDraft
+ patch /:id/outreach-drafts/:draftId (UpdateOutreachDraftHandlerReq) returns (GenerateOutreachDraftsData)
+
@handler patchScanPostOutreach
patch /:id/scan-posts/:postId (PatchScanPostOutreachHandlerReq) returns (ScanPostData)
diff --git a/backend/generate/api/copy_mission.api b/backend/generate/api/copy_mission.api
index 9e8ab69..cb60235 100644
--- a/backend/generate/api/copy_mission.api
+++ b/backend/generate/api/copy_mission.api
@@ -165,7 +165,10 @@ type (
}
CopyMissionInspirationReq {
- Keyword string `json:"keyword,optional"`
+ Keyword string `json:"keyword,optional"`
+ ContentDirection string `json:"content_direction,optional"`
+ UseWebSearch bool `json:"use_web_search,optional"`
+ AvoidTopics []string `json:"avoid_topics,optional"`
}
CopyMissionInspirationHandlerReq {
@@ -267,6 +270,26 @@ type (
Message string `json:"message"`
}
+ CopyMissionScheduleCopyDraftsReq {
+ AccountID string `json:"account_id" validate:"required"`
+ DraftIDs []string `json:"draft_ids" validate:"required,min=1"`
+ StartAt int64 `json:"start_at,optional"`
+ Timezone string `json:"timezone,optional"`
+ Slots []PublishSlotData `json:"slots,optional"`
+ Mode string `json:"mode,optional"`
+ }
+
+ ScheduleCopyMissionDraftsHandlerReq {
+ CopyMissionPath
+ CopyMissionScheduleCopyDraftsReq
+ }
+
+ CopyMissionScheduleCopyDraftsData {
+ Scheduled int `json:"scheduled"`
+ List []PublishQueueItemData `json:"list"`
+ Message string `json:"message"`
+ }
+
CopyMissionInspirationSourceData {
Query string `json:"query,omitempty"`
Title string `json:"title,omitempty"`
@@ -275,11 +298,22 @@ type (
}
CopyMissionInspirationData {
+ TopicCandidateID string `json:"topic_candidate_id,omitempty"`
+ ContentPlanID string `json:"content_plan_id,omitempty"`
Label string `json:"label"`
SeedQuery string `json:"seed_query"`
Brief string `json:"brief"`
TrendReason string `json:"trend_reason,omitempty"`
TrendKeywords []string `json:"trend_keywords,omitempty"`
+ Angles []string `json:"angles,omitempty"`
+ Mission string `json:"mission,omitempty"`
+ TargetAudience string `json:"target_audience,omitempty"`
+ OpeningType string `json:"opening_type,omitempty"`
+ BodyType string `json:"body_type,omitempty"`
+ Emotion string `json:"emotion,omitempty"`
+ CtaType string `json:"cta_type,omitempty"`
+ RiskLevel string `json:"risk_level,omitempty"`
+ Avoid []string `json:"avoid,omitempty"`
Sources []CopyMissionInspirationSourceData `json:"sources,omitempty"`
WebSearchUsed bool `json:"web_search_used"`
Message string `json:"message"`
@@ -336,6 +370,9 @@ service gateway {
@handler deleteCopyMissionMatrixDrafts
post /:personaId/copy-missions/:id/matrix-drafts/delete (DeleteCopyMissionMatrixDraftsHandlerReq) returns (DeleteCopyMissionMatrixDraftsData)
+ @handler scheduleCopyMissionDrafts
+ post /:personaId/copy-missions/:id/copy-drafts/schedule (ScheduleCopyMissionDraftsHandlerReq) returns (CopyMissionScheduleCopyDraftsData)
+
@handler getCopyMissionScanSchedule
get /:personaId/copy-missions/:id/scan-schedule (CopyMissionPath) returns (CopyMissionScanScheduleData)
diff --git a/backend/generate/api/member.api b/backend/generate/api/member.api
index 5f2f546..4df8067 100644
--- a/backend/generate/api/member.api
+++ b/backend/generate/api/member.api
@@ -39,6 +39,7 @@ type (
BraveSearchLang string `json:"brave_search_lang"`
ExaUserLocation string `json:"exa_user_location"`
ExpandStrategy string `json:"expand_strategy"` // brave | llm | hybrid
+ DevModeEnabled bool `json:"dev_mode_enabled"`
}
UpdateMemberPlacementSettingsReq {
@@ -49,6 +50,7 @@ type (
BraveSearchLang *string `json:"brave_search_lang,optional"`
ExaUserLocation *string `json:"exa_user_location,optional"`
ExpandStrategy *string `json:"expand_strategy,optional"`
+ DevModeEnabled *bool `json:"dev_mode_enabled,optional"`
}
MemberCapabilitiesData {
diff --git a/backend/generate/api/persona.api b/backend/generate/api/persona.api
index 4b364a1..e363ff0 100644
--- a/backend/generate/api/persona.api
+++ b/backend/generate/api/persona.api
@@ -64,6 +64,23 @@ type (
StartPersonaStyleAnalysisReq
}
+ StartPersonaStyleAnalysisFromTextReq {
+ ReferenceTexts []string `json:"reference_texts,optional"`
+ RawText string `json:"raw_text,optional"`
+ SourceLabel string `json:"source_label,optional"`
+ }
+
+ StartPersonaStyleAnalysisFromTextData {
+ Persona PersonaData `json:"persona"`
+ PostCount int `json:"post_count"`
+ Message string `json:"message"`
+ }
+
+ StartPersonaStyleAnalysisFromTextHandlerReq {
+ PersonaPath
+ StartPersonaStyleAnalysisFromTextReq
+ }
+
StartPersonaViralScanJobReq {
Keywords []string `json:"keywords,optional"`
}
@@ -113,17 +130,29 @@ type (
CopyDraftData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
+ ContentPlanID string `json:"content_plan_id,omitempty"`
CopyMissionID string `json:"copy_mission_id,omitempty"`
ScanPostID string `json:"scan_post_id,omitempty"`
+ FormulaID string `json:"formula_id,omitempty"`
DraftType string `json:"draft_type"`
SortOrder int `json:"sort_order,omitempty"`
Text string `json:"text"`
+ TopicTag string `json:"topic_tag,omitempty"`
Angle string `json:"angle,omitempty"`
Hook string `json:"hook,omitempty"`
Rationale string `json:"rationale,omitempty"`
ReferenceNotes string `json:"reference_notes,omitempty"`
Sources []string `json:"sources,omitempty"`
+ AiScore int `json:"ai_score,omitempty"`
+ FormulaScore int `json:"formula_score,omitempty"`
+ BrandFitScore int `json:"brand_fit_score,omitempty"`
+ RiskScore int `json:"risk_score,omitempty"`
+ SimilarityScore int `json:"similarity_score,omitempty"`
+ EngagementPotential int `json:"engagement_potential,omitempty"`
+ FreshnessScore int `json:"freshness_score,omitempty"`
+ ReviewSuggestion string `json:"review_suggestion,omitempty"`
Status string `json:"status,omitempty"`
+ PublishQueueID string `json:"publish_queue_id,omitempty"`
PublishedMediaID string `json:"published_media_id,omitempty"`
PublishedPermalink string `json:"published_permalink,omitempty"`
PublishedAt int64 `json:"published_at,omitempty"`
@@ -155,10 +184,202 @@ type (
}
UpdateCopyDraftReq {
- Text *string `json:"text,optional"`
- Hook *string `json:"hook,optional"`
- Angle *string `json:"angle,optional"`
- Status *string `json:"status,optional"`
+ Text *string `json:"text,optional"`
+ TopicTag *string `json:"topic_tag,optional"`
+ Hook *string `json:"hook,optional"`
+ Angle *string `json:"angle,optional"`
+ Rationale *string `json:"rationale,optional"`
+ Status *string `json:"status,optional"`
+ }
+
+ TopicCandidateData {
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ Name string `json:"name"`
+ Source string `json:"source,omitempty"`
+ Category string `json:"category,omitempty"`
+ SeedQuery string `json:"seed_query,omitempty"`
+ TrendReason string `json:"trend_reason,omitempty"`
+ TrendKeywords []string `json:"trend_keywords,omitempty"`
+ HeatScore int `json:"heat_score,omitempty"`
+ FitScore int `json:"fit_score,omitempty"`
+ InteractionScore int `json:"interaction_score,omitempty"`
+ ExtendScore int `json:"extend_score,omitempty"`
+ RiskScore int `json:"risk_score,omitempty"`
+ FinalScore float64 `json:"final_score,omitempty"`
+ RecommendedMissions []string `json:"recommended_missions,omitempty"`
+ Status string `json:"status,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+ }
+
+ ContentPlanData {
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ TopicCandidateID string `json:"topic_candidate_id,omitempty"`
+ Topic string `json:"topic"`
+ Mission string `json:"mission"`
+ TargetAudience string `json:"target_audience,omitempty"`
+ Angle string `json:"angle,omitempty"`
+ OpeningType string `json:"opening_type,omitempty"`
+ BodyType string `json:"body_type,omitempty"`
+ Emotion string `json:"emotion,omitempty"`
+ EndingType string `json:"ending_type,omitempty"`
+ CtaType string `json:"cta_type,omitempty"`
+ RiskLevel string `json:"risk_level,omitempty"`
+ RequiresHumanReview bool `json:"requires_human_review,omitempty"`
+ Avoid []string `json:"avoid,omitempty"`
+ SelectedKnowledge []string `json:"selected_knowledge,omitempty"`
+ Status string `json:"status,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+ }
+
+ FeedbackEventData {
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ ContentPlanID string `json:"content_plan_id,omitempty"`
+ DraftID string `json:"draft_id,omitempty"`
+ Decision string `json:"decision"`
+ Note string `json:"note,omitempty"`
+ Snapshot string `json:"snapshot,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ }
+
+ KnowledgeSourceData {
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ SourceType string `json:"source_type"`
+ Title string `json:"title,omitempty"`
+ Filename string `json:"filename,omitempty"`
+ ParsedStatus string `json:"parsed_status,omitempty"`
+ ChunkCount int `json:"chunk_count,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ }
+
+ KnowledgeChunkData {
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ SourceID string `json:"source_id"`
+ Content string `json:"content"`
+ Topics []string `json:"topics,omitempty"`
+ StyleTags []string `json:"style_tags,omitempty"`
+ RiskLevel string `json:"risk_level,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ }
+
+ FormulaPoolData {
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Pattern string `json:"pattern,omitempty"`
+ UseCases []string `json:"use_cases,omitempty"`
+ Avoid []string `json:"avoid,omitempty"`
+ Weight int `json:"weight,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+ }
+
+ CreateKnowledgeSourceReq {
+ SourceType string `json:"source_type" validate:"required"`
+ Title string `json:"title,optional"`
+ Filename string `json:"filename,optional"`
+ Content string `json:"content,optional"`
+ ContentBase64 string `json:"content_base64,optional"`
+ }
+
+ CreateKnowledgeSourceHandlerReq {
+ PersonaPath
+ CreateKnowledgeSourceReq
+ }
+
+ ListKnowledgeSourcesData { List []KnowledgeSourceData `json:"list"` }
+ ListKnowledgeChunksData { List []KnowledgeChunkData `json:"list"` }
+ ListFormulaPoolsData { List []FormulaPoolData `json:"list"` }
+
+ CreateFormulaPoolReq {
+ Type string `json:"type" validate:"required"`
+ Name string `json:"name" validate:"required"`
+ Pattern string `json:"pattern,optional"`
+ UseCases []string `json:"use_cases,optional"`
+ Avoid []string `json:"avoid,optional"`
+ Weight int `json:"weight,optional"`
+ }
+
+ CreateFormulaPoolHandlerReq {
+ PersonaPath
+ CreateFormulaPoolReq
+ }
+
+ ListTopicCandidatesData {
+ List []TopicCandidateData `json:"list"`
+ }
+
+ ListContentPlansData {
+ List []ContentPlanData `json:"list"`
+ }
+
+ CreateContentPlanReq {
+ TopicCandidateID string `json:"topic_candidate_id,optional"`
+ Topic string `json:"topic" validate:"required"`
+ Mission string `json:"mission" validate:"required"`
+ TargetAudience string `json:"target_audience,optional"`
+ Angle string `json:"angle,optional"`
+ OpeningType string `json:"opening_type,optional"`
+ BodyType string `json:"body_type,optional"`
+ Emotion string `json:"emotion,optional"`
+ EndingType string `json:"ending_type,optional"`
+ CtaType string `json:"cta_type,optional"`
+ RiskLevel string `json:"risk_level,optional"`
+ RequiresHumanReview bool `json:"requires_human_review,optional"`
+ Avoid []string `json:"avoid,optional"`
+ SelectedKnowledge []string `json:"selected_knowledge,optional"`
+ }
+
+ CreateContentPlanHandlerReq {
+ PersonaPath
+ CreateContentPlanReq
+ }
+
+ ContentPlanPath {
+ ID string `path:"id" validate:"required"`
+ ContentPlanID string `path:"contentPlanId" validate:"required"`
+ }
+
+ UpdateContentPlanReq {
+ Topic *string `json:"topic,optional"`
+ Mission *string `json:"mission,optional"`
+ TargetAudience *string `json:"target_audience,optional"`
+ Angle *string `json:"angle,optional"`
+ OpeningType *string `json:"opening_type,optional"`
+ BodyType *string `json:"body_type,optional"`
+ Emotion *string `json:"emotion,optional"`
+ EndingType *string `json:"ending_type,optional"`
+ CtaType *string `json:"cta_type,optional"`
+ RiskLevel *string `json:"risk_level,optional"`
+ RequiresHumanReview *bool `json:"requires_human_review,optional"`
+ Avoid []string `json:"avoid,optional"`
+ SelectedKnowledge []string `json:"selected_knowledge,optional"`
+ Status *string `json:"status,optional"`
+ }
+
+ UpdateContentPlanHandlerReq {
+ ContentPlanPath
+ UpdateContentPlanReq
+ }
+
+ CreateFeedbackEventReq {
+ ContentPlanID string `json:"content_plan_id,optional"`
+ DraftID string `json:"draft_id,optional"`
+ Decision string `json:"decision" validate:"required"`
+ Note string `json:"note,optional"`
+ Snapshot string `json:"snapshot,optional"`
+ }
+
+ CreateFeedbackEventHandlerReq {
+ PersonaPath
+ CreateFeedbackEventReq
}
UpdateCopyDraftHandlerReq {
@@ -167,8 +388,9 @@ type (
}
PublishCopyDraftReq {
- Text string `json:"text,optional"`
- Confirm bool `json:"confirm"`
+ Text string `json:"text,optional"`
+ TopicTag string `json:"topic_tag,optional"`
+ Confirm bool `json:"confirm"`
}
PublishCopyDraftHandlerReq {
@@ -184,10 +406,216 @@ type (
Message string `json:"message"`
}
+ ScheduleCopyDraftsReq {
+ AccountID string `json:"account_id" validate:"required"`
+ DraftIDs []string `json:"draft_ids" validate:"required,min=1"`
+ StartAt int64 `json:"start_at,optional"`
+ Timezone string `json:"timezone,optional"`
+ Slots []PublishSlotData `json:"slots,optional"`
+ Mode string `json:"mode,optional"`
+ }
+
+ ScheduleCopyDraftsData {
+ Scheduled int `json:"scheduled"`
+ List []PublishQueueItemData `json:"list"`
+ Message string `json:"message"`
+ }
+
+ SchedulePersonaDraftsHandlerReq {
+ PersonaPath
+ ScheduleCopyDraftsReq
+ }
+
+ StylePresetData {
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ Name string `json:"name"`
+ Tone string `json:"tone,omitempty"`
+ CTA []string `json:"cta,omitempty"`
+ BannedWords []string `json:"banned_words,omitempty"`
+ Notes string `json:"notes,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+ }
+
+ ListStylePresetsData {
+ List []StylePresetData `json:"list"`
+ }
+
+ UpsertStylePresetReq {
+ Name string `json:"name" validate:"required"`
+ Tone string `json:"tone,optional"`
+ CTA []string `json:"cta,optional"`
+ BannedWords []string `json:"banned_words,optional"`
+ Notes string `json:"notes,optional"`
+ Apply bool `json:"apply,optional"`
+ }
+
+ StylePresetPath {
+ ID string `path:"id" validate:"required"`
+ PresetID string `path:"presetId" validate:"required"`
+ }
+
+ UpsertStylePresetHandlerReq {
+ StylePresetPath
+ UpsertStylePresetReq
+ }
+
DeleteCopyDraftData {
DraftID string `json:"draft_id"`
Message string `json:"message"`
}
+
+ PrunePersonaCopyDraftsReq {
+ Keep int `json:"keep,optional"`
+ }
+
+ PrunePersonaCopyDraftsHandlerReq {
+ PersonaPath
+ PrunePersonaCopyDraftsReq
+ }
+
+ PrunePersonaCopyDraftsData {
+ Deleted int64 `json:"deleted"`
+ Kept int `json:"kept"`
+ Message string `json:"message"`
+ }
+
+ ListPersonaContentInboxReq {
+ Page int64 `form:"page,optional"`
+ PageSize int64 `form:"pageSize,optional"`
+ Status string `form:"status,optional"`
+ RangeStart int64 `form:"rangeStart,optional"`
+ RangeEnd int64 `form:"rangeEnd,optional"`
+ }
+
+ ListPersonaContentInboxHandlerReq {
+ PersonaPath
+ ListPersonaContentInboxReq
+ }
+
+ ContentInboxItemData {
+ CopyDraftData
+ ScheduledAt int64 `json:"scheduled_at,omitempty"`
+ Lifecycle string `json:"lifecycle"`
+ GroupDate int64 `json:"group_date"`
+ FormulaLabel string `json:"formula_label,omitempty"`
+ }
+
+ ListPersonaContentInboxData {
+ Pagination PaginationData `json:"pagination"`
+ List []ContentInboxItemData `json:"list"`
+ }
+
+ SchedulePersonaCopyDraftReq {
+ AccountID string `json:"account_id" validate:"required"`
+ TopicTag string `json:"topic_tag,optional"`
+ ScheduledAt int64 `json:"scheduled_at,optional"`
+ }
+
+ SchedulePersonaCopyDraftHandlerReq {
+ CopyDraftPath
+ SchedulePersonaCopyDraftReq
+ }
+
+ ContentFormulaPath {
+ ID string `path:"id" validate:"required"`
+ FormulaID string `path:"formulaId" validate:"required"`
+ }
+
+ GenerateFromContentFormulaReq {
+ AccountID string `json:"account_id" validate:"required"`
+ Topic string `json:"topic" validate:"required"`
+ Brief string `json:"brief,optional"`
+ UseWebSearch bool `json:"use_web_search,optional"`
+ DraftCount int `json:"draft_count,optional"`
+ }
+
+ GenerateFromContentFormulaHandlerReq {
+ ContentFormulaPath
+ GenerateFromContentFormulaReq
+ }
+
+ GenerateFromContentFormulaData {
+ List []CopyDraftData `json:"list"`
+ Message string `json:"message"`
+ }
+
+ GeneratePersonaTopicMatrixReq {
+ Topic string `json:"topic" validate:"required"`
+ ContentPlanID string `json:"content_plan_id,optional"`
+ Brief string `json:"brief,optional"`
+ UseWebSearch bool `json:"use_web_search,optional"`
+ DraftCount int `json:"draft_count,optional"`
+ }
+
+ GeneratePersonaTopicMatrixHandlerReq {
+ PersonaPath
+ GeneratePersonaTopicMatrixReq
+ }
+
+ GeneratePersonaTopicMatrixData {
+ List []CopyDraftData `json:"list"`
+ Message string `json:"message"`
+ }
+
+ StartPersonaTopicMatrixJobHandlerReq {
+ PersonaPath
+ GeneratePersonaTopicMatrixReq
+ }
+
+ StartPersonaTopicMatrixJobData {
+ JobID string `json:"job_id"`
+ Status string `json:"status"`
+ AiProvider string `json:"ai_provider"`
+ AiModel string `json:"ai_model"`
+ Message string `json:"message"`
+ }
+
+ StartPersonaFormulaDraftJobReq {
+ AccountID string `json:"account_id" validate:"required"`
+ FormulaID string `json:"formula_id" validate:"required"`
+ Topic string `json:"topic" validate:"required"`
+ Brief string `json:"brief,optional"`
+ UseWebSearch bool `json:"use_web_search,optional"`
+ DraftCount int `json:"draft_count,optional"`
+ }
+
+ StartPersonaFormulaDraftJobHandlerReq {
+ PersonaPath
+ StartPersonaFormulaDraftJobReq
+ }
+
+ StartPersonaFormulaDraftJobData {
+ JobID string `json:"job_id"`
+ Status string `json:"status"`
+ AiProvider string `json:"ai_provider"`
+ AiModel string `json:"ai_model"`
+ Message string `json:"message"`
+ }
+
+ StartPersonaRewriteDraftJobReq {
+ AccountID string `json:"account_id" validate:"required"`
+ ReferenceText string `json:"reference_text" validate:"required"`
+ Topic string `json:"topic" validate:"required"`
+ Brief string `json:"brief,optional"`
+ SaveLabel string `json:"save_label,optional"`
+ UseWebSearch bool `json:"use_web_search,optional"`
+ DraftCount int `json:"draft_count,optional"`
+ }
+
+ StartPersonaRewriteDraftJobHandlerReq {
+ PersonaPath
+ StartPersonaRewriteDraftJobReq
+ }
+
+ StartPersonaRewriteDraftJobData {
+ JobID string `json:"job_id"`
+ Status string `json:"status"`
+ AiProvider string `json:"ai_provider"`
+ AiModel string `json:"ai_model"`
+ Message string `json:"message"`
+ }
)
@server(
@@ -216,6 +644,9 @@ service gateway {
@handler startPersonaStyleAnalysis
post /:id/style-analysis (StartPersonaStyleAnalysisHandlerReq) returns (StartPersonaStyleAnalysisData)
+ @handler startPersonaStyleAnalysisFromText
+ post /:id/style-analysis-from-text (StartPersonaStyleAnalysisFromTextHandlerReq) returns (StartPersonaStyleAnalysisFromTextData)
+
@handler startPersonaViralScanJob
post /:id/viral-scan-jobs (StartPersonaViralScanJobHandlerReq) returns (StartPersonaViralScanJobData)
@@ -225,6 +656,39 @@ service gateway {
@handler listPersonaCopyDrafts
get /:id/copy-drafts (PersonaPath) returns (ListPersonaCopyDraftsData)
+ @handler listPersonaContentInbox
+ get /:id/content-inbox (ListPersonaContentInboxHandlerReq) returns (ListPersonaContentInboxData)
+
+ @handler listTopicCandidates
+ get /:id/topic-candidates (PersonaPath) returns (ListTopicCandidatesData)
+
+ @handler listContentPlans
+ get /:id/content-plans (PersonaPath) returns (ListContentPlansData)
+
+ @handler createContentPlan
+ post /:id/content-plans (CreateContentPlanHandlerReq) returns (ContentPlanData)
+
+ @handler updateContentPlan
+ patch /:id/content-plans/:contentPlanId (UpdateContentPlanHandlerReq) returns (ContentPlanData)
+
+ @handler createFeedbackEvent
+ post /:id/feedback-events (CreateFeedbackEventHandlerReq) returns (FeedbackEventData)
+
+ @handler createKnowledgeSource
+ post /:id/knowledge-sources (CreateKnowledgeSourceHandlerReq) returns (KnowledgeSourceData)
+
+ @handler listKnowledgeSources
+ get /:id/knowledge-sources (PersonaPath) returns (ListKnowledgeSourcesData)
+
+ @handler listKnowledgeChunks
+ get /:id/knowledge-chunks (PersonaPath) returns (ListKnowledgeChunksData)
+
+ @handler listFormulaPools
+ get /:id/formula-pools (PersonaPath) returns (ListFormulaPoolsData)
+
+ @handler createFormulaPool
+ post /:id/formula-pools (CreateFormulaPoolHandlerReq) returns (FormulaPoolData)
+
@handler generatePersonaCopyDraft
post /:id/copy-drafts/generate (GeneratePersonaCopyDraftHandlerReq) returns (GeneratePersonaCopyDraftData)
@@ -234,6 +698,39 @@ service gateway {
@handler publishPersonaCopyDraft
post /:id/copy-drafts/:draftId/publish (PublishCopyDraftHandlerReq) returns (PublishCopyDraftData)
+ @handler schedulePersonaDrafts
+ post /:id/copy-drafts/schedule (SchedulePersonaDraftsHandlerReq) returns (ScheduleCopyDraftsData)
+
+ @handler schedulePersonaCopyDraft
+ post /:id/copy-drafts/:draftId/schedule (SchedulePersonaCopyDraftHandlerReq) returns (ScheduleCopyDraftsData)
+
+ @handler generateFromContentFormula
+ post /:id/content-formulas/:formulaId/generate (GenerateFromContentFormulaHandlerReq) returns (GenerateFromContentFormulaData)
+
+ @handler generatePersonaTopicMatrix
+ post /:id/topic-matrix/generate (GeneratePersonaTopicMatrixHandlerReq) returns (GeneratePersonaTopicMatrixData)
+
+ @handler startPersonaTopicMatrixJob
+ post /:id/topic-matrix/jobs (StartPersonaTopicMatrixJobHandlerReq) returns (StartPersonaTopicMatrixJobData)
+
+ @handler startPersonaFormulaDraftJob
+ post /:id/formula-draft/jobs (StartPersonaFormulaDraftJobHandlerReq) returns (StartPersonaFormulaDraftJobData)
+
+ @handler startPersonaRewriteDraftJob
+ post /:id/rewrite-draft/jobs (StartPersonaRewriteDraftJobHandlerReq) returns (StartPersonaRewriteDraftJobData)
+
@handler deletePersonaCopyDraft
delete /:id/copy-drafts/:draftId (CopyDraftPath) returns (DeleteCopyDraftData)
-}
\ No newline at end of file
+
+ @handler prunePersonaCopyDrafts
+ post /:id/copy-drafts/prune (PrunePersonaCopyDraftsHandlerReq) returns (PrunePersonaCopyDraftsData)
+
+ @handler listStylePresets
+ get /:id/style-presets (PersonaPath) returns (ListStylePresetsData)
+
+ @handler upsertStylePreset
+ put /:id/style-presets/:presetId (UpsertStylePresetHandlerReq) returns (StylePresetData)
+
+ @handler deleteStylePreset
+ delete /:id/style-presets/:presetId (StylePresetPath)
+}
diff --git a/backend/generate/api/placement_topic.api b/backend/generate/api/placement_topic.api
index a6d66a1..a0b12d9 100644
--- a/backend/generate/api/placement_topic.api
+++ b/backend/generate/api/placement_topic.api
@@ -83,6 +83,12 @@ type (
PublishOutreachDraftReq
}
+ UpdatePlacementTopicOutreachDraftHandlerReq {
+ PlacementTopicPath
+ DraftID string `path:"draftId" validate:"required"`
+ UpdateOutreachDraftReq
+ }
+
PatchPlacementTopicScanPostOutreachHandlerReq {
PlacementTopicPath
PostID string `path:"postId"`
@@ -116,6 +122,25 @@ type (
PlacementTopicPath
UpsertBrandScanScheduleReq
}
+
+ StartPlacementTopicOutreachDraftJobReq {
+ ScanPostID string `json:"scan_post_id,optional"`
+ ScanPostIDs []string `json:"scan_post_ids,optional"`
+ Count int `json:"count,optional"`
+ VoicePersonaID string `json:"voice_persona_id,optional"`
+ ProductID string `json:"product_id,optional"`
+ Regenerate bool `json:"regenerate,optional"`
+ }
+
+ StartPlacementTopicOutreachDraftJobHandlerReq {
+ PlacementTopicPath
+ StartPlacementTopicOutreachDraftJobReq
+ }
+
+ StartPlacementTopicOutreachDraftJobsData {
+ Jobs []StartBrandScanJobData `json:"jobs"`
+ Message string `json:"message"`
+ }
)
@server(
@@ -159,9 +184,15 @@ service gateway {
@handler generatePlacementTopicOutreachDrafts
post /:id/outreach-drafts/generate (GeneratePlacementTopicOutreachDraftsHandlerReq) returns (GenerateOutreachDraftsData)
+ @handler startPlacementTopicOutreachDraftJob
+ post /:id/outreach-draft-jobs (StartPlacementTopicOutreachDraftJobHandlerReq) returns (StartPlacementTopicOutreachDraftJobsData)
+
@handler publishPlacementTopicOutreachDraft
post /:id/outreach-drafts/publish (PublishPlacementTopicOutreachDraftHandlerReq) returns (PublishOutreachDraftData)
+ @handler updatePlacementTopicOutreachDraft
+ patch /:id/outreach-drafts/:draftId (UpdatePlacementTopicOutreachDraftHandlerReq) returns (GenerateOutreachDraftsData)
+
@handler patchPlacementTopicScanPostOutreach
patch /:id/scan-posts/:postId (PatchPlacementTopicScanPostOutreachHandlerReq) returns (ScanPostData)
diff --git a/backend/generate/api/threads_account.api b/backend/generate/api/threads_account.api
index 93396a3..da1d081 100644
--- a/backend/generate/api/threads_account.api
+++ b/backend/generate/api/threads_account.api
@@ -8,8 +8,9 @@ type (
ThreadsUserID string `json:"threads_user_id,omitempty"`
PersonaID string `json:"persona_id,omitempty"` // deprecated: persona is chosen per publish, not bound to account
BrowserConnected bool `json:"browser_connected"`
- ApiConnected bool `json:"api_connected"`
- Status string `json:"status"`
+ ApiConnected bool `json:"api_connected"`
+ APITokenExpiresAt int64 `json:"api_token_expires_at,omitempty"`
+ Status string `json:"status"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
@@ -19,6 +20,12 @@ type (
ActiveAccountID string `json:"active_account_id"`
}
+ DeleteThreadsAccountData {
+ DeletedID string `json:"deleted_id"`
+ ActiveAccountID string `json:"active_account_id,omitempty"`
+ Message string `json:"message"`
+ }
+
CreateThreadsAccountReq {
DisplayName string `json:"display_name,optional"`
Activate *bool `json:"activate,optional"`
@@ -137,6 +144,711 @@ type (
Streams bool `json:"streams"`
Error string `json:"error,optional"`
}
+
+ StartThreadsOAuthQuery {
+ AccountID string `form:"account_id,optional"`
+ }
+
+ StartThreadsOAuthData {
+ AuthorizeURL string `json:"authorize_url"`
+ State string `json:"state"`
+ AccountID string `json:"account_id"`
+ }
+
+ ThreadsOAuthCallbackQuery {
+ Code string `form:"code,optional"`
+ State string `form:"state,optional"`
+ Error string `form:"error,optional"`
+ ErrorReason string `form:"error_reason,optional"`
+ ErrorDescription string `form:"error_description,optional"`
+ }
+
+ ThreadsOAuthLogItem {
+ At int64 `json:"at"`
+ Level string `json:"level"`
+ Stage string `json:"stage"`
+ AccountID string `json:"account_id,omitempty"`
+ Message string `json:"message"`
+ }
+
+ ListThreadsOAuthLogsData {
+ List []ThreadsOAuthLogItem `json:"list"`
+ }
+
+ ThreadsOAuthConfigData {
+ RedirectURI string `json:"redirect_uri"`
+ SuccessRedirect string `json:"success_redirect"`
+ Enabled bool `json:"enabled"`
+ RequiresHTTPS bool `json:"requires_https"`
+ }
+
+ ThreadsAPISmokeTestItem {
+ Scope string `json:"scope"`
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Message string `json:"message"`
+ Count int `json:"count"`
+ }
+
+ RunThreadsAPISmokeTestData {
+ List []ThreadsAPISmokeTestItem `json:"list"`
+ Passed int `json:"passed"`
+ Failed int `json:"failed"`
+ Skipped int `json:"skipped"`
+ Total int `json:"total"`
+ }
+
+ RunThreadsAPIPlaygroundReq {
+ Action string `json:"action" validate:"required"`
+ Query string `json:"query,optional"`
+ Username string `json:"username,optional"`
+ MediaID string `json:"media_id,optional"`
+ ReplyID string `json:"reply_id,optional"`
+ ReplyToID string `json:"reply_to_id,optional"`
+ Text string `json:"text,optional"`
+ Permalink string `json:"permalink,optional"`
+ HintText string `json:"hint_text,optional"`
+ SkipPrime bool `json:"skip_prime,optional"`
+ LocationQuery string `json:"location_query,optional"`
+ Limit int `json:"limit,optional"`
+ Hide bool `json:"hide,optional"`
+ SearchType string `json:"search_type,optional"`
+ }
+
+ RunThreadsAPIPlaygroundHandlerReq {
+ ThreadsAccountPath
+ RunThreadsAPIPlaygroundReq
+ }
+
+ ThreadsAPIPlaygroundData {
+ Action string `json:"action"`
+ Ok bool `json:"ok"`
+ Message string `json:"message"`
+ Data string `json:"data,optional"`
+ }
+
+ ListThreadsPostPerformanceQuery {
+ Limit int `form:"limit,optional"`
+ }
+
+ ListThreadsPostPerformanceHandlerReq {
+ ThreadsAccountPath
+ ListThreadsPostPerformanceQuery
+ }
+
+ ThreadsPostMetricCapability {
+ Scope string `json:"scope"`
+ Name string `json:"name"`
+ Metrics []string `json:"metrics"`
+ Trackable bool `json:"trackable"`
+ Note string `json:"note,optional"`
+ }
+
+ ThreadsInsightMetricItem {
+ Name string `json:"name"`
+ Value int `json:"value"`
+ }
+
+ ThreadsAccountInsightsBlock {
+ Status string `json:"status"`
+ Message string `json:"message,optional"`
+ Metrics []ThreadsInsightMetricItem `json:"metrics,optional"`
+ }
+
+ ThreadsPostPerformanceItem {
+ MediaID string `json:"media_id"`
+ Text string `json:"text,optional"`
+ Permalink string `json:"permalink,optional"`
+ Timestamp string `json:"timestamp,optional"`
+ MediaType string `json:"media_type,optional"`
+ MediaURL string `json:"media_url,optional"`
+ ThumbnailURL string `json:"thumbnail_url,optional"`
+ TopicTag string `json:"topic_tag,optional"`
+ Shortcode string `json:"shortcode,optional"`
+ LikeCount int `json:"like_count"`
+ ReplyCount int `json:"reply_count"`
+ RepostCount int `json:"repost_count,optional"`
+ QuoteCount int `json:"quote_count,optional"`
+ Views int `json:"views,optional"`
+ Shares int `json:"shares,optional"`
+ InsightsStatus string `json:"insights_status,optional"`
+ InsightsMessage string `json:"insights_message,optional"`
+ }
+
+ ThreadsPostPerformanceData {
+ AccountID string `json:"account_id"`
+ Username string `json:"username,optional"`
+ ThreadsUserID string `json:"threads_user_id,optional"`
+ FetchedAt int64 `json:"fetched_at"`
+ Capabilities []ThreadsPostMetricCapability `json:"capabilities"`
+ AccountInsights ThreadsAccountInsightsBlock `json:"account_insights"`
+ Posts []ThreadsPostPerformanceItem `json:"posts"`
+ }
+
+ PublishQueueItemPath {
+ ID string `path:"id" validate:"required"`
+ QID string `path:"qid" validate:"required"`
+ }
+
+ ListPublishQueueQuery {
+ Status string `form:"status,optional"`
+ Page int `form:"page,optional"`
+ PageSize int `form:"pageSize,optional"`
+ }
+
+ ListPublishQueueHandlerReq {
+ ThreadsAccountPath
+ ListPublishQueueQuery
+ }
+
+ CreatePublishQueueReq {
+ Text string `json:"text" validate:"required"`
+ TopicTag string `json:"topic_tag,optional"`
+ ScheduledAt int64 `json:"scheduled_at,optional"`
+ }
+
+ CreatePublishQueueHandlerReq {
+ ThreadsAccountPath
+ CreatePublishQueueReq
+ }
+
+ PatchPublishQueueReq {
+ Text *string `json:"text,optional"`
+ TopicTag *string `json:"topic_tag,optional"`
+ ScheduledAt *int64 `json:"scheduled_at,optional"`
+ }
+
+ PatchPublishQueueHandlerReq {
+ PublishQueueItemPath
+ PatchPublishQueueReq
+ }
+
+ PublishQueueItemData {
+ ID string `json:"id"`
+ AccountID string `json:"account_id"`
+ PersonaID string `json:"persona_id,omitempty"`
+ CopyMissionID string `json:"copy_mission_id,omitempty"`
+ CopyDraftID string `json:"copy_draft_id,omitempty"`
+ Text string `json:"text"`
+ TopicTag string `json:"topic_tag,omitempty"`
+ ScheduledAt int64 `json:"scheduled_at"`
+ Status string `json:"status"`
+ MediaID string `json:"media_id,optional"`
+ Permalink string `json:"permalink,optional"`
+ PublishedAt int64 `json:"published_at,optional"`
+ ErrorMessage string `json:"error_message,optional"`
+ RetryCount int `json:"retry_count,omitempty"`
+ LastAttemptAt int64 `json:"last_attempt_at,omitempty"`
+ NextAttemptAt int64 `json:"next_attempt_at,omitempty"`
+ MissedAt int64 `json:"missed_at,omitempty"`
+ PausedReason string `json:"paused_reason,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+ }
+
+ PublishQueueListData {
+ Pagination PaginationData `json:"pagination"`
+ List []PublishQueueItemData `json:"list"`
+ }
+
+ ListThreadsPublishHealthQuery {
+ Page int `form:"page,optional"`
+ PageSize int `form:"pageSize,optional"`
+ }
+
+ ListThreadsPublishHealthHandlerReq {
+ ThreadsAccountPath
+ ListThreadsPublishHealthQuery
+ }
+
+ ThreadsPublishHealthSummary {
+ PendingScheduled int64 `json:"pending_scheduled"`
+ FailedCount int64 `json:"failed_count"`
+ Published7d int64 `json:"published_7d"`
+ TotalLikes7d int `json:"total_likes_7d"`
+ TotalReplies7d int `json:"total_replies_7d"`
+ }
+
+ ThreadsPublishHealthCheckpoint {
+ Checkpoint string `json:"checkpoint"`
+ LikeCount int `json:"like_count"`
+ ReplyCount int `json:"reply_count"`
+ RepostCount int `json:"repost_count"`
+ QuoteCount int `json:"quote_count"`
+ Views int `json:"views,optional"`
+ CheckedAt int64 `json:"checked_at"`
+ }
+
+ ThreadsPublishHealthItem {
+ MediaID string `json:"media_id"`
+ Permalink string `json:"permalink,optional"`
+ Text string `json:"text,optional"`
+ PublishedAt int64 `json:"published_at"`
+ LikeCount int `json:"like_count"`
+ ReplyCount int `json:"reply_count"`
+ RepostCount int `json:"repost_count,optional"`
+ QuoteCount int `json:"quote_count,optional"`
+ Views int `json:"views,optional"`
+ Shares int `json:"shares,optional"`
+ Checkpoints []ThreadsPublishHealthCheckpoint `json:"checkpoints"`
+ }
+
+ ThreadsPublishHealthData {
+ Summary ThreadsPublishHealthSummary `json:"summary"`
+ Pagination PaginationData `json:"pagination"`
+ List []ThreadsPublishHealthItem `json:"list"`
+ }
+
+ PublishSlotData {
+ Weekday int `json:"weekday"`
+ Time string `json:"time"`
+ }
+
+ PublishSourceRefData {
+ Type string `json:"type"`
+ PersonaID string `json:"persona_id,omitempty"`
+ CopyMissionID string `json:"copy_mission_id,omitempty"`
+ ScanPostID string `json:"scan_post_id,omitempty"`
+ ManualSeed string `json:"manual_seed,omitempty"`
+ }
+
+ PublishInventoryPolicyData {
+ AccountID string `json:"account_id"`
+ Enabled bool `json:"enabled"`
+ TargetDailyCount int `json:"target_daily_count"`
+ LowStockThreshold int `json:"low_stock_threshold"`
+ LookaheadDays int `json:"lookahead_days"`
+ Timezone string `json:"timezone"`
+ Slots []PublishSlotData `json:"slots"`
+ SourceRefs []PublishSourceRefData `json:"source_refs"`
+ UpdateAt int64 `json:"update_at"`
+ }
+
+ UpsertPublishInventoryPolicyReq {
+ Enabled bool `json:"enabled"`
+ TargetDailyCount int `json:"target_daily_count,optional"`
+ LowStockThreshold int `json:"low_stock_threshold,optional"`
+ LookaheadDays int `json:"lookahead_days,optional"`
+ Timezone string `json:"timezone,optional"`
+ Slots []PublishSlotData `json:"slots,optional"`
+ SourceRefs []PublishSourceRefData `json:"source_refs,optional"`
+ }
+
+ UpsertPublishInventoryPolicyHandlerReq {
+ ThreadsAccountPath
+ UpsertPublishInventoryPolicyReq
+ }
+
+ StartPublishInventoryRefillJobReq {
+ Count int `json:"count,optional"`
+ }
+
+ StartPublishInventoryRefillJobHandlerReq {
+ ThreadsAccountPath
+ StartPublishInventoryRefillJobReq
+ }
+
+ StartPublishInventoryRefillJobData {
+ JobID string `json:"job_id"`
+ Status string `json:"status"`
+ Message string `json:"message"`
+ }
+
+ PublishSlotInsightData {
+ Weekday int `json:"weekday"`
+ Time string `json:"time"`
+ AvgLikes1h int `json:"avg_likes_1h"`
+ AvgReplies1h int `json:"avg_replies_1h"`
+ AvgLikes24h int `json:"avg_likes_24h"`
+ AvgReplies24h int `json:"avg_replies_24h"`
+ AvgLikes7d int `json:"avg_likes_7d"`
+ AvgReplies7d int `json:"avg_replies_7d"`
+ SampleSize int `json:"sample_size"`
+ Recommendation string `json:"recommendation"`
+ }
+
+ PublishSlotInsightsData {
+ AccountID string `json:"account_id"`
+ Timezone string `json:"timezone"`
+ Slots []PublishSlotInsightData `json:"slots"`
+ }
+
+ PublishGuardPolicyData {
+ AccountID string `json:"account_id"`
+ Enabled bool `json:"enabled"`
+ MaxDailyPosts int `json:"max_daily_posts"`
+ MinIntervalMinutes int `json:"min_interval_minutes"`
+ ConsecutiveFailurePauseLimit int `json:"consecutive_failure_pause_limit"`
+ Paused bool `json:"paused"`
+ PausedReason string `json:"paused_reason,omitempty"`
+ UpdateAt int64 `json:"update_at"`
+ }
+
+ UpsertPublishGuardPolicyReq {
+ Enabled bool `json:"enabled"`
+ MaxDailyPosts int `json:"max_daily_posts,optional"`
+ MinIntervalMinutes int `json:"min_interval_minutes,optional"`
+ ConsecutiveFailurePauseLimit int `json:"consecutive_failure_pause_limit,optional"`
+ Paused *bool `json:"paused,optional"`
+ PausedReason string `json:"paused_reason,optional"`
+ }
+
+ UpsertPublishGuardPolicyHandlerReq {
+ ThreadsAccountPath
+ UpsertPublishGuardPolicyReq
+ }
+
+ PublishQueueEventData {
+ ID string `json:"id"`
+ QueueID string `json:"queue_id"`
+ EventType string `json:"event_type"`
+ FromStatus string `json:"from_status,omitempty"`
+ ToStatus string `json:"to_status,omitempty"`
+ Message string `json:"message,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ }
+
+ PublishQueueEventsData {
+ List []PublishQueueEventData `json:"list"`
+ }
+
+ PublishAlertData {
+ Type string `json:"type"`
+ Severity string `json:"severity"`
+ Message string `json:"message"`
+ QueueID string `json:"queue_id,omitempty"`
+ AccountID string `json:"account_id"`
+ CreateAt int64 `json:"create_at"`
+ }
+
+ PublishAlertsData {
+ List []PublishAlertData `json:"list"`
+ }
+
+ PublishQueueRangeQuery {
+ StartAt int64 `form:"startAt,optional"`
+ EndAt int64 `form:"endAt,optional"`
+ Status string `form:"status,optional"`
+ }
+
+ PublishQueueRangeHandlerReq {
+ ThreadsAccountPath
+ PublishQueueRangeQuery
+ }
+
+ PublishDashboardAccountSummary {
+ AccountID string `json:"account_id"`
+ AccountName string `json:"account_name"`
+ PendingScheduled int64 `json:"pending_scheduled"`
+ FailedCount int64 `json:"failed_count"`
+ Published7d int64 `json:"published_7d"`
+ TotalLikes7d int `json:"total_likes_7d"`
+ TotalReplies7d int `json:"total_replies_7d"`
+ Paused bool `json:"paused"`
+ BestSlot string `json:"best_slot,omitempty"`
+ LowSlot string `json:"low_slot,omitempty"`
+ }
+
+ PublishDashboardSummaryData {
+ List []PublishDashboardAccountSummary `json:"list"`
+ TotalPending int64 `json:"total_pending"`
+ TotalFailed int64 `json:"total_failed"`
+ TotalPublished7d int64 `json:"total_published_7d"`
+ TotalLikes7d int `json:"total_likes_7d"`
+ TotalReplies7d int `json:"total_replies_7d"`
+ }
+
+ GenerateOwnPostReplyDraftReq {
+ MediaID string `json:"media_id"`
+ PersonaID string `json:"persona_id,optional"`
+ ReplyToID string `json:"reply_to_id,optional"`
+ PostText string `json:"post_text,optional"`
+ ReplyText string `json:"reply_text,optional"`
+ ThreadPostText string `json:"thread_post_text,optional"`
+ ParentReplyText string `json:"parent_reply_text,optional"`
+ }
+
+ GenerateOwnPostReplyDraftHandlerReq {
+ ThreadsAccountPath
+ GenerateOwnPostReplyDraftReq
+ }
+
+ GenerateOwnPostReplyDraftData {
+ Text string `json:"text"`
+ }
+
+ MentionInboxItemData {
+ MediaID string `json:"media_id"`
+ AuthorUsername string `json:"author_username,omitempty"`
+ Text string `json:"text,omitempty"`
+ Permalink string `json:"permalink,omitempty"`
+ Shortcode string `json:"shortcode,omitempty"`
+ Timestamp string `json:"timestamp,omitempty"`
+ MediaType string `json:"media_type,omitempty"`
+ IsReply bool `json:"is_reply"`
+ IsQuotePost bool `json:"is_quote_post"`
+ HasReplies bool `json:"has_replies"`
+ RootPostID string `json:"root_post_id,omitempty"`
+ ParentID string `json:"parent_id,omitempty"`
+ ThreadPostText string `json:"thread_post_text,omitempty"`
+ ParentReplyText string `json:"parent_reply_text,omitempty"`
+ ReplyReady bool `json:"reply_ready"`
+ ReplyReadyMsg string `json:"reply_ready_msg,omitempty"`
+ SyncedAt int64 `json:"synced_at"`
+ }
+
+ SyncMentionInboxReq {
+ Limit int `json:"limit,optional"`
+ MaxPages int `json:"max_pages,optional"`
+ }
+
+ SyncMentionInboxHandlerReq {
+ ThreadsAccountPath
+ SyncMentionInboxReq
+ }
+
+ SyncMentionInboxData {
+ Synced int `json:"synced"`
+ Ready int `json:"ready"`
+ Total int64 `json:"total"`
+ }
+
+ ListMentionInboxQuery {
+ Page int `form:"page,optional"`
+ PageSize int `form:"pageSize,optional"`
+ }
+
+ ListMentionInboxHandlerReq {
+ ThreadsAccountPath
+ ListMentionInboxQuery
+ }
+
+ ListMentionInboxData {
+ List []MentionInboxItemData `json:"list"`
+ Pagination PaginationData `json:"pagination"`
+ ReadyTotal int64 `json:"ready_total"`
+ NewestSyncedAt int64 `json:"newest_synced_at,omitempty"`
+ }
+
+ PublishMentionReplyReq {
+ Text string `json:"text"`
+ }
+
+ PublishMentionReplyPath {
+ ID string `path:"id" validate:"required"`
+ MediaID string `path:"media_id" validate:"required"`
+ }
+
+ PublishMentionReplyHandlerReq {
+ PublishMentionReplyPath
+ PublishMentionReplyReq
+ }
+
+ PublishMentionReplyData {
+ MediaID string `json:"media_id,omitempty"`
+ Permalink string `json:"permalink,omitempty"`
+ }
+
+ GenerateOwnPostFormulaReq {
+ MediaID string `json:"media_id"`
+ PersonaID string `json:"persona_id,optional"`
+ PostText string `json:"post_text,optional"`
+ TopicTag string `json:"topic_tag,optional"`
+ MediaType string `json:"media_type,optional"`
+ Timestamp string `json:"timestamp,optional"`
+ LikeCount int `json:"like_count,optional"`
+ ReplyCount int `json:"reply_count,optional"`
+ Views int `json:"views,optional"`
+ RepostCount int `json:"repost_count,optional"`
+ QuoteCount int `json:"quote_count,optional"`
+ Shares int `json:"shares,optional"`
+ Force bool `json:"force,optional"`
+ }
+
+ GenerateOwnPostFormulaHandlerReq {
+ ThreadsAccountPath
+ GenerateOwnPostFormulaReq
+ }
+
+ OwnPostFormulaReviewData {
+ MediaID string `json:"media_id"`
+ ReviewedAt int64 `json:"reviewed_at"`
+ FromCache bool `json:"from_cache,omitempty"`
+ Summary string `json:"summary"`
+ Wins []string `json:"wins"`
+ Improvements []string `json:"improvements"`
+ Formula string `json:"formula"`
+ PostTemplate string `json:"post_template"`
+ HookPattern string `json:"hook_pattern"`
+ Structure string `json:"structure"`
+ ReplicationTips []string `json:"replication_tips"`
+ Avoid []string `json:"avoid"`
+ }
+
+ GenerateOwnPostFormulaData {
+ OwnPostFormulaReviewData
+ }
+
+ ListOwnPostFormulasHandlerReq {
+ ThreadsAccountPath
+ }
+
+ ListOwnPostFormulasData {
+ List []OwnPostFormulaReviewData `json:"list"`
+ }
+
+ ThreadsDiagnosticsData {
+ AccountID string `json:"account_id"`
+ CheckedAt int64 `json:"checked_at"`
+ ApiConnected bool `json:"api_connected"`
+ TokenExpiresAt int64 `json:"token_expires_at,omitempty"`
+ Scopes []string `json:"scopes"`
+ Items []ThreadsAPISmokeTestItem `json:"items"`
+ Message string `json:"message"`
+ }
+
+ ContentFormulaSourceMetricsData {
+ LikeCount int `json:"like_count,omitempty"`
+ ReplyCount int `json:"reply_count,omitempty"`
+ Views int `json:"views,omitempty"`
+ RepostCount int `json:"repost_count,omitempty"`
+ QuoteCount int `json:"quote_count,omitempty"`
+ Shares int `json:"shares,omitempty"`
+ }
+
+ ContentFormulaData {
+ ID string `json:"id"`
+ AccountID string `json:"account_id"`
+ Label string `json:"label"`
+ SourceType string `json:"source_type"`
+ SourceRef string `json:"source_ref,omitempty"`
+ SourcePostText string `json:"source_post_text,omitempty"`
+ SourceAuthor string `json:"source_author,omitempty"`
+ SourcePermalink string `json:"source_permalink,omitempty"`
+ SourceMetrics *ContentFormulaSourceMetricsData `json:"source_metrics,omitempty"`
+ Summary string `json:"summary"`
+ Wins []string `json:"wins,omitempty"`
+ Improvements []string `json:"improvements,omitempty"`
+ Formula string `json:"formula"`
+ PostTemplate string `json:"post_template"`
+ HookPattern string `json:"hook_pattern"`
+ Structure string `json:"structure"`
+ ReplicationTips []string `json:"replication_tips,omitempty"`
+ Avoid []string `json:"avoid,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+ }
+
+ ListContentFormulasReq {
+ Page int64 `form:"page,optional"`
+ PageSize int64 `form:"pageSize,optional"`
+ SourceType string `form:"source_type,optional"`
+ Tag string `form:"tag,optional"`
+ }
+
+ ListContentFormulasHandlerReq {
+ ThreadsAccountPath
+ ListContentFormulasReq
+ }
+
+ ListContentFormulasData {
+ Pagination PaginationData `json:"pagination"`
+ List []ContentFormulaData `json:"list"`
+ }
+
+ ContentFormulaItemPath {
+ ID string `path:"id" validate:"required"`
+ FormulaID string `path:"formulaId" validate:"required"`
+ }
+
+ GetContentFormulaHandlerReq {
+ ContentFormulaItemPath
+ }
+
+ AnalyzeContentFormulaReq {
+ SourceType string `json:"source_type" validate:"required"`
+ PersonaID string `json:"persona_id,optional"`
+ PostText string `json:"post_text,optional"`
+ MediaID string `json:"media_id,optional"`
+ ScanPostID string `json:"scan_post_id,optional"`
+ Keyword string `json:"keyword,optional"`
+ SaveLabel string `json:"save_label,optional"`
+ AuthorName string `json:"author_name,optional"`
+ Permalink string `json:"permalink,optional"`
+ LikeCount int `json:"like_count,optional"`
+ ReplyCount int `json:"reply_count,optional"`
+ Views int `json:"views,optional"`
+ RepostCount int `json:"repost_count,optional"`
+ QuoteCount int `json:"quote_count,optional"`
+ Shares int `json:"shares,optional"`
+ ForceRefresh bool `json:"force_refresh,optional"`
+ }
+
+ AnalyzeContentFormulaHandlerReq {
+ ThreadsAccountPath
+ AnalyzeContentFormulaReq
+ }
+
+ AnalyzeContentFormulaData {
+ Formula ContentFormulaData `json:"formula"`
+ Message string `json:"message"`
+ }
+
+ SearchContentFormulaPostsReq {
+ Keyword string `json:"keyword" validate:"required"`
+ SearchType string `json:"search_type,optional"`
+ Limit int `json:"limit,optional"`
+ }
+
+ SearchContentFormulaPostsHandlerReq {
+ ThreadsAccountPath
+ SearchContentFormulaPostsReq
+ }
+
+ ContentFormulaSearchPostData {
+ Text string `json:"text"`
+ Author string `json:"author"`
+ Permalink string `json:"permalink,omitempty"`
+ MediaID string `json:"media_id,omitempty"`
+ LikeCount int `json:"like_count"`
+ ReplyCount int `json:"reply_count"`
+ EngagementScore int `json:"engagement_score"`
+ }
+
+ SearchContentFormulaPostsData {
+ List []ContentFormulaSearchPostData `json:"list"`
+ Message string `json:"message"`
+ }
+
+ PatchContentFormulaReq {
+ Label *string `json:"label,optional"`
+ Formula *string `json:"formula,optional"`
+ PostTemplate *string `json:"post_template,optional"`
+ HookPattern *string `json:"hook_pattern,optional"`
+ Structure *string `json:"structure,optional"`
+ Tags []string `json:"tags,optional"`
+ }
+
+ PatchContentFormulaHandlerReq {
+ ContentFormulaItemPath
+ PatchContentFormulaReq
+ }
+
+ DeleteContentFormulaData {
+ FormulaID string `json:"formula_id"`
+ Message string `json:"message"`
+ }
+
+ ImportOwnPostFormulaReq {
+ MediaID string `json:"media_id" validate:"required"`
+ Label string `json:"label,optional"`
+ }
+
+ ImportOwnPostFormulaHandlerReq {
+ ThreadsAccountPath
+ ImportOwnPostFormulaReq
+ }
)
@server(
@@ -159,6 +871,9 @@ service gateway {
@handler updateThreadsAccount
patch /:id (UpdateThreadsAccountHandlerReq) returns (ThreadsAccountData)
+ @handler deleteThreadsAccount
+ delete /:id (ThreadsAccountPath) returns (DeleteThreadsAccountData)
+
@handler activateThreadsAccount
post /:id/activate (ThreadsAccountPath)
@@ -179,4 +894,135 @@ service gateway {
@handler listThreadsAccountAiProviderModels
post /:id/ai-settings/providers/:provider/models (ThreadsAccountAiProviderModelsHandlerReq) returns (ThreadsAccountAiProviderModelsData)
-}
\ No newline at end of file
+
+ @handler startThreadsOauth
+ get /oauth/start (StartThreadsOAuthQuery) returns (StartThreadsOAuthData)
+
+ @handler listThreadsOauthLogs
+ get /oauth/logs returns (ListThreadsOAuthLogsData)
+
+ @handler getThreadsOauthConfig
+ get /oauth/config returns (ThreadsOAuthConfigData)
+
+ @handler disconnectThreadsApi
+ post /:id/api/disconnect (ThreadsAccountPath) returns (ThreadsAccountData)
+
+ @handler runThreadsApiSmokeTest
+ post /:id/api/smoke-test (ThreadsAccountPath) returns (RunThreadsAPISmokeTestData)
+
+ @handler runThreadsApiPlayground
+ post /:id/api/playground (RunThreadsAPIPlaygroundHandlerReq) returns (ThreadsAPIPlaygroundData)
+
+ @handler listThreadsPostPerformance
+ get /:id/post-performance (ListThreadsPostPerformanceHandlerReq) returns (ThreadsPostPerformanceData)
+
+ @handler createPublishQueueItem
+ post /:id/publish-queue (CreatePublishQueueHandlerReq) returns (PublishQueueItemData)
+
+ @handler listPublishQueueItems
+ get /:id/publish-queue (ListPublishQueueHandlerReq) returns (PublishQueueListData)
+
+ @handler getPublishQueueItem
+ get /:id/publish-queue/:qid (PublishQueueItemPath) returns (PublishQueueItemData)
+
+ @handler patchPublishQueueItem
+ patch /:id/publish-queue/:qid (PatchPublishQueueHandlerReq) returns (PublishQueueItemData)
+
+ @handler cancelPublishQueueItem
+ post /:id/publish-queue/:qid/cancel (PublishQueueItemPath) returns (PublishQueueItemData)
+
+ @handler deletePublishQueueItem
+ delete /:id/publish-queue/:qid (PublishQueueItemPath)
+
+ @handler listThreadsPublishHealth
+ get /:id/publish-health (ListThreadsPublishHealthHandlerReq) returns (ThreadsPublishHealthData)
+
+ @handler getPublishInventoryPolicy
+ get /:id/publish-inventory-policy (ThreadsAccountPath) returns (PublishInventoryPolicyData)
+
+ @handler upsertPublishInventoryPolicy
+ put /:id/publish-inventory-policy (UpsertPublishInventoryPolicyHandlerReq) returns (PublishInventoryPolicyData)
+
+ @handler startPublishInventoryRefillJob
+ post /:id/publish-inventory/refill-jobs (StartPublishInventoryRefillJobHandlerReq) returns (StartPublishInventoryRefillJobData)
+
+ @handler getPublishSlotInsights
+ get /:id/publish-slot-insights (ThreadsAccountPath) returns (PublishSlotInsightsData)
+
+ @handler getPublishGuardPolicy
+ get /:id/publish-guard-policy (ThreadsAccountPath) returns (PublishGuardPolicyData)
+
+ @handler upsertPublishGuardPolicy
+ put /:id/publish-guard-policy (UpsertPublishGuardPolicyHandlerReq) returns (PublishGuardPolicyData)
+
+ @handler resumePublishGuard
+ post /:id/publish-guard/resume (ThreadsAccountPath) returns (PublishGuardPolicyData)
+
+ @handler retryPublishQueueItem
+ post /:id/publish-queue/:qid/retry (PublishQueueItemPath) returns (PublishQueueItemData)
+
+ @handler listPublishQueueEvents
+ get /:id/publish-queue/:qid/events (PublishQueueItemPath) returns (PublishQueueEventsData)
+
+ @handler listPublishAlerts
+ get /:id/publish-alerts (ThreadsAccountPath) returns (PublishAlertsData)
+
+ @handler listPublishQueueRange
+ get /:id/publish-calendar (PublishQueueRangeHandlerReq) returns (PublishQueueListData)
+
+ @handler getThreadsDiagnostics
+ get /:id/diagnostics (ThreadsAccountPath) returns (ThreadsDiagnosticsData)
+
+ @handler generateOwnPostReplyDraft
+ post /:id/own-post-reply-draft (GenerateOwnPostReplyDraftHandlerReq) returns (GenerateOwnPostReplyDraftData)
+
+ @handler syncMentionInbox
+ post /:id/mention-inbox/sync (SyncMentionInboxHandlerReq) returns (SyncMentionInboxData)
+
+ @handler listMentionInbox
+ get /:id/mention-inbox (ListMentionInboxHandlerReq) returns (ListMentionInboxData)
+
+ @handler publishMentionReply
+ post /:id/mention-inbox/:media_id/reply (PublishMentionReplyHandlerReq) returns (PublishMentionReplyData)
+
+ @handler generateOwnPostFormula
+ post /:id/own-post-formula (GenerateOwnPostFormulaHandlerReq) returns (GenerateOwnPostFormulaData)
+
+ @handler listOwnPostFormulas
+ get /:id/own-post-formulas (ListOwnPostFormulasHandlerReq) returns (ListOwnPostFormulasData)
+
+ @handler getPublishDashboardSummary
+ get /publish-dashboard-summary returns (PublishDashboardSummaryData)
+
+ @handler listContentFormulas
+ get /:id/content-formulas (ListContentFormulasHandlerReq) returns (ListContentFormulasData)
+
+ @handler getContentFormula
+ get /:id/content-formulas/:formulaId (GetContentFormulaHandlerReq) returns (ContentFormulaData)
+
+ @handler analyzeContentFormula
+ post /:id/content-formulas/analyze (AnalyzeContentFormulaHandlerReq) returns (AnalyzeContentFormulaData)
+
+ @handler searchContentFormulaPosts
+ post /:id/content-formulas/search-posts (SearchContentFormulaPostsHandlerReq) returns (SearchContentFormulaPostsData)
+
+ @handler patchContentFormula
+ patch /:id/content-formulas/:formulaId (PatchContentFormulaHandlerReq) returns (ContentFormulaData)
+
+ @handler deleteContentFormula
+ delete /:id/content-formulas/:formulaId (GetContentFormulaHandlerReq) returns (DeleteContentFormulaData)
+
+ @handler importOwnPostToContentFormula
+ post /:id/content-formulas/import-own-post (ImportOwnPostFormulaHandlerReq) returns (AnalyzeContentFormulaData)
+}
+
+@server(
+ group: threads_account
+ prefix: /api/v1/threads-accounts
+ tags: "ThreadsAccount"
+ summary: "Threads OAuth callback (public redirect from Meta)."
+)
+service gateway {
+ @handler threadsOauthCallback
+ get /oauth/callback (ThreadsOAuthCallbackQuery)
+}
diff --git a/backend/go.mod b/backend/go.mod
index 288e93a..4bae144 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -4,6 +4,7 @@ go 1.23
require (
github.com/go-playground/validator/v10 v10.27.0
+ github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.6.0
github.com/redis/go-redis/v9 v9.14.0
@@ -27,7 +28,6 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
- github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/grafana/pyroscope-go v1.2.7 // indirect
diff --git a/backend/go.sum b/backend/go.sum
index 0f9b29a..6b31483 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -100,6 +100,8 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
+github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
+github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
diff --git a/backend/internal/bootstrap/admin.go b/backend/internal/bootstrap/admin.go
index a820eae..2ab36d3 100644
--- a/backend/internal/bootstrap/admin.go
+++ b/backend/internal/bootstrap/admin.go
@@ -36,6 +36,18 @@ func EnsureAdminMember(ctx context.Context, repo domrepo.Repository, opts AdminO
return nil, false, err
}
existing.Roles = []string{"admin"}
+
+ if err := bcrypt.CompareHashAndPassword([]byte(existing.PasswordHash), []byte(opts.Password)); err != nil {
+ hash, hashErr := bcrypt.GenerateFromPassword([]byte(opts.Password), bcrypt.DefaultCost)
+ if hashErr != nil {
+ return nil, false, app.For(code.Member).SysInternal("rehash password failed").WithCause(hashErr)
+ }
+ if updateErr := repo.UpdatePassword(ctx, tenantID, existing.UID, string(hash)); updateErr != nil {
+ return nil, false, updateErr
+ }
+ existing.PasswordHash = string(hash)
+ }
+
return existing, false, nil
}
if e := app.FromError(err); e == nil || e.Category() != code.ResNotFound {
diff --git a/backend/internal/bootstrap/admin_test.go b/backend/internal/bootstrap/admin_test.go
index b36df77..06b0f20 100644
--- a/backend/internal/bootstrap/admin_test.go
+++ b/backend/internal/bootstrap/admin_test.go
@@ -68,6 +68,16 @@ func (m *memoryMemberRepo) SetActiveThreadsAccountID(_ context.Context, tenantID
return app.For(code.Member).ResNotFound("member not found")
}
+func (m *memoryMemberRepo) UpdatePassword(_ context.Context, tenantID, uid, passwordHash string) error {
+ for _, item := range m.byEmail {
+ if item.TenantID == tenantID && item.UID == uid {
+ item.PasswordHash = passwordHash
+ return nil
+ }
+ }
+ return app.For(code.Member).ResNotFound("member not found")
+}
+
func (m *memoryMemberRepo) SetRoles(_ context.Context, tenantID, uid string, roles []string) error {
for _, item := range m.byEmail {
if item.TenantID == tenantID && item.UID == uid {
@@ -123,4 +133,72 @@ func TestEnsureAdminMemberUpgradesExisting(t *testing.T) {
if len(member.Roles) != 1 || member.Roles[0] != "admin" {
t.Fatalf("roles=%v", member.Roles)
}
+ if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte("Admin-Pass-1!")); err != nil {
+ t.Fatalf("password should have been synced: %v", err)
+ }
+}
+
+func TestEnsureAdminMemberSyncsPasswordWhenChanged(t *testing.T) {
+ oldHash, err := bcrypt.GenerateFromPassword([]byte("Old-Pass-1!"), bcrypt.DefaultCost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ repo := &memoryMemberRepo{byEmail: map[string]*entity.Member{
+ memberKey("default", "admin@haixun.local"): {
+ TenantID: "default",
+ UID: "uid-1",
+ Email: "admin@haixun.local",
+ Roles: []string{"admin"},
+ PasswordHash: string(oldHash),
+ },
+ }}
+ _, created, err := EnsureAdminMember(context.Background(), repo, AdminOptions{
+ TenantID: "default",
+ Email: "admin@haixun.local",
+ Password: "New-Pass-2@",
+ })
+ if err != nil {
+ t.Fatalf("EnsureAdminMember: %v", err)
+ }
+ if created {
+ t.Fatal("expected created=false")
+ }
+ member := repo.byEmail[memberKey("default", "admin@haixun.local")]
+ if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte("Old-Pass-1!")); err == nil {
+ t.Fatal("password should have been updated, but old hash still matches")
+ }
+ if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte("New-Pass-2@")); err != nil {
+ t.Fatalf("new password hash does not match: %v", err)
+ }
+}
+
+func TestEnsureAdminMemberKeepsPasswordWhenSame(t *testing.T) {
+ hash, err := bcrypt.GenerateFromPassword([]byte("Same-Pass-1!"), bcrypt.DefaultCost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ repo := &memoryMemberRepo{byEmail: map[string]*entity.Member{
+ memberKey("default", "admin@haixun.local"): {
+ TenantID: "default",
+ UID: "uid-1",
+ Email: "admin@haixun.local",
+ Roles: []string{"admin"},
+ PasswordHash: string(hash),
+ },
+ }}
+ _, created, err := EnsureAdminMember(context.Background(), repo, AdminOptions{
+ TenantID: "default",
+ Email: "admin@haixun.local",
+ Password: "Same-Pass-1!",
+ })
+ if err != nil {
+ t.Fatalf("EnsureAdminMember: %v", err)
+ }
+ if created {
+ t.Fatal("expected created=false")
+ }
+ member := repo.byEmail[memberKey("default", "admin@haixun.local")]
+ if member.PasswordHash != string(hash) {
+ t.Fatal("password hash should not change when password is the same")
+ }
}
diff --git a/backend/internal/bootstrap/envfile.go b/backend/internal/bootstrap/envfile.go
new file mode 100644
index 0000000..f4f73c6
--- /dev/null
+++ b/backend/internal/bootstrap/envfile.go
@@ -0,0 +1,67 @@
+package bootstrap
+
+import (
+ "bufio"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// LoadInfraEnv reads local env files into process env (only keys not already set).
+// Search order: HAIXUN_ENV_FILE, deploy/.env.dev (for native dev run).
+func LoadInfraEnv() {
+ if path := strings.TrimSpace(os.Getenv("HAIXUN_ENV_FILE")); path != "" {
+ _ = loadEnvFile(path)
+ return
+ }
+ cwd, err := os.Getwd()
+ if err != nil {
+ return
+ }
+ candidates := []string{
+ filepath.Join(cwd, "deploy", ".env.dev"),
+ filepath.Join(cwd, "..", "deploy", ".env.dev"),
+ filepath.Join(cwd, "..", "..", "deploy", ".env.dev"),
+ }
+ for _, path := range candidates {
+ if loadEnvFile(path) {
+ return
+ }
+ }
+}
+
+func loadEnvFile(path string) bool {
+ f, err := os.Open(path)
+ if err != nil {
+ return false
+ }
+ defer f.Close()
+
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ key, value, ok := strings.Cut(line, "=")
+ if !ok {
+ continue
+ }
+ key = strings.TrimSpace(key)
+ if key == "" {
+ continue
+ }
+ if _, exists := os.LookupEnv(key); exists {
+ continue
+ }
+ value = strings.TrimSpace(value)
+ if len(value) >= 2 {
+ if (value[0] == '"' && value[len(value)-1] == '"') ||
+ (value[0] == '\'' && value[len(value)-1] == '\'') {
+ value = value[1 : len(value)-1]
+ }
+ }
+ _ = os.Setenv(key, value)
+ }
+ return true
+}
diff --git a/backend/internal/bootstrap/envfile_test.go b/backend/internal/bootstrap/envfile_test.go
new file mode 100644
index 0000000..0f6ab1a
--- /dev/null
+++ b/backend/internal/bootstrap/envfile_test.go
@@ -0,0 +1,36 @@
+package bootstrap
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLoadEnvFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, ".env")
+ content := `# comment
+HAIXUN_JWT_ACCESS_SECRET=from-file
+THREADS_APP_ID="1566509781118049"
+`
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("HAIXUN_JWT_REFRESH_SECRET", "preset")
+ unset := []string{"HAIXUN_JWT_ACCESS_SECRET", "THREADS_APP_ID"}
+ for _, key := range unset {
+ _ = os.Unsetenv(key)
+ }
+ if !loadEnvFile(path) {
+ t.Fatal("loadEnvFile returned false")
+ }
+ if got := os.Getenv("HAIXUN_JWT_ACCESS_SECRET"); got != "from-file" {
+ t.Fatalf("access secret = %q", got)
+ }
+ if got := os.Getenv("HAIXUN_JWT_REFRESH_SECRET"); got != "preset" {
+ t.Fatalf("refresh secret should stay preset, got %q", got)
+ }
+ if got := os.Getenv("THREADS_APP_ID"); got != "1566509781118049" {
+ t.Fatalf("threads app id = %q", got)
+ }
+}
diff --git a/backend/internal/bootstrap/init.go b/backend/internal/bootstrap/init.go
index 5b00b39..baf77c2 100644
--- a/backend/internal/bootstrap/init.go
+++ b/backend/internal/bootstrap/init.go
@@ -8,19 +8,31 @@ import (
libcrypto "haixun-backend/internal/library/crypto"
libmongo "haixun-backend/internal/library/mongo"
brandrepo "haixun-backend/internal/model/brand/repository"
+ contentformularepo "haixun-backend/internal/model/content_formula/repository"
cmatrixrepo "haixun-backend/internal/model/content_matrix/repository"
+ contentopsrepo "haixun-backend/internal/model/content_ops/repository"
copydraftrepo "haixun-backend/internal/model/copy_draft/repository"
copymissionrepo "haixun-backend/internal/model/copy_mission/repository"
+ crmcontactrepo "haixun-backend/internal/model/crm_contact/repository"
jobrepo "haixun-backend/internal/model/job/repository"
+ jobusecase "haixun-backend/internal/model/job/usecase"
kgrepo "haixun-backend/internal/model/knowledge_graph/repository"
memberrepo "haixun-backend/internal/model/member/repository"
+ mentioninboxrepo "haixun-backend/internal/model/mention_inbox/repository"
outreachdraftrepo "haixun-backend/internal/model/outreach_draft/repository"
+ ownpostformularepo "haixun-backend/internal/model/own_post_formula/repository"
permissionrepo "haixun-backend/internal/model/permission/repository"
permissionuc "haixun-backend/internal/model/permission/usecase"
personarepo "haixun-backend/internal/model/persona/repository"
placementtopicrepo "haixun-backend/internal/model/placement_topic/repository"
+ publishanalyticsrepo "haixun-backend/internal/model/publish_analytics/repository"
+ publishguardrepo "haixun-backend/internal/model/publish_guard/repository"
+ publishinventoryrepo "haixun-backend/internal/model/publish_inventory/repository"
+ publishqueuerepo "haixun-backend/internal/model/publish_queue/repository"
+ publishqueueeventrepo "haixun-backend/internal/model/publish_queue_event/repository"
scanpostrepo "haixun-backend/internal/model/scan_post/repository"
settingrepo "haixun-backend/internal/model/setting/repository"
+ stylepresetrepo "haixun-backend/internal/model/style_preset/repository"
threadsaccountrepo "haixun-backend/internal/model/threads_account/repository"
)
@@ -97,6 +109,17 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport
{"placement_topics", placementtopicrepo.NewMongoRepository(db).EnsureIndexes},
{"threads_accounts", threadsaccountrepo.NewMongoRepository(db).EnsureIndexes},
{"threads_account_secrets", threadsaccountrepo.NewSecretsMongoRepository(db, secretsCipher).EnsureIndexes},
+ {"publish_analytics", publishanalyticsrepo.NewMongoRepository(db).EnsureIndexes},
+ {"publish_queue", publishqueuerepo.NewMongoRepository(db).EnsureIndexes},
+ {"publish_inventory", publishinventoryrepo.NewMongoRepository(db).EnsureIndexes},
+ {"publish_guard", publishguardrepo.NewMongoRepository(db).EnsureIndexes},
+ {"publish_queue_events", publishqueueeventrepo.NewMongoRepository(db).EnsureIndexes},
+ {"style_presets", stylepresetrepo.NewMongoRepository(db).EnsureIndexes},
+ {"own_post_formulas", ownpostformularepo.NewMongoRepository(db).EnsureIndexes},
+ {"content_formulas", contentformularepo.NewMongoRepository(db).EnsureIndexes},
+ {"content_ops", contentopsrepo.NewMongoRepository(db).EnsureIndexes},
+ {"mention_inbox", mentioninboxrepo.NewMongoRepository(db).EnsureIndexes},
+ {"crm_contacts", crmcontactrepo.NewMongoRepository(db).EnsureIndexes},
}
for _, repo := range repos {
if err := repo.fn(ctx); err != nil {
@@ -105,6 +128,26 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport
}
report.IndexesEnsured = true
+ jobUseCase := jobusecase.NewUseCase(jobTemplateRepository, jobRunRepository, jobScheduleRepository, jobEventRepository, nil)
+ jobTemplates := []struct {
+ name string
+ fn func(context.Context) error
+ }{
+ {"demo", jobUseCase.EnsureDemoTemplate},
+ {"style_8d", jobUseCase.EnsureStyle8DTemplate},
+ {"expand_graph", jobUseCase.EnsureExpandGraphTemplate},
+ {"placement_scan", jobUseCase.EnsurePlacementScanTemplate},
+ {"scan_viral", jobUseCase.EnsureScanViralTemplate},
+ {"generate_outreach_draft", jobUseCase.EnsureGenerateOutreachDraftTemplate},
+ {"refresh_threads_token", jobUseCase.EnsureRefreshThreadsTokenTemplate},
+ {"publish_analytics", jobUseCase.EnsurePublishAnalyticsTemplate},
+ }
+ for _, template := range jobTemplates {
+ if err := template.fn(ctx); err != nil {
+ return nil, fmt.Errorf("seed %s job template: %w", template.name, err)
+ }
+ }
+
permissionUseCase := permissionuc.NewUseCase(permissionRepository, rolePermissionRepository)
if err := permissionUseCase.EnsureDefaultPermissions(ctx); err != nil {
return nil, fmt.Errorf("seed permissions catalog: %w", err)
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index 478ea7a..868cb19 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -52,15 +52,37 @@ type BraveConf struct {
APIKey string `json:",optional"`
}
+type ThreadsOAuthConf struct {
+ AppID string `json:",optional"`
+ AppSecret string `json:",optional"`
+ RedirectURI string `json:",optional"`
+ SuccessRedirect string `json:",optional"`
+}
+
+type ThreadsTokenRefreshConf struct {
+ Enabled bool `json:",default=true"`
+ IntervalSeconds int `json:",default=3600"`
+ LeadDays int `json:",default=7"`
+}
+
+type ThreadsPublishQueueConf struct {
+ Enabled bool `json:",default=true"`
+ IntervalSeconds int `json:",default=60"`
+ BatchSize int `json:",default=20"`
+}
+
type Config struct {
rest.RestConf
- Mongo MongoConf `json:",optional"`
- Redis RedisConf `json:",optional"`
- Auth AuthConf `json:",optional"`
- Secrets SecretsConf `json:",optional"`
- InternalWorker InternalWorkerConf `json:",optional"`
- JobWorker JobWorkerConf `json:",optional"`
- JobScheduler JobSchedulerConf `json:",optional"`
- JobReaper JobReaperConf `json:",optional"`
- Brave BraveConf `json:",optional"`
+ Mongo MongoConf `json:",optional"`
+ Redis RedisConf `json:",optional"`
+ Auth AuthConf `json:",optional"`
+ Secrets SecretsConf `json:",optional"`
+ InternalWorker InternalWorkerConf `json:",optional"`
+ JobWorker JobWorkerConf `json:",optional"`
+ JobScheduler JobSchedulerConf `json:",optional"`
+ JobReaper JobReaperConf `json:",optional"`
+ Brave BraveConf `json:",optional"`
+ ThreadsOAuth ThreadsOAuthConf `json:",optional"`
+ ThreadsTokenRefresh ThreadsTokenRefreshConf `json:",optional"`
+ ThreadsPublishQueue ThreadsPublishQueueConf `json:",optional"`
}
diff --git a/backend/internal/handler/scan_post/lookalike_handler.go b/backend/internal/handler/brand/update_outreach_draft_handler.go
similarity index 66%
rename from backend/internal/handler/scan_post/lookalike_handler.go
rename to backend/internal/handler/brand/update_outreach_draft_handler.go
index 5123c62..f78e0c9 100644
--- a/backend/internal/handler/scan_post/lookalike_handler.go
+++ b/backend/internal/handler/brand/update_outreach_draft_handler.go
@@ -1,9 +1,9 @@
-package scan_post
+package brand
import (
"net/http"
- "haixun-backend/internal/logic/scan_post"
+ "haixun-backend/internal/logic/brand"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@@ -11,9 +11,9 @@ import (
"github.com/zeromicro/go-zero/rest/httpx"
)
-func LookalikeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+func UpdateOutreachDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- var req types.LookalikeReq
+ var req types.UpdateOutreachDraftHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
@@ -22,8 +22,9 @@ func LookalikeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
- l := scan_post.NewLookalikeLogic(r.Context(), svcCtx)
- data, err := l.Lookalike(&req)
+
+ l := brand.NewUpdateOutreachDraftLogic(r.Context(), svcCtx)
+ data, err := l.UpdateOutreachDraft(&req)
response.Write(r.Context(), w, data, err)
}
}
diff --git a/backend/internal/handler/copy_mission/create_copy_mission_handler.go b/backend/internal/handler/copy_mission/create_copy_mission_handler.go
index b97e098..870a129 100644
--- a/backend/internal/handler/copy_mission/create_copy_mission_handler.go
+++ b/backend/internal/handler/copy_mission/create_copy_mission_handler.go
@@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
+ "github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
)
func CreateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
diff --git a/backend/internal/handler/copy_mission/delete_copy_mission_handler.go b/backend/internal/handler/copy_mission/delete_copy_mission_handler.go
index cedeb8d..2340be4 100644
--- a/backend/internal/handler/copy_mission/delete_copy_mission_handler.go
+++ b/backend/internal/handler/copy_mission/delete_copy_mission_handler.go
@@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
+ "github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
)
func DeleteCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
diff --git a/backend/internal/handler/copy_mission/generate_copy_mission_matrix_handler.go b/backend/internal/handler/copy_mission/generate_copy_mission_matrix_handler.go
index db5e4bb..f114ea8 100644
--- a/backend/internal/handler/copy_mission/generate_copy_mission_matrix_handler.go
+++ b/backend/internal/handler/copy_mission/generate_copy_mission_matrix_handler.go
@@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
+ "github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
)
func GenerateCopyMissionMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
diff --git a/backend/internal/handler/copy_mission/get_copy_mission_handler.go b/backend/internal/handler/copy_mission/get_copy_mission_handler.go
index 6034abd..7cbb8d2 100644
--- a/backend/internal/handler/copy_mission/get_copy_mission_handler.go
+++ b/backend/internal/handler/copy_mission/get_copy_mission_handler.go
@@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
+ "github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
)
func GetCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
diff --git a/backend/internal/handler/copy_mission/get_copy_mission_scan_schedule_handler.go b/backend/internal/handler/copy_mission/get_copy_mission_scan_schedule_handler.go
index 9737c28..493d849 100644
--- a/backend/internal/handler/copy_mission/get_copy_mission_scan_schedule_handler.go
+++ b/backend/internal/handler/copy_mission/get_copy_mission_scan_schedule_handler.go
@@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
+ "github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
)
func GetCopyMissionScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
diff --git a/backend/internal/handler/copy_mission/inspire_copy_mission_handler.go b/backend/internal/handler/copy_mission/inspire_copy_mission_handler.go
index 5d0f751..74849bb 100644
--- a/backend/internal/handler/copy_mission/inspire_copy_mission_handler.go
+++ b/backend/internal/handler/copy_mission/inspire_copy_mission_handler.go
@@ -1,14 +1,16 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"net/http"
+ "github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
)
func InspireCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
diff --git a/backend/internal/handler/copy_mission/list_copy_mission_copy_drafts_handler.go b/backend/internal/handler/copy_mission/list_copy_mission_copy_drafts_handler.go
index ff4fe54..00db1ea 100644
--- a/backend/internal/handler/copy_mission/list_copy_mission_copy_drafts_handler.go
+++ b/backend/internal/handler/copy_mission/list_copy_mission_copy_drafts_handler.go
@@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
+ "github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
)
func ListCopyMissionCopyDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
diff --git a/backend/internal/handler/copy_mission/list_copy_mission_scan_posts_handler.go b/backend/internal/handler/copy_mission/list_copy_mission_scan_posts_handler.go
index 1f8705b..460684b 100644
--- a/backend/internal/handler/copy_mission/list_copy_mission_scan_posts_handler.go
+++ b/backend/internal/handler/copy_mission/list_copy_mission_scan_posts_handler.go
@@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
+ "github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
)
func ListCopyMissionScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
diff --git a/backend/internal/handler/copy_mission/list_copy_missions_handler.go b/backend/internal/handler/copy_mission/list_copy_missions_handler.go
index 3d82016..0ee198d 100644
--- a/backend/internal/handler/copy_mission/list_copy_missions_handler.go
+++ b/backend/internal/handler/copy_mission/list_copy_missions_handler.go
@@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
+ "github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
)
func ListCopyMissionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
diff --git a/backend/internal/handler/copy_mission/schedule_copy_mission_drafts_handler.go b/backend/internal/handler/copy_mission/schedule_copy_mission_drafts_handler.go
new file mode 100644
index 0000000..054fd6d
--- /dev/null
+++ b/backend/internal/handler/copy_mission/schedule_copy_mission_drafts_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package copy_mission
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/copy_mission"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func ScheduleCopyMissionDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ScheduleCopyMissionDraftsHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := copy_mission.NewScheduleCopyMissionDraftsLogic(r.Context(), svcCtx)
+ data, err := l.ScheduleCopyMissionDrafts(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/copy_mission/start_copy_mission_analyze_job_handler.go b/backend/internal/handler/copy_mission/start_copy_mission_analyze_job_handler.go
index 8b550a5..1348728 100644
--- a/backend/internal/handler/copy_mission/start_copy_mission_analyze_job_handler.go
+++ b/backend/internal/handler/copy_mission/start_copy_mission_analyze_job_handler.go
@@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
+ "github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
)
func StartCopyMissionAnalyzeJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
diff --git a/backend/internal/handler/copy_mission/start_copy_mission_copy_draft_job_handler.go b/backend/internal/handler/copy_mission/start_copy_mission_copy_draft_job_handler.go
index e76ce7f..fe5dda0 100644
--- a/backend/internal/handler/copy_mission/start_copy_mission_copy_draft_job_handler.go
+++ b/backend/internal/handler/copy_mission/start_copy_mission_copy_draft_job_handler.go
@@ -1,14 +1,16 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"net/http"
+ "github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
)
func StartCopyMissionCopyDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
diff --git a/backend/internal/handler/copy_mission/start_copy_mission_matrix_job_handler.go b/backend/internal/handler/copy_mission/start_copy_mission_matrix_job_handler.go
index b626e5c..2cbcb84 100644
--- a/backend/internal/handler/copy_mission/start_copy_mission_matrix_job_handler.go
+++ b/backend/internal/handler/copy_mission/start_copy_mission_matrix_job_handler.go
@@ -1,14 +1,16 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"net/http"
+ "github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
)
func StartCopyMissionMatrixJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
diff --git a/backend/internal/handler/copy_mission/start_copy_mission_scan_job_handler.go b/backend/internal/handler/copy_mission/start_copy_mission_scan_job_handler.go
index 4309303..d9bbed4 100644
--- a/backend/internal/handler/copy_mission/start_copy_mission_scan_job_handler.go
+++ b/backend/internal/handler/copy_mission/start_copy_mission_scan_job_handler.go
@@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
+ "github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
)
func StartCopyMissionScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
diff --git a/backend/internal/handler/copy_mission/update_copy_mission_handler.go b/backend/internal/handler/copy_mission/update_copy_mission_handler.go
index 580a256..b766055 100644
--- a/backend/internal/handler/copy_mission/update_copy_mission_handler.go
+++ b/backend/internal/handler/copy_mission/update_copy_mission_handler.go
@@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
+ "github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
)
func UpdateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
diff --git a/backend/internal/handler/copy_mission/upsert_copy_mission_scan_schedule_handler.go b/backend/internal/handler/copy_mission/upsert_copy_mission_scan_schedule_handler.go
index 4beed7c..9819673 100644
--- a/backend/internal/handler/copy_mission/upsert_copy_mission_scan_schedule_handler.go
+++ b/backend/internal/handler/copy_mission/upsert_copy_mission_scan_schedule_handler.go
@@ -6,12 +6,11 @@ package copy_mission
import (
"net/http"
+ "github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
)
func UpsertCopyMissionScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
diff --git a/backend/internal/handler/crm/delete_crm_contact_handler.go b/backend/internal/handler/crm/delete_crm_contact_handler.go
deleted file mode 100644
index c46723d..0000000
--- a/backend/internal/handler/crm/delete_crm_contact_handler.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package crm
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/crm"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func DeleteCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.CrmContactIDPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := crm.NewDeleteCrmContactLogic(r.Context(), svcCtx)
- err := l.DeleteCrmContact(&req)
- response.Write(r.Context(), w, nil, err)
- }
-}
diff --git a/backend/internal/handler/persona/create_content_plan_handler.go b/backend/internal/handler/persona/create_content_plan_handler.go
new file mode 100644
index 0000000..33264ab
--- /dev/null
+++ b/backend/internal/handler/persona/create_content_plan_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func CreateContentPlanHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.CreateContentPlanHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewCreateContentPlanLogic(r.Context(), svcCtx)
+ data, err := l.CreateContentPlan(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/create_feedback_event_handler.go b/backend/internal/handler/persona/create_feedback_event_handler.go
new file mode 100644
index 0000000..6085fc3
--- /dev/null
+++ b/backend/internal/handler/persona/create_feedback_event_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func CreateFeedbackEventHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.CreateFeedbackEventHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewCreateFeedbackEventLogic(r.Context(), svcCtx)
+ data, err := l.CreateFeedbackEvent(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/create_formula_pool_handler.go b/backend/internal/handler/persona/create_formula_pool_handler.go
new file mode 100644
index 0000000..209740e
--- /dev/null
+++ b/backend/internal/handler/persona/create_formula_pool_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func CreateFormulaPoolHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.CreateFormulaPoolHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewCreateFormulaPoolLogic(r.Context(), svcCtx)
+ data, err := l.CreateFormulaPool(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/create_knowledge_source_handler.go b/backend/internal/handler/persona/create_knowledge_source_handler.go
new file mode 100644
index 0000000..5dcff2a
--- /dev/null
+++ b/backend/internal/handler/persona/create_knowledge_source_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func CreateKnowledgeSourceHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.CreateKnowledgeSourceHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewCreateKnowledgeSourceLogic(r.Context(), svcCtx)
+ data, err := l.CreateKnowledgeSource(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/delete_style_preset_handler.go b/backend/internal/handler/persona/delete_style_preset_handler.go
new file mode 100644
index 0000000..c7e0873
--- /dev/null
+++ b/backend/internal/handler/persona/delete_style_preset_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func DeleteStylePresetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.StylePresetPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewDeleteStylePresetLogic(r.Context(), svcCtx)
+ err := l.DeleteStylePreset(&req)
+ response.Write(r.Context(), w, nil, err)
+ }
+}
diff --git a/backend/internal/handler/persona/generate_from_content_formula_handler.go b/backend/internal/handler/persona/generate_from_content_formula_handler.go
new file mode 100644
index 0000000..b874afc
--- /dev/null
+++ b/backend/internal/handler/persona/generate_from_content_formula_handler.go
@@ -0,0 +1,29 @@
+package persona
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func GenerateFromContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.GenerateFromContentFormulaHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := persona.NewGenerateFromContentFormulaLogic(r.Context(), svcCtx)
+ data, err := l.GenerateFromContentFormula(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/generate_persona_topic_matrix_handler.go b/backend/internal/handler/persona/generate_persona_topic_matrix_handler.go
new file mode 100644
index 0000000..3148c38
--- /dev/null
+++ b/backend/internal/handler/persona/generate_persona_topic_matrix_handler.go
@@ -0,0 +1,29 @@
+package persona
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func GeneratePersonaTopicMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.GeneratePersonaTopicMatrixHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := persona.NewGeneratePersonaTopicMatrixLogic(r.Context(), svcCtx)
+ data, err := l.GeneratePersonaTopicMatrix(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/list_content_plans_handler.go b/backend/internal/handler/persona/list_content_plans_handler.go
new file mode 100644
index 0000000..850b3c7
--- /dev/null
+++ b/backend/internal/handler/persona/list_content_plans_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func ListContentPlansHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PersonaPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewListContentPlansLogic(r.Context(), svcCtx)
+ data, err := l.ListContentPlans(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/list_formula_pools_handler.go b/backend/internal/handler/persona/list_formula_pools_handler.go
new file mode 100644
index 0000000..6759aa6
--- /dev/null
+++ b/backend/internal/handler/persona/list_formula_pools_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func ListFormulaPoolsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PersonaPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewListFormulaPoolsLogic(r.Context(), svcCtx)
+ data, err := l.ListFormulaPools(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/list_knowledge_chunks_handler.go b/backend/internal/handler/persona/list_knowledge_chunks_handler.go
new file mode 100644
index 0000000..f4d6649
--- /dev/null
+++ b/backend/internal/handler/persona/list_knowledge_chunks_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func ListKnowledgeChunksHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PersonaPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewListKnowledgeChunksLogic(r.Context(), svcCtx)
+ data, err := l.ListKnowledgeChunks(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/list_knowledge_sources_handler.go b/backend/internal/handler/persona/list_knowledge_sources_handler.go
new file mode 100644
index 0000000..6dca00d
--- /dev/null
+++ b/backend/internal/handler/persona/list_knowledge_sources_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func ListKnowledgeSourcesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PersonaPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewListKnowledgeSourcesLogic(r.Context(), svcCtx)
+ data, err := l.ListKnowledgeSources(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/list_persona_content_inbox_handler.go b/backend/internal/handler/persona/list_persona_content_inbox_handler.go
new file mode 100644
index 0000000..efd7e41
--- /dev/null
+++ b/backend/internal/handler/persona/list_persona_content_inbox_handler.go
@@ -0,0 +1,29 @@
+package persona
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func ListPersonaContentInboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ListPersonaContentInboxHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := persona.NewListPersonaContentInboxLogic(r.Context(), svcCtx)
+ data, err := l.ListPersonaContentInbox(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/list_style_presets_handler.go b/backend/internal/handler/persona/list_style_presets_handler.go
new file mode 100644
index 0000000..34cfa79
--- /dev/null
+++ b/backend/internal/handler/persona/list_style_presets_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func ListStylePresetsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PersonaPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewListStylePresetsLogic(r.Context(), svcCtx)
+ data, err := l.ListStylePresets(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/list_topic_candidates_handler.go b/backend/internal/handler/persona/list_topic_candidates_handler.go
new file mode 100644
index 0000000..f5c56ea
--- /dev/null
+++ b/backend/internal/handler/persona/list_topic_candidates_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func ListTopicCandidatesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PersonaPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewListTopicCandidatesLogic(r.Context(), svcCtx)
+ data, err := l.ListTopicCandidates(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/prune_persona_copy_drafts_handler.go b/backend/internal/handler/persona/prune_persona_copy_drafts_handler.go
new file mode 100644
index 0000000..fd51cde
--- /dev/null
+++ b/backend/internal/handler/persona/prune_persona_copy_drafts_handler.go
@@ -0,0 +1,30 @@
+package persona
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func PrunePersonaCopyDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PrunePersonaCopyDraftsHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewPrunePersonaCopyDraftsLogic(r.Context(), svcCtx)
+ data, err := l.PrunePersonaCopyDrafts(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/schedule_persona_copy_draft_handler.go b/backend/internal/handler/persona/schedule_persona_copy_draft_handler.go
new file mode 100644
index 0000000..a799be6
--- /dev/null
+++ b/backend/internal/handler/persona/schedule_persona_copy_draft_handler.go
@@ -0,0 +1,29 @@
+package persona
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func SchedulePersonaCopyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.SchedulePersonaCopyDraftHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := persona.NewSchedulePersonaCopyDraftLogic(r.Context(), svcCtx)
+ data, err := l.SchedulePersonaCopyDraft(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/schedule_persona_drafts_handler.go b/backend/internal/handler/persona/schedule_persona_drafts_handler.go
new file mode 100644
index 0000000..a8e9471
--- /dev/null
+++ b/backend/internal/handler/persona/schedule_persona_drafts_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func SchedulePersonaDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.SchedulePersonaDraftsHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewSchedulePersonaDraftsLogic(r.Context(), svcCtx)
+ data, err := l.SchedulePersonaDrafts(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/start_persona_formula_draft_job_handler.go b/backend/internal/handler/persona/start_persona_formula_draft_job_handler.go
new file mode 100644
index 0000000..2d0db5a
--- /dev/null
+++ b/backend/internal/handler/persona/start_persona_formula_draft_job_handler.go
@@ -0,0 +1,30 @@
+package persona
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func StartPersonaFormulaDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.StartPersonaFormulaDraftJobHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewStartPersonaFormulaDraftJobLogic(r.Context(), svcCtx)
+ data, err := l.StartPersonaFormulaDraftJob(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/start_persona_rewrite_draft_job_handler.go b/backend/internal/handler/persona/start_persona_rewrite_draft_job_handler.go
new file mode 100644
index 0000000..eac2b0d
--- /dev/null
+++ b/backend/internal/handler/persona/start_persona_rewrite_draft_job_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func StartPersonaRewriteDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.StartPersonaRewriteDraftJobHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewStartPersonaRewriteDraftJobLogic(r.Context(), svcCtx)
+ data, err := l.StartPersonaRewriteDraftJob(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/start_persona_style_analysis_from_text_handler.go b/backend/internal/handler/persona/start_persona_style_analysis_from_text_handler.go
new file mode 100644
index 0000000..ae9dc93
--- /dev/null
+++ b/backend/internal/handler/persona/start_persona_style_analysis_from_text_handler.go
@@ -0,0 +1,33 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func StartPersonaStyleAnalysisFromTextHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.StartPersonaStyleAnalysisFromTextHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewStartPersonaStyleAnalysisFromTextLogic(r.Context(), svcCtx)
+ data, err := l.StartPersonaStyleAnalysisFromText(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/start_persona_topic_matrix_job_handler.go b/backend/internal/handler/persona/start_persona_topic_matrix_job_handler.go
new file mode 100644
index 0000000..108a6dc
--- /dev/null
+++ b/backend/internal/handler/persona/start_persona_topic_matrix_job_handler.go
@@ -0,0 +1,30 @@
+package persona
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func StartPersonaTopicMatrixJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.StartPersonaTopicMatrixJobHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewStartPersonaTopicMatrixJobLogic(r.Context(), svcCtx)
+ data, err := l.StartPersonaTopicMatrixJob(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/update_content_plan_handler.go b/backend/internal/handler/persona/update_content_plan_handler.go
new file mode 100644
index 0000000..ba1bb32
--- /dev/null
+++ b/backend/internal/handler/persona/update_content_plan_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func UpdateContentPlanHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.UpdateContentPlanHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewUpdateContentPlanLogic(r.Context(), svcCtx)
+ data, err := l.UpdateContentPlan(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/persona/upsert_style_preset_handler.go b/backend/internal/handler/persona/upsert_style_preset_handler.go
new file mode 100644
index 0000000..cfc28d6
--- /dev/null
+++ b/backend/internal/handler/persona/upsert_style_preset_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func UpsertStylePresetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.UpsertStylePresetHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := persona.NewUpsertStylePresetLogic(r.Context(), svcCtx)
+ data, err := l.UpsertStylePreset(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/placement_topic/start_placement_topic_outreach_draft_job_handler.go b/backend/internal/handler/placement_topic/start_placement_topic_outreach_draft_job_handler.go
new file mode 100644
index 0000000..1a763bd
--- /dev/null
+++ b/backend/internal/handler/placement_topic/start_placement_topic_outreach_draft_job_handler.go
@@ -0,0 +1,30 @@
+package placement_topic
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/placement_topic"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func StartPlacementTopicOutreachDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.StartPlacementTopicOutreachDraftJobHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := placement_topic.NewStartPlacementTopicOutreachDraftJobLogic(r.Context(), svcCtx)
+ data, err := l.StartPlacementTopicOutreachDraftJob(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/placement_topic/update_placement_topic_outreach_draft_handler.go b/backend/internal/handler/placement_topic/update_placement_topic_outreach_draft_handler.go
new file mode 100644
index 0000000..fb4bcb0
--- /dev/null
+++ b/backend/internal/handler/placement_topic/update_placement_topic_outreach_draft_handler.go
@@ -0,0 +1,30 @@
+package placement_topic
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/placement_topic"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func UpdatePlacementTopicOutreachDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.UpdatePlacementTopicOutreachDraftHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := placement_topic.NewUpdatePlacementTopicOutreachDraftLogic(r.Context(), svcCtx)
+ data, err := l.UpdatePlacementTopicOutreachDraft(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/register_extra.go b/backend/internal/handler/register_extra.go
new file mode 100644
index 0000000..e973666
--- /dev/null
+++ b/backend/internal/handler/register_extra.go
@@ -0,0 +1,37 @@
+package handler
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/handler/static"
+ threads_account "haixun-backend/internal/handler/threads_account"
+ "haixun-backend/internal/svc"
+
+ "github.com/zeromicro/go-zero/rest"
+)
+
+// RegisterExtraHandlers mounts routes that are not generated by goctl.
+func RegisterExtraHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
+ server.AddRoutes(
+ []rest.Route{
+ {
+ Method: http.MethodGet,
+ Path: "/downloads/haixun-threads-sync.zip",
+ Handler: static.ExtensionDownloadHandler(),
+ },
+ },
+ rest.WithPrefix("/api/v1"),
+ )
+
+ // Legacy Meta redirect URI (deploy/.env.dev 曾誤設為 /api/threads/oauth/callback)。
+ server.AddRoutes(
+ []rest.Route{
+ {
+ Method: http.MethodGet,
+ Path: "/oauth/callback",
+ Handler: threads_account.ThreadsOauthCallbackHandler(serverCtx),
+ },
+ },
+ rest.WithPrefix("/api/threads"),
+ )
+}
diff --git a/backend/internal/handler/routes.go b/backend/internal/handler/routes.go
index fa9501e..efaebe0 100644
--- a/backend/internal/handler/routes.go
+++ b/backend/internal/handler/routes.go
@@ -10,14 +10,12 @@ import (
auth "haixun-backend/internal/handler/auth"
brand "haixun-backend/internal/handler/brand"
copy_mission "haixun-backend/internal/handler/copy_mission"
- crm "haixun-backend/internal/handler/crm"
job "haixun-backend/internal/handler/job"
member "haixun-backend/internal/handler/member"
normal "haixun-backend/internal/handler/normal"
permission "haixun-backend/internal/handler/permission"
persona "haixun-backend/internal/handler/persona"
placement_topic "haixun-backend/internal/handler/placement_topic"
- scan_post "haixun-backend/internal/handler/scan_post"
setting "haixun-backend/internal/handler/setting"
threads_account "haixun-backend/internal/handler/threads_account"
"haixun-backend/internal/svc"
@@ -164,6 +162,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/knowledge-graph/nodes",
Handler: brand.PatchKnowledgeGraphNodesHandler(serverCtx),
},
+ {
+ Method: http.MethodPatch,
+ Path: "/:id/outreach-drafts/:draftId",
+ Handler: brand.UpdateOutreachDraftHandler(serverCtx),
+ },
{
Method: http.MethodPost,
Path: "/:id/outreach-drafts/generate",
@@ -278,6 +281,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:personaId/copy-missions/:id/copy-drafts",
Handler: copy_mission.ListCopyMissionCopyDraftsHandler(serverCtx),
},
+ {
+ Method: http.MethodPost,
+ Path: "/:personaId/copy-missions/:id/copy-drafts/schedule",
+ Handler: copy_mission.ScheduleCopyMissionDraftsHandler(serverCtx),
+ },
{
Method: http.MethodGet,
Path: "/:personaId/copy-missions/:id/knowledge-graph",
@@ -574,6 +582,31 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id",
Handler: persona.DeletePersonaHandler(serverCtx),
},
+ {
+ Method: http.MethodPost,
+ Path: "/:id/content-formulas/:formulaId/generate",
+ Handler: persona.GenerateFromContentFormulaHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/content-inbox",
+ Handler: persona.ListPersonaContentInboxHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/content-plans",
+ Handler: persona.ListContentPlansHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/content-plans",
+ Handler: persona.CreateContentPlanHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPatch,
+ Path: "/:id/content-plans/:contentPlanId",
+ Handler: persona.UpdateContentPlanHandler(serverCtx),
+ },
{
Method: http.MethodGet,
Path: "/:id/copy-drafts",
@@ -594,16 +627,106 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/copy-drafts/:draftId/publish",
Handler: persona.PublishPersonaCopyDraftHandler(serverCtx),
},
+ {
+ Method: http.MethodPost,
+ Path: "/:id/copy-drafts/:draftId/schedule",
+ Handler: persona.SchedulePersonaCopyDraftHandler(serverCtx),
+ },
{
Method: http.MethodPost,
Path: "/:id/copy-drafts/generate",
Handler: persona.GeneratePersonaCopyDraftHandler(serverCtx),
},
+ {
+ Method: http.MethodPost,
+ Path: "/:id/copy-drafts/prune",
+ Handler: persona.PrunePersonaCopyDraftsHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/copy-drafts/schedule",
+ Handler: persona.SchedulePersonaDraftsHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/feedback-events",
+ Handler: persona.CreateFeedbackEventHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/formula-draft/jobs",
+ Handler: persona.StartPersonaFormulaDraftJobHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/formula-pools",
+ Handler: persona.ListFormulaPoolsHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/formula-pools",
+ Handler: persona.CreateFormulaPoolHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/knowledge-chunks",
+ Handler: persona.ListKnowledgeChunksHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/knowledge-sources",
+ Handler: persona.CreateKnowledgeSourceHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/knowledge-sources",
+ Handler: persona.ListKnowledgeSourcesHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/rewrite-draft/jobs",
+ Handler: persona.StartPersonaRewriteDraftJobHandler(serverCtx),
+ },
{
Method: http.MethodPost,
Path: "/:id/style-analysis",
Handler: persona.StartPersonaStyleAnalysisHandler(serverCtx),
},
+ {
+ Method: http.MethodPost,
+ Path: "/:id/style-analysis-from-text",
+ Handler: persona.StartPersonaStyleAnalysisFromTextHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/style-presets",
+ Handler: persona.ListStylePresetsHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPut,
+ Path: "/:id/style-presets/:presetId",
+ Handler: persona.UpsertStylePresetHandler(serverCtx),
+ },
+ {
+ Method: http.MethodDelete,
+ Path: "/:id/style-presets/:presetId",
+ Handler: persona.DeleteStylePresetHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/topic-candidates",
+ Handler: persona.ListTopicCandidatesHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/topic-matrix/generate",
+ Handler: persona.GeneratePersonaTopicMatrixHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/topic-matrix/jobs",
+ Handler: persona.StartPersonaTopicMatrixJobHandler(serverCtx),
+ },
{
Method: http.MethodPost,
Path: "/:id/viral-scan-jobs",
@@ -673,6 +796,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/knowledge-graph/nodes",
Handler: placement_topic.PatchPlacementTopicGraphNodesHandler(serverCtx),
},
+ {
+ Method: http.MethodPost,
+ Path: "/:id/outreach-draft-jobs",
+ Handler: placement_topic.StartPlacementTopicOutreachDraftJobHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPatch,
+ Path: "/:id/outreach-drafts/:draftId",
+ Handler: placement_topic.UpdatePlacementTopicOutreachDraftHandler(serverCtx),
+ },
{
Method: http.MethodPost,
Path: "/:id/outreach-drafts/generate",
@@ -752,54 +885,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/settings"),
)
- server.AddRoutes(
- rest.WithMiddlewares(
- []rest.Middleware{serverCtx.AuthJWT},
- []rest.Route{
- {
- Method: http.MethodGet,
- Path: "/",
- Handler: crm.ListCrmContactsHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/",
- Handler: crm.CreateCrmContactHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id",
- Handler: crm.GetCrmContactHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:id",
- Handler: crm.UpdateCrmContactHandler(serverCtx),
- },
- {
- Method: http.MethodDelete,
- Path: "/:id",
- Handler: crm.DeleteCrmContactHandler(serverCtx),
- },
- }...,
- ),
- rest.WithPrefix("/api/v1/crm/contacts"),
- )
-
- server.AddRoutes(
- rest.WithMiddlewares(
- []rest.Middleware{serverCtx.AuthJWT},
- []rest.Route{
- {
- Method: http.MethodPost,
- Path: "/lookalike",
- Handler: scan_post.LookalikeHandler(serverCtx),
- },
- }...,
- ),
- rest.WithPrefix("/api/v1/scan-post"),
- )
-
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
@@ -824,6 +909,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id",
Handler: threads_account.UpdateThreadsAccountHandler(serverCtx),
},
+ {
+ Method: http.MethodDelete,
+ Path: "/:id",
+ Handler: threads_account.DeleteThreadsAccountHandler(serverCtx),
+ },
{
Method: http.MethodPost,
Path: "/:id/activate",
@@ -844,6 +934,21 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/ai-settings/providers/:provider/models",
Handler: threads_account.ListThreadsAccountAiProviderModelsHandler(serverCtx),
},
+ {
+ Method: http.MethodPost,
+ Path: "/:id/api/disconnect",
+ Handler: threads_account.DisconnectThreadsApiHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/api/playground",
+ Handler: threads_account.RunThreadsApiPlaygroundHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/api/smoke-test",
+ Handler: threads_account.RunThreadsApiSmokeTestHandler(serverCtx),
+ },
{
Method: http.MethodGet,
Path: "/:id/connection",
@@ -854,13 +959,209 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/connection",
Handler: threads_account.UpdateThreadsAccountConnectionHandler(serverCtx),
},
+ {
+ Method: http.MethodGet,
+ Path: "/:id/content-formulas",
+ Handler: threads_account.ListContentFormulasHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/content-formulas/:formulaId",
+ Handler: threads_account.GetContentFormulaHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPatch,
+ Path: "/:id/content-formulas/:formulaId",
+ Handler: threads_account.PatchContentFormulaHandler(serverCtx),
+ },
+ {
+ Method: http.MethodDelete,
+ Path: "/:id/content-formulas/:formulaId",
+ Handler: threads_account.DeleteContentFormulaHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/content-formulas/analyze",
+ Handler: threads_account.AnalyzeContentFormulaHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/content-formulas/import-own-post",
+ Handler: threads_account.ImportOwnPostToContentFormulaHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/content-formulas/search-posts",
+ Handler: threads_account.SearchContentFormulaPostsHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/diagnostics",
+ Handler: threads_account.GetThreadsDiagnosticsHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/mention-inbox",
+ Handler: threads_account.ListMentionInboxHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/mention-inbox/:media_id/reply",
+ Handler: threads_account.PublishMentionReplyHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/mention-inbox/sync",
+ Handler: threads_account.SyncMentionInboxHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/own-post-formula",
+ Handler: threads_account.GenerateOwnPostFormulaHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/own-post-formulas",
+ Handler: threads_account.ListOwnPostFormulasHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/own-post-reply-draft",
+ Handler: threads_account.GenerateOwnPostReplyDraftHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/post-performance",
+ Handler: threads_account.ListThreadsPostPerformanceHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/publish-alerts",
+ Handler: threads_account.ListPublishAlertsHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/publish-calendar",
+ Handler: threads_account.ListPublishQueueRangeHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/publish-guard-policy",
+ Handler: threads_account.GetPublishGuardPolicyHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPut,
+ Path: "/:id/publish-guard-policy",
+ Handler: threads_account.UpsertPublishGuardPolicyHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/publish-guard/resume",
+ Handler: threads_account.ResumePublishGuardHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/publish-health",
+ Handler: threads_account.ListThreadsPublishHealthHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/publish-inventory-policy",
+ Handler: threads_account.GetPublishInventoryPolicyHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPut,
+ Path: "/:id/publish-inventory-policy",
+ Handler: threads_account.UpsertPublishInventoryPolicyHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/publish-inventory/refill-jobs",
+ Handler: threads_account.StartPublishInventoryRefillJobHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/publish-queue",
+ Handler: threads_account.CreatePublishQueueItemHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/publish-queue",
+ Handler: threads_account.ListPublishQueueItemsHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/publish-queue/:qid",
+ Handler: threads_account.GetPublishQueueItemHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPatch,
+ Path: "/:id/publish-queue/:qid",
+ Handler: threads_account.PatchPublishQueueItemHandler(serverCtx),
+ },
+ {
+ Method: http.MethodDelete,
+ Path: "/:id/publish-queue/:qid",
+ Handler: threads_account.DeletePublishQueueItemHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/publish-queue/:qid/cancel",
+ Handler: threads_account.CancelPublishQueueItemHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/publish-queue/:qid/events",
+ Handler: threads_account.ListPublishQueueEventsHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/publish-queue/:qid/retry",
+ Handler: threads_account.RetryPublishQueueItemHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/publish-slot-insights",
+ Handler: threads_account.GetPublishSlotInsightsHandler(serverCtx),
+ },
{
Method: http.MethodPost,
Path: "/:id/session/import",
Handler: threads_account.ImportThreadsAccountSessionHandler(serverCtx),
},
+ {
+ Method: http.MethodGet,
+ Path: "/oauth/config",
+ Handler: threads_account.GetThreadsOauthConfigHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/oauth/logs",
+ Handler: threads_account.ListThreadsOauthLogsHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/oauth/start",
+ Handler: threads_account.StartThreadsOauthHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/publish-dashboard-summary",
+ Handler: threads_account.GetPublishDashboardSummaryHandler(serverCtx),
+ },
}...,
),
rest.WithPrefix("/api/v1/threads-accounts"),
)
+
+ server.AddRoutes(
+ []rest.Route{
+ {
+ Method: http.MethodGet,
+ Path: "/oauth/callback",
+ Handler: threads_account.ThreadsOauthCallbackHandler(serverCtx),
+ },
+ },
+ rest.WithPrefix("/api/v1/threads-accounts"),
+ )
}
diff --git a/backend/internal/handler/static/extension_download.go b/backend/internal/handler/static/extension_download.go
new file mode 100644
index 0000000..ad1d2f5
--- /dev/null
+++ b/backend/internal/handler/static/extension_download.go
@@ -0,0 +1,42 @@
+package static
+
+import (
+ "net/http"
+ "os"
+ "path/filepath"
+)
+
+func ExtensionDownloadHandler() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ path, ok := resolveExtensionZipPath()
+ if !ok {
+ http.Error(w, "extension package not found", http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Content-Type", "application/zip")
+ w.Header().Set("Content-Disposition", `attachment; filename="haixun-threads-sync.zip"`)
+ http.ServeFile(w, r, path)
+ }
+}
+
+func resolveExtensionZipPath() (string, bool) {
+ candidates := []string{
+ filepath.Join("web", "public", "downloads", "haixun-threads-sync.zip"),
+ filepath.Join("backend", "web", "public", "downloads", "haixun-threads-sync.zip"),
+ filepath.Join("..", "web", "public", "downloads", "haixun-threads-sync.zip"),
+ filepath.Join("extension", "haixun-threads-sync.zip"),
+ }
+ if wd, err := os.Getwd(); err == nil {
+ candidates = append(candidates,
+ filepath.Join(wd, "web", "public", "downloads", "haixun-threads-sync.zip"),
+ filepath.Join(wd, "backend", "web", "public", "downloads", "haixun-threads-sync.zip"),
+ filepath.Join(wd, "..", "extension", "haixun-threads-sync.zip"),
+ )
+ }
+ for _, candidate := range candidates {
+ if info, err := os.Stat(candidate); err == nil && !info.IsDir() && info.Size() > 0 {
+ return candidate, true
+ }
+ }
+ return "", false
+}
diff --git a/backend/internal/handler/threads_account/analyze_content_formula_handler.go b/backend/internal/handler/threads_account/analyze_content_formula_handler.go
new file mode 100644
index 0000000..b9d9714
--- /dev/null
+++ b/backend/internal/handler/threads_account/analyze_content_formula_handler.go
@@ -0,0 +1,29 @@
+package threads_account
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func AnalyzeContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.AnalyzeContentFormulaHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewAnalyzeContentFormulaLogic(r.Context(), svcCtx)
+ data, err := l.AnalyzeContentFormula(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/cancel_publish_queue_item_handler.go b/backend/internal/handler/threads_account/cancel_publish_queue_item_handler.go
new file mode 100644
index 0000000..c521ec8
--- /dev/null
+++ b/backend/internal/handler/threads_account/cancel_publish_queue_item_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func CancelPublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PublishQueueItemPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewCancelPublishQueueItemLogic(r.Context(), svcCtx)
+ data, err := l.CancelPublishQueueItem(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/create_publish_queue_item_handler.go b/backend/internal/handler/threads_account/create_publish_queue_item_handler.go
new file mode 100644
index 0000000..2f57380
--- /dev/null
+++ b/backend/internal/handler/threads_account/create_publish_queue_item_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func CreatePublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.CreatePublishQueueHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewCreatePublishQueueItemLogic(r.Context(), svcCtx)
+ data, err := l.CreatePublishQueueItem(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/delete_content_formula_handler.go b/backend/internal/handler/threads_account/delete_content_formula_handler.go
new file mode 100644
index 0000000..e3ec1ee
--- /dev/null
+++ b/backend/internal/handler/threads_account/delete_content_formula_handler.go
@@ -0,0 +1,29 @@
+package threads_account
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func DeleteContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.GetContentFormulaHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewDeleteContentFormulaLogic(r.Context(), svcCtx)
+ data, err := l.DeleteContentFormula(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/delete_publish_queue_item_handler.go b/backend/internal/handler/threads_account/delete_publish_queue_item_handler.go
new file mode 100644
index 0000000..568c3a6
--- /dev/null
+++ b/backend/internal/handler/threads_account/delete_publish_queue_item_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func DeletePublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PublishQueueItemPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewDeletePublishQueueItemLogic(r.Context(), svcCtx)
+ err := l.DeletePublishQueueItem(&req)
+ response.Write(r.Context(), w, nil, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/delete_threads_account_handler.go b/backend/internal/handler/threads_account/delete_threads_account_handler.go
new file mode 100644
index 0000000..9029e7f
--- /dev/null
+++ b/backend/internal/handler/threads_account/delete_threads_account_handler.go
@@ -0,0 +1,33 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func DeleteThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ThreadsAccountPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewDeleteThreadsAccountLogic(r.Context(), svcCtx)
+ data, err := l.DeleteThreadsAccount(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/disconnect_threads_api_handler.go b/backend/internal/handler/threads_account/disconnect_threads_api_handler.go
new file mode 100644
index 0000000..a1b8f53
--- /dev/null
+++ b/backend/internal/handler/threads_account/disconnect_threads_api_handler.go
@@ -0,0 +1,30 @@
+package threads_account
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func DisconnectThreadsApiHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ThreadsAccountPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewDisconnectThreadsApiLogic(r.Context(), svcCtx)
+ data, err := l.DisconnectThreadsApi(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/generate_own_post_formula_handler.go b/backend/internal/handler/threads_account/generate_own_post_formula_handler.go
new file mode 100644
index 0000000..f78d69d
--- /dev/null
+++ b/backend/internal/handler/threads_account/generate_own_post_formula_handler.go
@@ -0,0 +1,28 @@
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func GenerateOwnPostFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.GenerateOwnPostFormulaHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewGenerateOwnPostFormulaLogic(r.Context(), svcCtx)
+ data, err := l.GenerateOwnPostFormula(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/generate_own_post_reply_draft_handler.go b/backend/internal/handler/threads_account/generate_own_post_reply_draft_handler.go
new file mode 100644
index 0000000..fc8341c
--- /dev/null
+++ b/backend/internal/handler/threads_account/generate_own_post_reply_draft_handler.go
@@ -0,0 +1,28 @@
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func GenerateOwnPostReplyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.GenerateOwnPostReplyDraftHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewGenerateOwnPostReplyDraftLogic(r.Context(), svcCtx)
+ data, err := l.GenerateOwnPostReplyDraft(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/get_content_formula_handler.go b/backend/internal/handler/threads_account/get_content_formula_handler.go
new file mode 100644
index 0000000..cc267a0
--- /dev/null
+++ b/backend/internal/handler/threads_account/get_content_formula_handler.go
@@ -0,0 +1,29 @@
+package threads_account
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func GetContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.GetContentFormulaHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewGetContentFormulaLogic(r.Context(), svcCtx)
+ data, err := l.GetContentFormula(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/get_publish_dashboard_summary_handler.go b/backend/internal/handler/threads_account/get_publish_dashboard_summary_handler.go
new file mode 100644
index 0000000..cc261ed
--- /dev/null
+++ b/backend/internal/handler/threads_account/get_publish_dashboard_summary_handler.go
@@ -0,0 +1,20 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+)
+
+func GetPublishDashboardSummaryHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ l := threads_account.NewGetPublishDashboardSummaryLogic(r.Context(), svcCtx)
+ data, err := l.GetPublishDashboardSummary()
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/get_publish_guard_policy_handler.go b/backend/internal/handler/threads_account/get_publish_guard_policy_handler.go
new file mode 100644
index 0000000..0f2171f
--- /dev/null
+++ b/backend/internal/handler/threads_account/get_publish_guard_policy_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func GetPublishGuardPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ThreadsAccountPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewGetPublishGuardPolicyLogic(r.Context(), svcCtx)
+ data, err := l.GetPublishGuardPolicy(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/get_publish_inventory_policy_handler.go b/backend/internal/handler/threads_account/get_publish_inventory_policy_handler.go
new file mode 100644
index 0000000..6b0e387
--- /dev/null
+++ b/backend/internal/handler/threads_account/get_publish_inventory_policy_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func GetPublishInventoryPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ThreadsAccountPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewGetPublishInventoryPolicyLogic(r.Context(), svcCtx)
+ data, err := l.GetPublishInventoryPolicy(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/get_publish_queue_item_handler.go b/backend/internal/handler/threads_account/get_publish_queue_item_handler.go
new file mode 100644
index 0000000..c525c26
--- /dev/null
+++ b/backend/internal/handler/threads_account/get_publish_queue_item_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func GetPublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PublishQueueItemPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewGetPublishQueueItemLogic(r.Context(), svcCtx)
+ data, err := l.GetPublishQueueItem(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/get_publish_slot_insights_handler.go b/backend/internal/handler/threads_account/get_publish_slot_insights_handler.go
new file mode 100644
index 0000000..d1d21ad
--- /dev/null
+++ b/backend/internal/handler/threads_account/get_publish_slot_insights_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func GetPublishSlotInsightsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ThreadsAccountPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewGetPublishSlotInsightsLogic(r.Context(), svcCtx)
+ data, err := l.GetPublishSlotInsights(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/get_threads_diagnostics_handler.go b/backend/internal/handler/threads_account/get_threads_diagnostics_handler.go
new file mode 100644
index 0000000..1053001
--- /dev/null
+++ b/backend/internal/handler/threads_account/get_threads_diagnostics_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func GetThreadsDiagnosticsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ThreadsAccountPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewGetThreadsDiagnosticsLogic(r.Context(), svcCtx)
+ data, err := l.GetThreadsDiagnostics(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/get_threads_oauth_config_handler.go b/backend/internal/handler/threads_account/get_threads_oauth_config_handler.go
new file mode 100644
index 0000000..74a2fde
--- /dev/null
+++ b/backend/internal/handler/threads_account/get_threads_oauth_config_handler.go
@@ -0,0 +1,16 @@
+package threads_account
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+)
+
+func GetThreadsOauthConfigHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ l := threads_account.NewGetThreadsOAuthConfigLogic(svcCtx)
+ response.Write(r.Context(), w, l.GetThreadsOAuthConfig(), nil)
+ }
+}
diff --git a/backend/internal/handler/threads_account/import_own_post_to_content_formula_handler.go b/backend/internal/handler/threads_account/import_own_post_to_content_formula_handler.go
new file mode 100644
index 0000000..7848037
--- /dev/null
+++ b/backend/internal/handler/threads_account/import_own_post_to_content_formula_handler.go
@@ -0,0 +1,29 @@
+package threads_account
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func ImportOwnPostToContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ImportOwnPostFormulaHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewImportOwnPostToContentFormulaLogic(r.Context(), svcCtx)
+ data, err := l.ImportOwnPostToContentFormula(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/list_content_formulas_handler.go b/backend/internal/handler/threads_account/list_content_formulas_handler.go
new file mode 100644
index 0000000..7b47455
--- /dev/null
+++ b/backend/internal/handler/threads_account/list_content_formulas_handler.go
@@ -0,0 +1,29 @@
+package threads_account
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func ListContentFormulasHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ListContentFormulasHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewListContentFormulasLogic(r.Context(), svcCtx)
+ data, err := l.ListContentFormulas(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/crm/create_crm_contact_handler.go b/backend/internal/handler/threads_account/list_mention_inbox_handler.go
similarity index 66%
rename from backend/internal/handler/crm/create_crm_contact_handler.go
rename to backend/internal/handler/threads_account/list_mention_inbox_handler.go
index 52d1aed..51139bf 100644
--- a/backend/internal/handler/crm/create_crm_contact_handler.go
+++ b/backend/internal/handler/threads_account/list_mention_inbox_handler.go
@@ -1,19 +1,18 @@
-package crm
+package threads_account
import (
"net/http"
- "haixun-backend/internal/logic/crm"
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
)
-func CreateCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+func ListMentionInboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- var req types.CreateCrmContactReq
+ var req types.ListMentionInboxHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
@@ -22,8 +21,8 @@ func CreateCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
- l := crm.NewCreateCrmContactLogic(r.Context(), svcCtx)
- data, err := l.CreateCrmContact(&req)
+ l := threads_account.NewListMentionInboxLogic(r.Context(), svcCtx)
+ data, err := l.ListMentionInbox(&req)
response.Write(r.Context(), w, data, err)
}
}
diff --git a/backend/internal/handler/threads_account/list_own_post_formulas_handler.go b/backend/internal/handler/threads_account/list_own_post_formulas_handler.go
new file mode 100644
index 0000000..e40a02b
--- /dev/null
+++ b/backend/internal/handler/threads_account/list_own_post_formulas_handler.go
@@ -0,0 +1,28 @@
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func ListOwnPostFormulasHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ListOwnPostFormulasHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewListOwnPostFormulasLogic(r.Context(), svcCtx)
+ data, err := l.ListOwnPostFormulas(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/list_publish_alerts_handler.go b/backend/internal/handler/threads_account/list_publish_alerts_handler.go
new file mode 100644
index 0000000..793552b
--- /dev/null
+++ b/backend/internal/handler/threads_account/list_publish_alerts_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func ListPublishAlertsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ThreadsAccountPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewListPublishAlertsLogic(r.Context(), svcCtx)
+ data, err := l.ListPublishAlerts(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/list_publish_queue_events_handler.go b/backend/internal/handler/threads_account/list_publish_queue_events_handler.go
new file mode 100644
index 0000000..7aed7d9
--- /dev/null
+++ b/backend/internal/handler/threads_account/list_publish_queue_events_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func ListPublishQueueEventsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PublishQueueItemPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewListPublishQueueEventsLogic(r.Context(), svcCtx)
+ data, err := l.ListPublishQueueEvents(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/list_publish_queue_items_handler.go b/backend/internal/handler/threads_account/list_publish_queue_items_handler.go
new file mode 100644
index 0000000..8044880
--- /dev/null
+++ b/backend/internal/handler/threads_account/list_publish_queue_items_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func ListPublishQueueItemsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ListPublishQueueHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewListPublishQueueItemsLogic(r.Context(), svcCtx)
+ data, err := l.ListPublishQueueItems(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/list_publish_queue_range_handler.go b/backend/internal/handler/threads_account/list_publish_queue_range_handler.go
new file mode 100644
index 0000000..0c910b4
--- /dev/null
+++ b/backend/internal/handler/threads_account/list_publish_queue_range_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func ListPublishQueueRangeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PublishQueueRangeHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewListPublishQueueRangeLogic(r.Context(), svcCtx)
+ data, err := l.ListPublishQueueRange(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/list_threads_oauth_logs_handler.go b/backend/internal/handler/threads_account/list_threads_oauth_logs_handler.go
new file mode 100644
index 0000000..652dc36
--- /dev/null
+++ b/backend/internal/handler/threads_account/list_threads_oauth_logs_handler.go
@@ -0,0 +1,17 @@
+package threads_account
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+)
+
+func ListThreadsOauthLogsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ l := threads_account.NewListThreadsOAuthLogsLogic(r.Context(), svcCtx)
+ data, err := l.ListThreadsOAuthLogs()
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/list_threads_post_performance_handler.go b/backend/internal/handler/threads_account/list_threads_post_performance_handler.go
new file mode 100644
index 0000000..9771db6
--- /dev/null
+++ b/backend/internal/handler/threads_account/list_threads_post_performance_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func ListThreadsPostPerformanceHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ListThreadsPostPerformanceHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewListThreadsPostPerformanceLogic(r.Context(), svcCtx)
+ data, err := l.ListThreadsPostPerformance(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/list_threads_publish_health_handler.go b/backend/internal/handler/threads_account/list_threads_publish_health_handler.go
new file mode 100644
index 0000000..5b2c35b
--- /dev/null
+++ b/backend/internal/handler/threads_account/list_threads_publish_health_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func ListThreadsPublishHealthHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ListThreadsPublishHealthHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewListThreadsPublishHealthLogic(r.Context(), svcCtx)
+ data, err := l.ListThreadsPublishHealth(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/patch_content_formula_handler.go b/backend/internal/handler/threads_account/patch_content_formula_handler.go
new file mode 100644
index 0000000..56a98d3
--- /dev/null
+++ b/backend/internal/handler/threads_account/patch_content_formula_handler.go
@@ -0,0 +1,29 @@
+package threads_account
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func PatchContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PatchContentFormulaHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewPatchContentFormulaLogic(r.Context(), svcCtx)
+ data, err := l.PatchContentFormula(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/patch_publish_queue_item_handler.go b/backend/internal/handler/threads_account/patch_publish_queue_item_handler.go
new file mode 100644
index 0000000..13276a4
--- /dev/null
+++ b/backend/internal/handler/threads_account/patch_publish_queue_item_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func PatchPublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PatchPublishQueueHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewPatchPublishQueueItemLogic(r.Context(), svcCtx)
+ data, err := l.PatchPublishQueueItem(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/publish_mention_reply_handler.go b/backend/internal/handler/threads_account/publish_mention_reply_handler.go
new file mode 100644
index 0000000..0568b7c
--- /dev/null
+++ b/backend/internal/handler/threads_account/publish_mention_reply_handler.go
@@ -0,0 +1,28 @@
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func PublishMentionReplyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PublishMentionReplyHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewPublishMentionReplyLogic(r.Context(), svcCtx)
+ data, err := l.PublishMentionReply(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/resume_publish_guard_handler.go b/backend/internal/handler/threads_account/resume_publish_guard_handler.go
new file mode 100644
index 0000000..67e2dd3
--- /dev/null
+++ b/backend/internal/handler/threads_account/resume_publish_guard_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func ResumePublishGuardHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ThreadsAccountPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewResumePublishGuardLogic(r.Context(), svcCtx)
+ data, err := l.ResumePublishGuard(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/retry_publish_queue_item_handler.go b/backend/internal/handler/threads_account/retry_publish_queue_item_handler.go
new file mode 100644
index 0000000..c56893c
--- /dev/null
+++ b/backend/internal/handler/threads_account/retry_publish_queue_item_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func RetryPublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PublishQueueItemPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewRetryPublishQueueItemLogic(r.Context(), svcCtx)
+ data, err := l.RetryPublishQueueItem(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/crm/get_crm_contact_handler.go b/backend/internal/handler/threads_account/run_threads_api_playground_handler.go
similarity index 55%
rename from backend/internal/handler/crm/get_crm_contact_handler.go
rename to backend/internal/handler/threads_account/run_threads_api_playground_handler.go
index d4df08c..b0be4e7 100644
--- a/backend/internal/handler/crm/get_crm_contact_handler.go
+++ b/backend/internal/handler/threads_account/run_threads_api_playground_handler.go
@@ -1,9 +1,9 @@
-package crm
+package threads_account
import (
"net/http"
- "haixun-backend/internal/logic/crm"
+ "haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@@ -11,15 +11,15 @@ import (
"github.com/zeromicro/go-zero/rest/httpx"
)
-func GetCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+func RunThreadsApiPlaygroundHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- var req types.CrmContactIDPath
+ var req types.RunThreadsAPIPlaygroundHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
- l := crm.NewGetCrmContactLogic(r.Context(), svcCtx)
- data, err := l.GetCrmContact(&req)
+ l := threads_account.NewRunThreadsAPIPlaygroundLogic(r.Context(), svcCtx)
+ data, err := l.RunThreadsAPIPlayground(&req)
response.Write(r.Context(), w, data, err)
}
}
diff --git a/backend/internal/handler/crm/update_crm_contact_handler.go b/backend/internal/handler/threads_account/run_threads_api_smoke_test_handler.go
similarity index 56%
rename from backend/internal/handler/crm/update_crm_contact_handler.go
rename to backend/internal/handler/threads_account/run_threads_api_smoke_test_handler.go
index 8d2746c..3893098 100644
--- a/backend/internal/handler/crm/update_crm_contact_handler.go
+++ b/backend/internal/handler/threads_account/run_threads_api_smoke_test_handler.go
@@ -1,9 +1,9 @@
-package crm
+package threads_account
import (
"net/http"
- "haixun-backend/internal/logic/crm"
+ "haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@@ -11,15 +11,15 @@ import (
"github.com/zeromicro/go-zero/rest/httpx"
)
-func UpdateCrmContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+func RunThreadsApiSmokeTestHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdateCrmContactReq
+ var req types.ThreadsAccountPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
- l := crm.NewUpdateCrmContactLogic(r.Context(), svcCtx)
- data, err := l.UpdateCrmContact(&req)
+ l := threads_account.NewRunThreadsAPISmokeTestLogic(r.Context(), svcCtx)
+ data, err := l.RunThreadsAPISmokeTest(&req)
response.Write(r.Context(), w, data, err)
}
}
diff --git a/backend/internal/handler/threads_account/search_content_formula_posts_handler.go b/backend/internal/handler/threads_account/search_content_formula_posts_handler.go
new file mode 100644
index 0000000..75139b6
--- /dev/null
+++ b/backend/internal/handler/threads_account/search_content_formula_posts_handler.go
@@ -0,0 +1,29 @@
+package threads_account
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func SearchContentFormulaPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.SearchContentFormulaPostsHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewSearchContentFormulaPostsLogic(r.Context(), svcCtx)
+ data, err := l.SearchContentFormulaPosts(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/start_publish_inventory_refill_job_handler.go b/backend/internal/handler/threads_account/start_publish_inventory_refill_job_handler.go
new file mode 100644
index 0000000..639f209
--- /dev/null
+++ b/backend/internal/handler/threads_account/start_publish_inventory_refill_job_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func StartPublishInventoryRefillJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.StartPublishInventoryRefillJobHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewStartPublishInventoryRefillJobLogic(r.Context(), svcCtx)
+ data, err := l.StartPublishInventoryRefillJob(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/crm/list_crm_contacts_handler.go b/backend/internal/handler/threads_account/start_threads_oauth_handler.go
similarity index 57%
rename from backend/internal/handler/crm/list_crm_contacts_handler.go
rename to backend/internal/handler/threads_account/start_threads_oauth_handler.go
index 6d74542..9c2c39a 100644
--- a/backend/internal/handler/crm/list_crm_contacts_handler.go
+++ b/backend/internal/handler/threads_account/start_threads_oauth_handler.go
@@ -1,9 +1,9 @@
-package crm
+package threads_account
import (
"net/http"
- "haixun-backend/internal/logic/crm"
+ "haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@@ -11,15 +11,16 @@ import (
"github.com/zeromicro/go-zero/rest/httpx"
)
-func ListCrmContactsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+func StartThreadsOauthHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- var req types.ListCrmContactsReq
+ var req types.StartThreadsOAuthQuery
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
- l := crm.NewListCrmContactsLogic(r.Context(), svcCtx)
- data, err := l.ListCrmContacts(&req)
+
+ l := threads_account.NewStartThreadsOAuthLogic(r.Context(), svcCtx)
+ data, err := l.StartThreadsOAuth(&req)
response.Write(r.Context(), w, data, err)
}
}
diff --git a/backend/internal/handler/threads_account/sync_mention_inbox_handler.go b/backend/internal/handler/threads_account/sync_mention_inbox_handler.go
new file mode 100644
index 0000000..2978069
--- /dev/null
+++ b/backend/internal/handler/threads_account/sync_mention_inbox_handler.go
@@ -0,0 +1,28 @@
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func SyncMentionInboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.SyncMentionInboxHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewSyncMentionInboxLogic(r.Context(), svcCtx)
+ data, err := l.SyncMentionInbox(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/threads_oauth_callback_handler.go b/backend/internal/handler/threads_account/threads_oauth_callback_handler.go
new file mode 100644
index 0000000..d9d7d6d
--- /dev/null
+++ b/backend/internal/handler/threads_account/threads_oauth_callback_handler.go
@@ -0,0 +1,29 @@
+package threads_account
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func ThreadsOauthCallbackHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ThreadsOAuthCallbackQuery
+ if err := httpx.Parse(r, &req); err != nil {
+ http.Redirect(w, r, threads_account.OAuthFailureRedirect(svcCtx.Config.ThreadsOAuth.SuccessRedirect, err.Error()), http.StatusFound)
+ return
+ }
+
+ l := threads_account.NewThreadsOAuthCallbackLogic(r.Context(), svcCtx)
+ redirectURL, err := l.ThreadsOAuthCallback(&req)
+ if err != nil {
+ http.Redirect(w, r, threads_account.OAuthFailureRedirect(svcCtx.Config.ThreadsOAuth.SuccessRedirect, err.Error()), http.StatusFound)
+ return
+ }
+ http.Redirect(w, r, redirectURL, http.StatusFound)
+ }
+}
diff --git a/backend/internal/handler/threads_account/upsert_publish_guard_policy_handler.go b/backend/internal/handler/threads_account/upsert_publish_guard_policy_handler.go
new file mode 100644
index 0000000..95eeb81
--- /dev/null
+++ b/backend/internal/handler/threads_account/upsert_publish_guard_policy_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func UpsertPublishGuardPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.UpsertPublishGuardPolicyHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewUpsertPublishGuardPolicyLogic(r.Context(), svcCtx)
+ data, err := l.UpsertPublishGuardPolicy(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/handler/threads_account/upsert_publish_inventory_policy_handler.go b/backend/internal/handler/threads_account/upsert_publish_inventory_policy_handler.go
new file mode 100644
index 0000000..9e55ad4
--- /dev/null
+++ b/backend/internal/handler/threads_account/upsert_publish_inventory_policy_handler.go
@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "net/http"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+func UpsertPublishInventoryPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.UpsertPublishInventoryPolicyHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := threads_account.NewUpsertPublishInventoryPolicyLogic(r.Context(), svcCtx)
+ data, err := l.UpsertPublishInventoryPolicy(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/backend/internal/library/copymission/knowledge.go b/backend/internal/library/copymission/knowledge.go
index 80d79a3..d5cdbf1 100644
--- a/backend/internal/library/copymission/knowledge.go
+++ b/backend/internal/library/copymission/knowledge.go
@@ -385,4 +385,4 @@ func BuildResearchMapFromGraph(prev missionentity.ResearchMap, nodes []libkg.Nod
SelectedKnowledgeItems: selectedItems,
ResearchItems: ResearchItemsFromSources(sources),
}
-}
\ No newline at end of file
+}
diff --git a/backend/internal/library/copymission/knowledge_test.go b/backend/internal/library/copymission/knowledge_test.go
index b2f6b45..a7205df 100644
--- a/backend/internal/library/copymission/knowledge_test.go
+++ b/backend/internal/library/copymission/knowledge_test.go
@@ -118,4 +118,4 @@ func TestApplyDefaultNodeSelectionPreserving(t *testing.T) {
if !nodes[1].SelectedForScan {
t.Fatal("expected default selection on new pain node")
}
-}
\ No newline at end of file
+}
diff --git a/backend/internal/library/copymission/matrix_lock.go b/backend/internal/library/copymission/matrix_lock.go
index 0410f26..cb79cb9 100644
--- a/backend/internal/library/copymission/matrix_lock.go
+++ b/backend/internal/library/copymission/matrix_lock.go
@@ -52,4 +52,4 @@ return 0
_ = redis.Eval(ctx, script, []string{key}, holder).Err()
}()
return fn()
-}
\ No newline at end of file
+}
diff --git a/backend/internal/library/copymission/reference_urls.go b/backend/internal/library/copymission/reference_urls.go
index 1f32578..f81ab01 100644
--- a/backend/internal/library/copymission/reference_urls.go
+++ b/backend/internal/library/copymission/reference_urls.go
@@ -63,4 +63,4 @@ func normalizeURLKey(raw string) string {
return ""
}
return strings.ToLower(strings.TrimRight(u, "/"))
-}
\ No newline at end of file
+}
diff --git a/backend/internal/library/copymission/reference_urls_test.go b/backend/internal/library/copymission/reference_urls_test.go
index 0a89518..8ca19f3 100644
--- a/backend/internal/library/copymission/reference_urls_test.go
+++ b/backend/internal/library/copymission/reference_urls_test.go
@@ -31,4 +31,4 @@ func TestReferenceURLsFromSelection(t *testing.T) {
if len(urls) != 1 || urls[0] != "https://example.com/a" {
t.Fatalf("urls = %v", urls)
}
-}
\ No newline at end of file
+}
diff --git a/backend/internal/library/copymission/research_items.go b/backend/internal/library/copymission/research_items.go
index 6aea779..9e726a7 100644
--- a/backend/internal/library/copymission/research_items.go
+++ b/backend/internal/library/copymission/research_items.go
@@ -76,4 +76,4 @@ func MergeReferenceURLs(selected []string, researchItems []ResearchSource) []str
out = append(out, raw)
}
return out
-}
\ No newline at end of file
+}
diff --git a/backend/internal/library/copymission/research_items_test.go b/backend/internal/library/copymission/research_items_test.go
index 99a57c4..8714b37 100644
--- a/backend/internal/library/copymission/research_items_test.go
+++ b/backend/internal/library/copymission/research_items_test.go
@@ -25,4 +25,4 @@ func TestFormatResearchItemsForMatrix(t *testing.T) {
if !strings.Contains(block, "備孕指南") || !strings.Contains(block, "葉酸補充重點") {
t.Fatalf("block = %q", block)
}
-}
\ No newline at end of file
+}
diff --git a/backend/internal/library/copymission/research_sources.go b/backend/internal/library/copymission/research_sources.go
index f43c633..ce5a8f2 100644
--- a/backend/internal/library/copymission/research_sources.go
+++ b/backend/internal/library/copymission/research_sources.go
@@ -16,4 +16,4 @@ func ResearchSourcesFromSummaries(items []missiondomain.ResearchItemSummary) []R
})
}
return out
-}
\ No newline at end of file
+}
diff --git a/backend/internal/library/copyvoice/human.go b/backend/internal/library/copyvoice/human.go
new file mode 100644
index 0000000..ac3352b
--- /dev/null
+++ b/backend/internal/library/copyvoice/human.go
@@ -0,0 +1,50 @@
+package copyvoice
+
+import "strings"
+
+// SystemRules are shared instructions for human-like Threads copy generation.
+func SystemRules() string {
+ return strings.TrimSpace(`寫作目標:像真人在 Threads / Instagram 發文,不是在套模板。若有人設語言指紋,必須優先照它的說話習慣寫。
+
+敘事(比結構重要):
+- 依主題選內容型態:心情陪伴、知識整理、清單工具、互動提問、輕幽默共鳴等,不要每篇都同一套。
+- 可以寫具體場景,也可以先承接情緒或整理資訊;不要為了故事感硬編不存在的生活細節。
+- 開場、推進方式、收尾都要自然變化;禁止固定三段式、固定問句收尾、固定金句收束。
+
+仿寫原則:
+- 只學參考文的情緒力度、資訊密度與節奏,不學填空骨架或固定 hook 句型。
+- 禁止照抄參考句型,只換幾個名詞;要用人設語言重新說一次。
+- 篇幅、行數、標點、換行優先貼近人設語言指紋;沒有指紋時再參考原文篇幅。
+- 每一句都要推進情緒、資訊或理解;重複強調、無關鋪陳、廉價雞湯一律刪掉。
+
+禁止 AI 腔與複製感:
+- 不要:過度總結、硬寫強 hook、每篇都同一種 CTA、教科書定義、品牌口吻、客服腔。
+- 不要:為了像人設而硬塞人設標籤或興趣名詞。
+- 不要全域禁止某個口頭禪;若人設樣本常用「其實」「有時候」「後來」等自然轉折,可以使用,但不能每篇硬塞。`)
+}
+
+// PersonaCalibrationNote reminds the model how to use persona blocks.
+func PersonaCalibrationNote() string {
+ return "注意:人設定位決定誰在說,語言指紋決定怎麼說;請遵守段落節奏、標點換行、知識轉譯與 CTA 習慣,不要套固定模板。"
+}
+
+// NarrativeLens picks a storytelling angle from a stable seed (e.g. scan post id).
+func NarrativeLens(seed string) string {
+ lenses := []string{
+ "從一個具體場景開始講,再帶出感受",
+ "先坦白一件小事或窘況,再自然轉到觀點",
+ "像跟朋友聊天一樣開場,語氣可以鬆一點",
+ "從某個瞬間的畫面切入,再講後來怎麼想",
+ }
+ if strings.TrimSpace(seed) == "" {
+ return lenses[0]
+ }
+ sum := 0
+ for _, ch := range seed {
+ sum = sum*31 + int(ch)
+ }
+ if sum < 0 {
+ sum = -sum
+ }
+ return lenses[sum%len(lenses)]
+}
diff --git a/backend/internal/library/copyvoice/human_test.go b/backend/internal/library/copyvoice/human_test.go
new file mode 100644
index 0000000..ee9a620
--- /dev/null
+++ b/backend/internal/library/copyvoice/human_test.go
@@ -0,0 +1,21 @@
+package copyvoice
+
+import "testing"
+
+func TestNarrativeLensStableForSeed(t *testing.T) {
+ a := NarrativeLens("scan-post-1")
+ b := NarrativeLens("scan-post-1")
+ if a == "" || a != b {
+ t.Fatalf("NarrativeLens() = %q, %q", a, b)
+ }
+}
+
+func TestNarrativeLensDiffersAcrossSeeds(t *testing.T) {
+ seen := map[string]struct{}{}
+ for _, seed := range []string{"a", "b", "c", "d", "e", "f"} {
+ seen[NarrativeLens(seed)] = struct{}{}
+ }
+ if len(seen) < 2 {
+ t.Fatalf("expected varied lenses, got %d", len(seen))
+ }
+}
diff --git a/backend/internal/library/embedding/client.go b/backend/internal/library/embedding/client.go
index 097d8df..2754e4e 100644
--- a/backend/internal/library/embedding/client.go
+++ b/backend/internal/library/embedding/client.go
@@ -1,3 +1,6 @@
+// Package embedding provides an optional OpenAI embeddings client.
+// It is intentionally not wired into patrol/graph flows; relevance uses
+// offline lexical intent scoring in knowledge/semantic_fit.go instead.
package embedding
import (
diff --git a/backend/internal/library/formula/analyze_pasted.go b/backend/internal/library/formula/analyze_pasted.go
new file mode 100644
index 0000000..af422e7
--- /dev/null
+++ b/backend/internal/library/formula/analyze_pasted.go
@@ -0,0 +1,163 @@
+package formula
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strings"
+
+ libllmjson "haixun-backend/internal/library/llmjson"
+ libownpost "haixun-backend/internal/library/ownpost"
+ domai "haixun-backend/internal/model/ai/domain/usecase"
+)
+
+var pastedFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
+
+type AnalyzePastedInput struct {
+ PostText string
+ AuthorName string
+ PersonaBlock string
+ LikeCount int
+ ReplyCount int
+}
+
+type PastedAnalysis struct {
+ Summary string `json:"summary"`
+ Wins []string `json:"wins"`
+ Improvements []string `json:"improvements"`
+ Formula string `json:"formula"`
+ PostTemplate string `json:"post_template"`
+ HookPattern string `json:"hook_pattern"`
+ Structure string `json:"structure"`
+ ReplicationTips []string `json:"replication_tips"`
+ Avoid []string `json:"avoid"`
+}
+
+func BuildAnalyzePastedPrompt(in AnalyzePastedInput) (system string, user string) {
+ personaBlock := strings.TrimSpace(in.PersonaBlock)
+ if personaBlock == "" {
+ personaBlock = "(未指定人設,請依貼文本身語氣分析)"
+ }
+ postText := strings.TrimSpace(in.PostText)
+ if postText == "" {
+ postText = "(貼文內容未提供)"
+ }
+ author := strings.TrimSpace(in.AuthorName)
+ if author == "" {
+ author = "匿名"
+ }
+
+ system = strings.TrimSpace(`
+你是 Threads / Instagram 貼文結構分析師。任務是拆解「外部貼文」哪些地方可借鏡,再說明如何用指定人設的語言指紋重新說一次;不要產生會讓文案機械的填空模板。
+
+規則:
+- 用台灣繁體中文。
+- formula:整理可借鏡的敘事/資訊推進方式,但要提醒不可照搬句型。
+- post_template:不要寫填空骨架;改寫成「此人設可採用的鬆結構與注意事項」。
+- replication_tips:重點放在如何換成人設語言,包括段落節奏、標點換行、知識轉譯與 CTA。
+- 只輸出一個 JSON 物件,不要 markdown,欄位:
+ summary, wins, improvements, formula, post_template, hook_pattern, structure, replication_tips, avoid
+- formula / structure / hook_pattern 必須是字串,不要用陣列或巢狀物件
+- wins / improvements / replication_tips / avoid 各 2~5 點字串陣列。`)
+
+ var b strings.Builder
+ b.WriteString("【人設脈絡】\n")
+ b.WriteString(personaBlock)
+ b.WriteString("\n\n【貼文】\n作者:")
+ b.WriteString(author)
+ if in.LikeCount > 0 || in.ReplyCount > 0 {
+ b.WriteString(fmt.Sprintf(" · %d 讚 · %d 留言", in.LikeCount, in.ReplyCount))
+ }
+ b.WriteString("\n")
+ b.WriteString(postText)
+ b.WriteString("\n\n請分析爆款結構並輸出 JSON。")
+ user = b.String()
+ return system, user
+}
+
+func AnalyzePasted(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in AnalyzePastedInput) (*PastedAnalysis, error) {
+ system, user := BuildAnalyzePastedPrompt(in)
+ temp := 0.65
+ req.System = system
+ req.Messages = []domai.Message{{Role: "user", Content: user}}
+ req.Temperature = &temp
+ out, err := ai.GenerateText(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ return ParsePastedAnalysis(out.Text)
+}
+
+func ParsePastedAnalysis(raw string) (*PastedAnalysis, error) {
+ payload, err := extractPastedJSON(raw)
+ if err != nil {
+ return nil, err
+ }
+ var flex map[string]any
+ if err := libllmjson.Unmarshal(payload, &flex); err != nil {
+ return nil, fmt.Errorf("parse pasted formula json: %w", err)
+ }
+ review := pastedAnalysisFromMap(flex)
+ if review.Summary == "" && review.Formula == "" && review.PostTemplate == "" {
+ return nil, fmt.Errorf("AI 未回傳有效公式內容")
+ }
+ return review, nil
+}
+
+func FromOwnPostReview(review *libownpost.PostFormulaReview) *PastedAnalysis {
+ if review == nil {
+ return nil
+ }
+ return &PastedAnalysis{
+ Summary: review.Summary,
+ Wins: review.Wins,
+ Improvements: review.Improvements,
+ Formula: review.Formula,
+ PostTemplate: review.PostTemplate,
+ HookPattern: review.HookPattern,
+ Structure: review.Structure,
+ ReplicationTips: review.ReplicationTips,
+ Avoid: review.Avoid,
+ }
+}
+
+func FromViralAnalysis(hook, structure, emotion, strategy string, takeaways []string) *PastedAnalysis {
+ summary := strings.TrimSpace(emotion)
+ if summary == "" {
+ summary = "爆款結構分析"
+ }
+ formula := strings.TrimSpace(strategy)
+ if structure != "" {
+ if formula != "" {
+ formula = structure + " → " + formula
+ } else {
+ formula = structure
+ }
+ }
+ template := ""
+ if hook != "" && structure != "" {
+ template = "[" + hook + "] → [" + structure + "] → [你的觀點] → [互動收尾]"
+ }
+ return &PastedAnalysis{
+ Summary: summary,
+ Wins: takeaways,
+ Formula: formula,
+ PostTemplate: template,
+ HookPattern: hook,
+ Structure: structure,
+ ReplicationTips: takeaways,
+ }
+}
+
+func extractPastedJSON(raw string) ([]byte, error) {
+ raw = strings.TrimSpace(raw)
+ if m := pastedFenceRE.FindStringSubmatch(raw); len(m) == 2 {
+ raw = strings.TrimSpace(m[1])
+ }
+ start := strings.Index(raw, "{")
+ end := strings.LastIndex(raw, "}")
+ if start < 0 || end <= start {
+ return nil, fmt.Errorf("formula output missing json object")
+ }
+ return []byte(raw[start : end+1]), nil
+}
diff --git a/backend/internal/library/formula/generate_from_formula.go b/backend/internal/library/formula/generate_from_formula.go
new file mode 100644
index 0000000..200db66
--- /dev/null
+++ b/backend/internal/library/formula/generate_from_formula.go
@@ -0,0 +1,148 @@
+package formula
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strings"
+
+ "haixun-backend/internal/library/copyvoice"
+ libllmjson "haixun-backend/internal/library/llmjson"
+ "haixun-backend/internal/library/threadspost"
+ domai "haixun-backend/internal/model/ai/domain/usecase"
+ cfdom "haixun-backend/internal/model/content_formula/domain/usecase"
+)
+
+var generatedDraftFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
+
+type GenerateInput struct {
+ Topic string
+ Brief string
+ PersonaBlock string
+ ResearchNotes string
+ Formula cfdom.FormulaSummary
+}
+
+type GeneratedDraft struct {
+ Text string `json:"text"`
+ TopicTag string `json:"topic_tag"`
+ Hook string `json:"hook"`
+ Angle string `json:"angle"`
+ Rationale string `json:"rationale"`
+ StructureNotes string `json:"structure_notes"`
+}
+
+func BuildGenerateSystemPrompt() string {
+ return strings.TrimSpace(`你是 Threads / Instagram 貼文代筆。寫出符合人設語言指紋、可直接發布的完整新貼文,不是解釋公式,也不是填空模板。
+
+` + copyvoice.SystemRules() + `
+
+規則:
+- 用台灣繁體中文,符合人設語氣。
+- 正文素材以【主題】、【補充】、【周邊知識參考】為準;人設不得改變題材。
+- 人設定位決定誰在說,語言指紋決定怎麼說;必須遵守段落節奏、標點換行、知識轉譯與 CTA 習慣。
+- text 必須是一篇完整自然的 Threads 貼文,可直接複製發布。
+- 不可以出現「公式、模板、Hook、情境、轉折、觀點、收尾、[括號]、步驟、結構」等後台分析字眼。
+- 不可以輸出填空骨架,不可以照抄參考原文。
+- 有參考原文時:只學資訊密度、情緒力度與可用結構;最後仍要換成人設會講出口的話。
+- text 排版:像真人手機發文;可使用自然標點、空行、列點與少量 emoji。不要完全無標點,也不要每句硬換行,除非人設樣本就是這樣。
+- topic_tag 請產生 1 個適合 Threads 主題標籤的短詞,不含 #,例如「AI工具」「內容創作」「職場」。
+- 只回傳 JSON:text, topic_tag, hook, angle, rationale, structure_notes(繁體中文)。`)
+}
+
+func BuildGenerateUserPrompt(in GenerateInput) string {
+ var b strings.Builder
+ b.WriteString("【主題】\n")
+ b.WriteString(strings.TrimSpace(in.Topic))
+ if brief := strings.TrimSpace(in.Brief); brief != "" {
+ b.WriteString("\n補充:")
+ b.WriteString(brief)
+ }
+ b.WriteString("\n\n【人設定位、語言指紋與禁忌(最高優先)】\n")
+ b.WriteString(strings.TrimSpace(in.PersonaBlock))
+ b.WriteString("\n")
+ b.WriteString(copyvoice.PersonaCalibrationNote())
+ b.WriteString("\n\n【這篇爆文的故事感(只感受情緒與敘事,禁止照搬句型或填空)】\n")
+ b.WriteString(strings.TrimSpace(in.Formula.Formula))
+ if structure := strings.TrimSpace(in.Formula.Structure); structure != "" {
+ b.WriteString("\n\n敘事推進參考(勿照搬句型):")
+ b.WriteString(structure)
+ }
+ if len(in.Formula.Avoid) > 0 {
+ b.WriteString("\n\n【避免】\n")
+ b.WriteString(strings.Join(in.Formula.Avoid, "\n"))
+ }
+ if notes := strings.TrimSpace(in.ResearchNotes); notes != "" {
+ b.WriteString("\n\n【周邊知識參考】\n")
+ b.WriteString(notes)
+ }
+ if src := strings.TrimSpace(in.Formula.SourcePostText); src != "" {
+ if note := threadspost.MimicLengthGuidance(src); note != "" {
+ b.WriteString("\n\n")
+ b.WriteString(note)
+ }
+ b.WriteString("\n\n【參考原文(僅學結構與篇幅,勿抄內容)】\n")
+ b.WriteString(src)
+ }
+ b.WriteString("\n\n本次敘事切入:")
+ b.WriteString(copyvoice.NarrativeLens(in.Topic))
+ b.WriteString("\n\n請產出一篇可直接發布的 Threads 貼文 JSON。text 欄位只能放貼文正文,不要放分析、標題、公式、模板或填空欄位。topic_tag 請只放標籤文字,不要加 #。")
+ return b.String()
+}
+
+func GenerateDraft(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in GenerateInput) (*GeneratedDraft, error) {
+ temp := 0.82
+ req.System = BuildGenerateSystemPrompt()
+ req.Messages = []domai.Message{{Role: "user", Content: BuildGenerateUserPrompt(in)}}
+ req.Temperature = &temp
+ out, err := ai.GenerateText(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ return ParseGeneratedDraft(out.Text)
+}
+
+func extractGeneratedDraftJSON(raw string) ([]byte, error) {
+ raw = strings.TrimSpace(raw)
+ if m := generatedDraftFenceRE.FindStringSubmatch(raw); len(m) == 2 {
+ raw = strings.TrimSpace(m[1])
+ }
+ start := strings.Index(raw, "{")
+ end := strings.LastIndex(raw, "}")
+ if start < 0 || end <= start {
+ return nil, fmt.Errorf("generate output missing json object")
+ }
+ return []byte(raw[start : end+1]), nil
+}
+
+func ParseGeneratedDraft(raw string) (*GeneratedDraft, error) {
+ payload, err := extractGeneratedDraftJSON(raw)
+ if err != nil {
+ return nil, err
+ }
+ var draft GeneratedDraft
+ if err := libllmjson.Unmarshal(payload, &draft); err != nil {
+ return nil, fmt.Errorf("parse generated draft: %w", err)
+ }
+ draft.Text = threadspost.FormatDraftText(draft.Text)
+ draft.TopicTag = normalizeTopicTag(draft.TopicTag)
+ if draft.Text == "" {
+ return nil, fmt.Errorf("generated draft missing text")
+ }
+ badTokens := []string{"[", "]", "【", "】", "Hook", "公式", "模板", "情境→", "轉折→", "觀點→", "收尾", "步驟"}
+ for _, token := range badTokens {
+ if strings.Contains(draft.Text, token) {
+ return nil, fmt.Errorf("generated draft looks like a template, not a publishable post")
+ }
+ }
+ return &draft, nil
+}
+
+func normalizeTopicTag(value string) string {
+ value = strings.TrimSpace(strings.TrimPrefix(value, "#"))
+ if len([]rune(value)) > 40 {
+ runes := []rune(value)
+ value = string(runes[:40])
+ }
+ return value
+}
diff --git a/backend/internal/library/formula/generate_from_formula_test.go b/backend/internal/library/formula/generate_from_formula_test.go
new file mode 100644
index 0000000..94f0745
--- /dev/null
+++ b/backend/internal/library/formula/generate_from_formula_test.go
@@ -0,0 +1,14 @@
+package formula
+
+import "testing"
+
+func TestParseGeneratedDraftWithCodeFence(t *testing.T) {
+ raw := "```json\n{\"text\":\"第一句\\n第二句\",\"topic_tag\":\"AI工具\",\"hook\":\"\",\"angle\":\"\",\"rationale\":\"\",\"structure_notes\":\"\"}\n```"
+ got, err := ParseGeneratedDraft(raw)
+ if err != nil {
+ t.Fatalf("ParseGeneratedDraft() err = %v", err)
+ }
+ if got.Text == "" {
+ t.Fatal("expected text")
+ }
+}
diff --git a/backend/internal/library/formula/jsonflex.go b/backend/internal/library/formula/jsonflex.go
new file mode 100644
index 0000000..0da094c
--- /dev/null
+++ b/backend/internal/library/formula/jsonflex.go
@@ -0,0 +1,95 @@
+package formula
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+func coerceString(value any) string {
+ switch v := value.(type) {
+ case nil:
+ return ""
+ case string:
+ return strings.TrimSpace(v)
+ case json.Number:
+ return strings.TrimSpace(v.String())
+ case float64:
+ if v == float64(int64(v)) {
+ return fmt.Sprintf("%d", int64(v))
+ }
+ return fmt.Sprintf("%v", v)
+ case bool:
+ if v {
+ return "true"
+ }
+ return "false"
+ case []any:
+ parts := make([]string, 0, len(v))
+ for _, item := range v {
+ if s := coerceString(item); s != "" {
+ parts = append(parts, s)
+ }
+ }
+ return strings.Join(parts, " → ")
+ case map[string]any:
+ if text, ok := v["text"].(string); ok {
+ return strings.TrimSpace(text)
+ }
+ b, err := json.Marshal(v)
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(b))
+ default:
+ return strings.TrimSpace(fmt.Sprint(v))
+ }
+}
+
+func coerceStringSlice(value any) []string {
+ switch v := value.(type) {
+ case nil:
+ return nil
+ case string:
+ s := strings.TrimSpace(v)
+ if s == "" {
+ return nil
+ }
+ return []string{s}
+ case []any:
+ out := make([]string, 0, len(v))
+ for _, item := range v {
+ if s := coerceString(item); s != "" {
+ out = append(out, s)
+ }
+ }
+ return out
+ case []string:
+ out := make([]string, 0, len(v))
+ for _, item := range v {
+ if s := strings.TrimSpace(item); s != "" {
+ out = append(out, s)
+ }
+ }
+ return out
+ default:
+ if s := coerceString(v); s != "" {
+ return []string{s}
+ }
+ return nil
+ }
+}
+
+func pastedAnalysisFromMap(raw map[string]any) *PastedAnalysis {
+ return &PastedAnalysis{
+ Summary: coerceString(raw["summary"]),
+ Wins: coerceStringSlice(raw["wins"]),
+ Improvements: coerceStringSlice(raw["improvements"]),
+ Formula: coerceString(raw["formula"]),
+ PostTemplate: coerceString(raw["post_template"]),
+ HookPattern: coerceString(raw["hook_pattern"]),
+ Structure: coerceString(raw["structure"]),
+ ReplicationTips: coerceStringSlice(raw["replication_tips"]),
+ Avoid: coerceStringSlice(raw["avoid"]),
+ }
+}
diff --git a/backend/internal/library/formula/jsonflex_test.go b/backend/internal/library/formula/jsonflex_test.go
new file mode 100644
index 0000000..f34169e
--- /dev/null
+++ b/backend/internal/library/formula/jsonflex_test.go
@@ -0,0 +1,22 @@
+package formula
+
+import "testing"
+
+func TestCoerceStringFromArray(t *testing.T) {
+ got := coerceString([]any{"開場", "故事", "收尾"})
+ want := "開場 → 故事 → 收尾"
+ if got != want {
+ t.Fatalf("coerceString() = %q, want %q", got, want)
+ }
+}
+
+func TestParsePastedAnalysisFlexibleStructure(t *testing.T) {
+ raw := `{"summary":"測試","formula":"步驟","structure":["Hook","轉折","觀點"],"wins":["節奏好"]}`
+ got, err := ParsePastedAnalysis(raw)
+ if err != nil {
+ t.Fatalf("ParsePastedAnalysis() err = %v", err)
+ }
+ if got.Structure != "Hook → 轉折 → 觀點" {
+ t.Fatalf("Structure = %q", got.Structure)
+ }
+}
diff --git a/backend/internal/library/knowledge/graph.go b/backend/internal/library/knowledge/graph.go
index bacb474..3a220e7 100644
--- a/backend/internal/library/knowledge/graph.go
+++ b/backend/internal/library/knowledge/graph.go
@@ -80,6 +80,32 @@ func TotalTagCandidates(nodes []Node) int {
return count
}
+// PreserveNodeSelectionByLabel reapplies user scan selections after graph regen changes node IDs.
+func PreserveNodeSelectionByLabel(nodes []Node, previous []Node) {
+ if len(nodes) == 0 || len(previous) == 0 {
+ return
+ }
+ selected := map[string]struct{}{}
+ for _, node := range previous {
+ if !node.SelectedForScan {
+ continue
+ }
+ label := strings.ToLower(strings.TrimSpace(node.Label))
+ if label != "" {
+ selected[label] = struct{}{}
+ }
+ }
+ if len(selected) == 0 {
+ return
+ }
+ for i := range nodes {
+ label := strings.ToLower(strings.TrimSpace(nodes[i].Label))
+ if _, ok := selected[label]; ok {
+ nodes[i].SelectedForScan = true
+ }
+ }
+}
+
func NodeByID(nodes []Node, id string) (Node, bool) {
id = strings.TrimSpace(id)
for _, node := range nodes {
diff --git a/backend/internal/library/knowledge/graph_rescore.go b/backend/internal/library/knowledge/graph_rescore.go
new file mode 100644
index 0000000..ee00c6c
--- /dev/null
+++ b/backend/internal/library/knowledge/graph_rescore.go
@@ -0,0 +1,110 @@
+package knowledge
+
+// RescoreGraphIntentFit recomputes node productFitScore using:
+// 1. weighted intent similarity (product + research map)
+// 2. graph proximity propagation from high-fit core nodes
+// 3. tangential-topic penalty
+func RescoreGraphIntentFit(graph *Graph, in PatrolTagInput) {
+ if graph == nil || len(graph.Nodes) == 0 {
+ return
+ }
+ profile := BuildIntentProfile(in)
+ intentScores := make([]int, len(graph.Nodes))
+ for i, node := range graph.Nodes {
+ intentScores[i] = ScoreIntentSimilarity(NodeIntentText(node), profile)
+ }
+ propagated := propagateIntentAlongGraph(graph.Nodes, graph.Edges, intentScores)
+
+ for i := range graph.Nodes {
+ node := &graph.Nodes[i]
+ semantic := intentScores[i]
+ if propagated[i] > semantic {
+ semantic = propagated[i]
+ }
+ llmScore := node.ProductFitScore
+ if llmScore <= 0 {
+ llmScore = defaultProductFit(node.NodeKind, node.Layer)
+ }
+
+ layerBlend := 0.5 + float64(minInt(node.Layer, 3))*0.08
+ blended := int(float64(semantic)*layerBlend + float64(llmScore)*(1.0-layerBlend))
+ if IsTangentialToIntent(NodeIntentText(*node), profile) {
+ blended = minInt(blended, 28)
+ }
+ if blended < 0 {
+ blended = 0
+ }
+ if blended > 100 {
+ blended = 100
+ }
+ node.ProductFitScore = blended
+ }
+}
+
+func propagateIntentAlongGraph(nodes []Node, edges []Edge, intentScores []int) []int {
+ if len(nodes) == 0 {
+ return nil
+ }
+ idIndex := map[string]int{}
+ for i, node := range nodes {
+ idIndex[node.ID] = i
+ }
+ adj := make([][]int, len(nodes))
+ addEdge := func(from, to string) {
+ fi, okFrom := idIndex[from]
+ ti, okTo := idIndex[to]
+ if !okFrom || !okTo || fi == ti {
+ return
+ }
+ adj[fi] = append(adj[fi], ti)
+ adj[ti] = append(adj[ti], fi)
+ }
+ for _, edge := range edges {
+ addEdge(edge.From, edge.To)
+ }
+
+ out := make([]int, len(nodes))
+ copy(out, intentScores)
+ visited := make([]bool, len(nodes))
+ type queueItem struct {
+ idx int
+ dist int
+ }
+ queue := make([]queueItem, 0, len(nodes))
+ for i, score := range intentScores {
+ if score >= 55 || nodes[i].Layer == 0 {
+ queue = append(queue, queueItem{idx: i, dist: 0})
+ visited[i] = true
+ }
+ }
+ for len(queue) > 0 {
+ item := queue[0]
+ queue = queue[1:]
+ if item.dist >= 3 {
+ continue
+ }
+ base := intentScores[item.idx]
+ if base <= 0 {
+ base = out[item.idx]
+ }
+ decayed := int(float64(base) * powDecay(0.74, item.dist+1))
+ for _, next := range adj[item.idx] {
+ if decayed > out[next] {
+ out[next] = decayed
+ }
+ if !visited[next] {
+ visited[next] = true
+ queue = append(queue, queueItem{idx: next, dist: item.dist + 1})
+ }
+ }
+ }
+ return out
+}
+
+func powDecay(base float64, exp int) float64 {
+ out := 1.0
+ for i := 0; i < exp; i++ {
+ out *= base
+ }
+ return out
+}
diff --git a/backend/internal/library/knowledge/graph_selection_test.go b/backend/internal/library/knowledge/graph_selection_test.go
new file mode 100644
index 0000000..f80a884
--- /dev/null
+++ b/backend/internal/library/knowledge/graph_selection_test.go
@@ -0,0 +1,21 @@
+package knowledge
+
+import "testing"
+
+func TestPreserveNodeSelectionByLabel(t *testing.T) {
+ previous := []Node{
+ {ID: "old-1", Label: "化療後 皮膚乾", SelectedForScan: true},
+ {ID: "old-2", Label: "無關節點", SelectedForScan: false},
+ }
+ nodes := []Node{
+ {ID: "new-1", Label: "化療後 皮膚乾"},
+ {ID: "new-2", Label: "新痛點"},
+ }
+ PreserveNodeSelectionByLabel(nodes, previous)
+ if !nodes[0].SelectedForScan {
+ t.Fatal("expected first node selection to be preserved by label")
+ }
+ if nodes[1].SelectedForScan {
+ t.Fatal("expected unrelated node to stay unselected")
+ }
+}
diff --git a/backend/internal/library/knowledge/patrol_input.go b/backend/internal/library/knowledge/patrol_input.go
index 5acafaf..5da4c30 100644
--- a/backend/internal/library/knowledge/patrol_input.go
+++ b/backend/internal/library/knowledge/patrol_input.go
@@ -13,6 +13,7 @@ type PatrolTagInput struct {
ProductName string
ProductFeatures string
AudienceSummary string
+ TargetAudience string
MatchTags []string
Questions []string
Pillars []string
@@ -65,6 +66,7 @@ func PatrolTagInputFromBrand(brand *branddomain.BrandSummary, productBrief strin
ProductName: productName,
ProductFeatures: features,
AudienceSummary: strings.TrimSpace(brand.ResearchMap.AudienceSummary),
+ TargetAudience: strings.TrimSpace(brand.TargetAudience),
MatchTags: matchTagsFromBrand(brand),
Questions: append([]string{}, brand.ResearchMap.Questions...),
Pillars: append([]string{}, brand.ResearchMap.Pillars...),
@@ -72,6 +74,23 @@ func PatrolTagInputFromBrand(brand *branddomain.BrandSummary, productBrief strin
}
}
+// OverlayResearchMap copies topic-level research map fields onto patrol input.
+func OverlayResearchMap(in PatrolTagInput, audienceSummary string, questions, pillars, patrolKeywords []string) PatrolTagInput {
+ if strings.TrimSpace(audienceSummary) != "" {
+ in.AudienceSummary = strings.TrimSpace(audienceSummary)
+ }
+ if len(questions) > 0 {
+ in.Questions = append([]string{}, questions...)
+ }
+ if len(pillars) > 0 {
+ in.Pillars = append([]string{}, pillars...)
+ }
+ if len(patrolKeywords) > 0 {
+ in.PatrolKeywords = append([]string{}, patrolKeywords...)
+ }
+ return in
+}
+
func productLabelFromBrand(brand *branddomain.BrandSummary) string {
if brand == nil {
return ""
@@ -134,9 +153,33 @@ func (in PatrolTagInput) HasProductContext() bool {
len(in.MatchTags) > 0
}
+var patrolCoreValueTokens = []string{
+ "敏感", "無香", "抗敏", "低敏", "香精", "香料", "香味", "過敏", "皮膚",
+ "化療", "癌症", "乳癌", "荷爾蒙", "標靶", "病友",
+}
+
+func primaryPatrolValueAnchor(in PatrolTagInput) string {
+ blob := strings.ToLower(strings.TrimSpace(in.ProductName + " " + in.ProductFeatures + " " +
+ strings.Join(in.MatchTags, " ") + " " + strings.Join(in.Pillars, " ") + " " +
+ in.AudienceSummary + " " + in.TargetAudience))
+ priority := []string{"無香", "抗敏", "敏感", "化療", "癌症", "乳癌", "荷爾蒙", "過敏", "香味", "香精"}
+ for _, token := range priority {
+ if strings.Contains(blob, token) {
+ return token
+ }
+ }
+ for _, token := range patrolCoreValueTokens {
+ if strings.Contains(blob, token) {
+ return token
+ }
+ }
+ return ""
+}
+
func productCategoryHint(productName, features string) string {
combined := productName + " " + features
for _, hint := range []string{
+ "洗衣精", "洗衣粉", "衣物", "洗碗精", "洗碗機", "碗盤",
"沐浴乳", "沐浴露", "洗髮精", "洗髮乳", "洗面乳", "洗面奶", "乳液", "精華",
"防曬", "卸妝", "潔面", "身體乳", "洗手乳", "清潔",
} {
diff --git a/backend/internal/library/knowledge/patrol_phrase.go b/backend/internal/library/knowledge/patrol_phrase.go
index b4f7308..05c4927 100644
--- a/backend/internal/library/knowledge/patrol_phrase.go
+++ b/backend/internal/library/knowledge/patrol_phrase.go
@@ -162,6 +162,48 @@ func splitPatrolChunks(text string) []string {
return []string{head, tail}
}
+// AnchorPatrolTagToProduct injects product category tokens when a tag lacks product context.
+func AnchorPatrolTagToProduct(tag string, in PatrolTagInput) string {
+ tag = strings.TrimSpace(tag)
+ if tag == "" {
+ return ""
+ }
+ hint := productCategoryHint(in.ProductName, in.ProductFeatures)
+ if hint == "" {
+ for _, matchTag := range in.MatchTags {
+ if h := productCategoryHint(matchTag, ""); h != "" {
+ hint = h
+ break
+ }
+ }
+ }
+ if hint == "" {
+ return tag
+ }
+ lowerTag := strings.ToLower(tag)
+ if strings.Contains(lowerTag, strings.ToLower(hint)) {
+ return tag
+ }
+ for _, matchTag := range in.MatchTags {
+ matchTag = strings.TrimSpace(strings.ToLower(matchTag))
+ if len([]rune(matchTag)) >= 2 && strings.Contains(lowerTag, matchTag) {
+ return tag
+ }
+ }
+ anchor := hint
+ if valueAnchor := primaryPatrolValueAnchor(in); valueAnchor != "" {
+ anchor = strings.TrimSpace(valueAnchor + " " + hint)
+ }
+ anchored := strings.TrimSpace(anchor + " " + tag)
+ if utf8.RuneCountInString(anchored) > maxPatrolTagRunes {
+ anchored = truncateRunes(anchor+" "+compressPatrolKeywords(tag), maxPatrolTagRunes)
+ }
+ if utf8.RuneCountInString(anchored) < minPatrolTagRunes {
+ return tag
+ }
+ return anchored
+}
+
func ensurePatrolIntent(phrase, intent string) string {
phrase = strings.TrimSpace(phrase)
if phrase == "" {
diff --git a/backend/internal/library/knowledge/patrol_phrase_test.go b/backend/internal/library/knowledge/patrol_phrase_test.go
index 9f942d2..e7bf3a1 100644
--- a/backend/internal/library/knowledge/patrol_phrase_test.go
+++ b/backend/internal/library/knowledge/patrol_phrase_test.go
@@ -1,6 +1,9 @@
package knowledge
-import "testing"
+import (
+ "strings"
+ "testing"
+)
func TestPatrolTagFromQuestionPreservesSearchPhrase(t *testing.T) {
got := PatrolTagFromQuestion("化療後皮膚敏感要換什麼沐浴乳")
@@ -9,6 +12,35 @@ func TestPatrolTagFromQuestionPreservesSearchPhrase(t *testing.T) {
}
}
+func TestAnchorPatrolTagToProductInjectsCategory(t *testing.T) {
+ in := PatrolTagInput{
+ ProductName: "天然洗衣精",
+ MatchTags: []string{"洗衣精", "衣物清潔"},
+ }
+ got := AnchorPatrolTagToProduct("敏感肌 推薦", in)
+ if got == "敏感肌 推薦" {
+ t.Fatal("expected product category to be injected into generic tag")
+ }
+ if !strings.Contains(got, "洗衣") {
+ t.Fatalf("expected laundry anchor in %q", got)
+ }
+}
+
+func TestAnchorPatrolTagToProductPrefersValueAnchor(t *testing.T) {
+ in := PatrolTagInput{
+ ProductName: "抗敏無香洗衣精",
+ ProductFeatures: "完全無香、抗敏、適合化療族群",
+ MatchTags: []string{"無香", "洗衣精"},
+ }
+ got := AnchorPatrolTagToProduct("皮膚癢 推薦", in)
+ if !strings.Contains(got, "無香") {
+ t.Fatalf("expected value anchor in %q", got)
+ }
+ if !strings.Contains(got, "洗衣精") {
+ t.Fatalf("expected product form in %q", got)
+ }
+}
+
func TestPatrolTagFromQuestionCompressesGenericLabel(t *testing.T) {
got := PatrolTagFromQuestion("敏感肌保養")
if got == "敏感肌保養" {
diff --git a/backend/internal/library/knowledge/patrol_rank.go b/backend/internal/library/knowledge/patrol_rank.go
index 9a8dc1e..7937570 100644
--- a/backend/internal/library/knowledge/patrol_rank.go
+++ b/backend/internal/library/knowledge/patrol_rank.go
@@ -5,7 +5,27 @@ import (
"strings"
)
-const MaxTopPatrolTags = 8
+const MaxTopPatrolTags = 10
+const MaxScanPatrolKeywords = 10
+
+// SelectedPatrolNodes returns nodes flagged for scan.
+func SelectedPatrolNodes(nodes []Node) []Node {
+ out := make([]Node, 0, len(nodes))
+ for _, node := range nodes {
+ if node.SelectedForScan {
+ out = append(out, node)
+ }
+ }
+ return out
+}
+
+// NodesForPatrolKeywordDerivation prefers user-selected graph nodes when present.
+func NodesForPatrolKeywordDerivation(nodes []Node) []Node {
+ if selected := SelectedPatrolNodes(nodes); len(selected) > 0 {
+ return selected
+ }
+ return nodes
+}
type PatrolTagCandidate struct {
Tag string
@@ -24,19 +44,20 @@ func BuildPatrolCandidates(in PatrolTagInput, nodes []Node) []PatrolTagCandidate
addPatrolCandidate(&out, tag, 120-i, "手動精選", patrolIntentRelevance)
}
for i, q := range in.Questions {
- addPatrolCandidate(&out, PatrolTagFromQuestion(q), scoreQuestion(i)+12, "受眾提問", patrolIntentRelevance)
+ addPatrolCandidate(&out, AnchorPatrolTagToProduct(PatrolTagFromQuestion(q), in), scoreQuestion(i)+12, "受眾提問", patrolIntentRelevance)
}
for i, pillar := range in.Pillars {
- addPatrolCandidate(&out, PatrolTagFromPillar(pillar), scorePillar(i)+6, "內容支柱", patrolIntentRelevance)
+ addPatrolCandidate(&out, AnchorPatrolTagToProduct(PatrolTagFromPillar(pillar), in), scorePillar(i)+6, "內容支柱", patrolIntentRelevance)
}
for i, matchTag := range in.MatchTags {
- addPatrolCandidate(&out, patrolTagFromSource(matchTag, patrolIntentRelevance), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
+ addPatrolCandidate(&out, AnchorPatrolTagToProduct(patrolTagFromSource(matchTag, patrolIntentRelevance), in), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
}
if in.ProductName != "" {
- addPatrolCandidate(&out, patrolTagFromSource(in.ProductName, patrolIntentRelevance), scoreProduct(), "置入產品", patrolIntentRelevance)
+ addPatrolCandidate(&out, AnchorPatrolTagToProduct(patrolTagFromSource(in.ProductName, patrolIntentRelevance), in), scoreProduct(), "置入產品", patrolIntentRelevance)
}
- for _, node := range nodes {
- if !IsPainNode(node) && node.Layer > 1 {
+ nodesToUse := NodesForPatrolKeywordDerivation(nodes)
+ for _, node := range nodesToUse {
+ if len(SelectedPatrolNodes(nodes)) == 0 && !IsPainNode(node) && node.Layer > 1 {
continue
}
nodeScore := scoreNode(node)
@@ -216,7 +237,7 @@ func scoreNode(node Node) int {
base += 8
}
if node.ProductFitScore > 0 {
- base += node.ProductFitScore / 6
+ base += node.ProductFitScore / 5
}
return base
}
diff --git a/backend/internal/library/knowledge/patrol_rank_test.go b/backend/internal/library/knowledge/patrol_rank_test.go
index 11533be..adfa421 100644
--- a/backend/internal/library/knowledge/patrol_rank_test.go
+++ b/backend/internal/library/knowledge/patrol_rank_test.go
@@ -13,9 +13,14 @@ func TestSelectTopPatrolTagsLimitsAndDedupes(t *testing.T) {
{Tag: "病友 沐浴乳", Score: 70, Reason: "周邊情境", Intent: patrolIntentRelevance},
{Tag: "抗敏 沐浴乳", Score: 65, Reason: "置入產品", Intent: patrolIntentRelevance},
{Tag: "香精 過敏", Score: 60, Reason: "周邊情境", Intent: patrolIntentRelevance},
+ {Tag: "標靶 治療 沐浴", Score: 58, Reason: "周邊情境", Intent: patrolIntentRelevance},
+ {Tag: "無香 沐浴乳 請問", Score: 56, Reason: "受眾提問", Intent: patrolIntentRelevance},
}, MaxTopPatrolTags)
- if len(tags) != MaxTopPatrolTags {
- t.Fatalf("expected %d tags, got %d: %v", MaxTopPatrolTags, len(tags), tags)
+ if len(tags) > MaxTopPatrolTags {
+ t.Fatalf("expected at most %d tags, got %d: %v", MaxTopPatrolTags, len(tags), tags)
+ }
+ if len(tags) < 8 {
+ t.Fatalf("expected at least 8 deduped tags, got %d: %v", len(tags), tags)
}
if tags[0] != "化療 沐浴乳" {
t.Fatalf("expected highest score first, got %q", tags[0])
diff --git a/backend/internal/library/knowledge/patrol_resolve.go b/backend/internal/library/knowledge/patrol_resolve.go
index 61b11a3..bdac43d 100644
--- a/backend/internal/library/knowledge/patrol_resolve.go
+++ b/backend/internal/library/knowledge/patrol_resolve.go
@@ -1,13 +1,16 @@
package knowledge
-// ResolveScanPatrolKeywords picks keywords for placement-scan in UI/API order:
-// explicit request > saved research map > auto-ranked from map + graph nodes.
+// ResolveScanPatrolKeywords picks the most relevant search phrases for the Search API.
+// Research map and graph nodes are inputs; UI display order is not preserved.
func ResolveScanPatrolKeywords(explicit, saved []string, input PatrolTagInput, nodes []Node) []string {
- if keywords := NormalizePatrolKeywordList(explicit); len(keywords) > 0 {
- return keywords
- }
- if keywords := NormalizePatrolKeywordList(saved); len(keywords) > 0 {
- return keywords
- }
- return NormalizePatrolKeywordList(CollectPatrolTagsFromGraph(input, nodes))
+ nodes = NodesForPatrolKeywordDerivation(nodes)
+ return SelectBestSearchKeywords(explicit, saved, input, nodes, MaxScanPatrolKeywords)
+}
+
+func capPatrolKeywordList(keywords []string, limit int) []string {
+ keywords = NormalizePatrolKeywordList(keywords)
+ if limit <= 0 || len(keywords) <= limit {
+ return keywords
+ }
+ return keywords[:limit]
}
diff --git a/backend/internal/library/knowledge/patrol_resolve_test.go b/backend/internal/library/knowledge/patrol_resolve_test.go
index 349b458..05af32f 100644
--- a/backend/internal/library/knowledge/patrol_resolve_test.go
+++ b/backend/internal/library/knowledge/patrol_resolve_test.go
@@ -2,15 +2,25 @@ package knowledge
import "testing"
-func TestResolveScanPatrolKeywordsPrefersExplicit(t *testing.T) {
+func TestResolveScanPatrolKeywordsIncludesExplicitHint(t *testing.T) {
got := ResolveScanPatrolKeywords(
[]string{"手動 關鍵字"},
[]string{"已儲存"},
PatrolTagInput{Questions: []string{"敏感肌 怎麼辦"}},
nil,
)
- if len(got) != 1 || got[0] != "手動 關鍵字" {
- t.Fatalf("ResolveScanPatrolKeywords() = %#v, want explicit keyword", got)
+ if len(got) == 0 {
+ t.Fatal("expected ranked keywords")
+ }
+ found := false
+ for _, kw := range got {
+ if kw == "手動 關鍵字" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("ResolveScanPatrolKeywords() = %#v, want explicit hint included", got)
}
}
@@ -22,3 +32,29 @@ func TestResolveScanPatrolKeywordsFromResearchMapWithoutGraph(t *testing.T) {
t.Fatal("expected research map questions to produce patrol keywords without graph nodes")
}
}
+
+func TestResolveScanPatrolKeywordsPrefersSelectedNodes(t *testing.T) {
+ nodes := []Node{
+ {ID: "pain", Label: "化療後 皮膚乾", SelectedForScan: true, ProductFitScore: 88, NodeKind: "pain", DerivedTags: DerivedTags{Relevance: []string{"化療後 皮膚乾 沐浴乳"}}},
+ {ID: "skip", Label: "無關節點", SelectedForScan: false, ProductFitScore: 90, DerivedTags: DerivedTags{Relevance: []string{"不該出現"}}},
+ }
+ got := ResolveScanPatrolKeywords(nil, nil, PatrolTagInput{}, nodes)
+ if len(got) == 0 {
+ t.Fatal("expected selected node to produce patrol keywords")
+ }
+ found := false
+ for _, kw := range got {
+ if kw == "化療後 皮膚乾 沐浴乳" || kw == "化療後 皮膚乾" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("expected selected node keyword, got %#v", got)
+ }
+ for _, kw := range got {
+ if kw == "不該出現" {
+ t.Fatalf("unselected node leaked into keywords: %#v", got)
+ }
+ }
+}
diff --git a/backend/internal/library/knowledge/patrol_search.go b/backend/internal/library/knowledge/patrol_search.go
new file mode 100644
index 0000000..aa08671
--- /dev/null
+++ b/backend/internal/library/knowledge/patrol_search.go
@@ -0,0 +1,82 @@
+package knowledge
+
+import (
+ "sort"
+ "strings"
+)
+
+// SelectBestSearchKeywords pools research map, graph, product, and optional hints,
+// then returns the most intent-relevant phrases for Search API (not UI display order).
+func SelectBestSearchKeywords(explicit, saved []string, in PatrolTagInput, nodes []Node, limit int) []string {
+ if limit <= 0 {
+ limit = MaxScanPatrolKeywords
+ }
+ profile := BuildIntentProfile(in)
+ candidates := BuildPatrolCandidates(in, nodes)
+
+ for i, tag := range NormalizePatrolKeywordList(explicit) {
+ addPatrolCandidate(&candidates, tag, 156-i, "掃描指定", patrolIntentRelevance)
+ }
+ for i, tag := range NormalizePatrolKeywordList(saved) {
+ if containsNormalizedTag(explicit, tag) {
+ continue
+ }
+ addPatrolCandidate(&candidates, tag, 88-i, "研究地圖儲存", patrolIntentRelevance)
+ }
+
+ type ranked struct {
+ tag string
+ score int
+ }
+ scored := make([]ranked, 0, len(candidates))
+ seen := map[string]struct{}{}
+ for _, item := range selectTopPatrolCandidates(candidates, len(candidates)+32) {
+ tag := strings.TrimSpace(item.Tag)
+ if tag == "" {
+ continue
+ }
+ key := patrolTagDedupeKey(tag)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ if IsTangentialToIntent(tag, profile) {
+ continue
+ }
+ intent := ScoreIntentSimilarity(tag, profile)
+ if intent < 8 && item.Score < 65 {
+ continue
+ }
+ final := (item.Score*2 + intent*4) / 6
+ if final <= 0 {
+ continue
+ }
+ scored = append(scored, ranked{tag: tag, score: final})
+ }
+
+ sort.SliceStable(scored, func(i, j int) bool {
+ if scored[i].score == scored[j].score {
+ return scored[i].tag < scored[j].tag
+ }
+ return scored[i].score > scored[j].score
+ })
+
+ out := make([]string, 0, limit)
+ for _, item := range scored {
+ out = append(out, item.tag)
+ if len(out) >= limit {
+ break
+ }
+ }
+ return out
+}
+
+func containsNormalizedTag(items []string, target string) bool {
+ targetKey := patrolTagDedupeKey(target)
+ for _, item := range items {
+ if patrolTagDedupeKey(item) == targetKey {
+ return true
+ }
+ }
+ return false
+}
diff --git a/backend/internal/library/knowledge/patrol_search_test.go b/backend/internal/library/knowledge/patrol_search_test.go
new file mode 100644
index 0000000..c5738ae
--- /dev/null
+++ b/backend/internal/library/knowledge/patrol_search_test.go
@@ -0,0 +1,58 @@
+package knowledge
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestSelectBestSearchKeywordsRanksByIntentNotSavedOrder(t *testing.T) {
+ in := PatrolTagInput{
+ ProductName: "抗敏無香洗衣精",
+ ProductFeatures: "完全無香、抗敏、適合化療族群",
+ MatchTags: []string{"無香", "抗敏", "洗衣精"},
+ Questions: []string{"化療後想找不會刺激的洗衣精"},
+ Pillars: []string{"無香洗衣", "化療後衣物清潔"},
+ }
+ got := SelectBestSearchKeywords(
+ nil,
+ []string{"洗衣機 推薦", "無香 洗衣精 請問"},
+ in,
+ nil,
+ 4,
+ )
+ if len(got) == 0 {
+ t.Fatal("expected ranked keywords")
+ }
+ for _, kw := range got {
+ if strings.Contains(kw, "洗衣機") {
+ t.Fatalf("expected weak saved keyword to lose ranking, got %#v", got)
+ }
+ }
+ found := false
+ for _, kw := range got {
+ if strings.Contains(kw, "無香") || strings.Contains(kw, "化療") {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("expected intent-aligned keyword in %#v", got)
+ }
+}
+
+func TestSelectBestSearchKeywordsIncludesExplicitHint(t *testing.T) {
+ in := PatrolTagInput{
+ ProductName: "抗敏無香洗衣精",
+ MatchTags: []string{"無香", "洗衣精"},
+ }
+ got := SelectBestSearchKeywords(
+ []string{"化療 無香 洗衣精"},
+ []string{"洗衣機 推薦"},
+ in,
+ nil,
+ 3,
+ )
+ if len(got) == 0 || got[0] != "化療 無香 洗衣精" {
+ t.Fatalf("expected explicit hint to rank first, got %#v", got)
+ }
+}
diff --git a/backend/internal/library/knowledge/patrol_tags.go b/backend/internal/library/knowledge/patrol_tags.go
index 08c2ea2..bee71c1 100644
--- a/backend/internal/library/knowledge/patrol_tags.go
+++ b/backend/internal/library/knowledge/patrol_tags.go
@@ -55,23 +55,23 @@ func deriveFromResearchMapAndProduct(node Node, in PatrolTagInput) DerivedTags {
matches := isCoreNode(node)
for i, q := range in.Questions {
- if matches || nodeMatchesResearchText(node, q) {
- addPatrolCandidate(&candidates, PatrolTagFromQuestion(q), scoreQuestion(i)+10, "受眾提問", patrolIntentRelevance)
- addPatrolCandidate(&candidates, patrolTagFromSource(q, patrolIntentRecency), scoreQuestion(i)-2, "受眾提問", patrolIntentRecency)
+ if matches || nodeMatchesResearchText(node, q, in) {
+ addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(PatrolTagFromQuestion(q), in), scoreQuestion(i)+10, "受眾提問", patrolIntentRelevance)
+ addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(q, patrolIntentRecency), in), scoreQuestion(i)-2, "受眾提問", patrolIntentRecency)
}
}
for i, pillar := range in.Pillars {
- if matches || nodeMatchesResearchText(node, pillar) {
- addPatrolCandidate(&candidates, PatrolTagFromPillar(pillar), scorePillar(i)+4, "內容支柱", patrolIntentRelevance)
+ if matches || nodeMatchesResearchText(node, pillar, in) {
+ addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(PatrolTagFromPillar(pillar), in), scorePillar(i)+4, "內容支柱", patrolIntentRelevance)
}
}
for i, matchTag := range in.MatchTags {
- if matches || nodeMatchesResearchText(node, matchTag) {
- addPatrolCandidate(&candidates, patrolTagFromSource(matchTag, patrolIntentRelevance), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
+ if matches || nodeMatchesResearchText(node, matchTag, in) {
+ addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(matchTag, patrolIntentRelevance), in), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
}
}
- if in.ProductName != "" && (matches || nodeMatchesResearchText(node, in.ProductName)) {
- addPatrolCandidate(&candidates, patrolTagFromSource(in.ProductName, patrolIntentRelevance), scoreProduct(), "置入產品", patrolIntentRelevance)
+ if in.ProductName != "" && (matches || nodeMatchesResearchText(node, in.ProductName, in)) {
+ addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(in.ProductName, patrolIntentRelevance), in), scoreProduct(), "置入產品", patrolIntentRelevance)
}
if len(candidates) == 0 && IsPainNode(node) {
@@ -79,16 +79,16 @@ func deriveFromResearchMapAndProduct(node Node, in PatrolTagInput) DerivedTags {
if i >= 2 {
break
}
- addPatrolCandidate(&candidates, PatrolTagFromQuestion(q), scoreQuestion(i)+8, "受眾提問", patrolIntentRelevance)
+ addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(PatrolTagFromQuestion(q), in), scoreQuestion(i)+8, "受眾提問", patrolIntentRelevance)
}
}
if len(candidates) == 0 && IsPainNode(node) {
- addPatrolCandidate(&candidates, patrolTagFromSource(in.ProductName, patrolIntentRelevance), scoreProduct(), "置入產品", patrolIntentRelevance)
+ addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(in.ProductName, patrolIntentRelevance), in), scoreProduct(), "置入產品", patrolIntentRelevance)
for i, matchTag := range in.MatchTags {
if i >= 1 {
break
}
- addPatrolCandidate(&candidates, patrolTagFromSource(matchTag, patrolIntentRelevance), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
+ addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(matchTag, patrolIntentRelevance), in), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
}
}
@@ -107,7 +107,7 @@ func isCoreNode(node Node) bool {
return node.Layer == 0 || strings.EqualFold(strings.TrimSpace(node.Type), "core")
}
-func nodeMatchesResearchText(node Node, text string) bool {
+func nodeMatchesResearchText(node Node, text string, in PatrolTagInput) bool {
label := strings.TrimSpace(node.Label)
text = strings.TrimSpace(text)
if label == "" || text == "" {
@@ -116,7 +116,12 @@ func nodeMatchesResearchText(node Node, text string) bool {
if label == text || strings.Contains(text, label) || strings.Contains(label, text) {
return true
}
- return sharesMeaningfulSubstring(label, text)
+ if sharesMeaningfulSubstring(label, text) {
+ return true
+ }
+ profile := BuildIntentProfile(in)
+ joined := strings.TrimSpace(NodeIntentText(node) + " " + text)
+ return ScoreIntentSimilarity(joined, profile) >= 40
}
func deriveFallbackPatrolTags(node Node) DerivedTags {
diff --git a/backend/internal/library/knowledge/research_map_fit.go b/backend/internal/library/knowledge/research_map_fit.go
new file mode 100644
index 0000000..2ed550e
--- /dev/null
+++ b/backend/internal/library/knowledge/research_map_fit.go
@@ -0,0 +1,59 @@
+package knowledge
+
+import (
+ "sort"
+ "strings"
+)
+
+// RankStringsByIntent sorts phrases by semantic proximity to product + research map.
+func RankStringsByIntent(items []string, in PatrolTagInput) []string {
+ if len(items) <= 1 {
+ return append([]string{}, items...)
+ }
+ profile := BuildIntentProfile(in)
+ type scored struct {
+ text string
+ score int
+ }
+ scores := make([]scored, 0, len(items))
+ for _, item := range items {
+ item = strings.TrimSpace(item)
+ if item == "" {
+ continue
+ }
+ scores = append(scores, scored{
+ text: item,
+ score: ScoreIntentSimilarity(item, profile),
+ })
+ }
+ sort.SliceStable(scores, func(i, j int) bool {
+ if scores[i].score == scores[j].score {
+ return scores[i].text < scores[j].text
+ }
+ return scores[i].score > scores[j].score
+ })
+ out := make([]string, 0, len(scores))
+ for _, item := range scores {
+ out = append(out, item.text)
+ }
+ return out
+}
+
+// FilterPatrolKeywordsByIntent drops low-intent patrol keywords from research map output.
+func FilterPatrolKeywordsByIntent(items []string, in PatrolTagInput, minScore int) []string {
+ if minScore <= 0 {
+ return RankStringsByIntent(items, in)
+ }
+ profile := BuildIntentProfile(in)
+ out := make([]string, 0, len(items))
+ for _, item := range items {
+ item = strings.TrimSpace(item)
+ if item == "" {
+ continue
+ }
+ if ScoreIntentSimilarity(item, profile) >= minScore {
+ out = append(out, item)
+ }
+ }
+ return out
+}
diff --git a/backend/internal/library/knowledge/search_keyword_shape.go b/backend/internal/library/knowledge/search_keyword_shape.go
new file mode 100644
index 0000000..2a617a4
--- /dev/null
+++ b/backend/internal/library/knowledge/search_keyword_shape.go
@@ -0,0 +1,122 @@
+package knowledge
+
+import (
+ "strings"
+ "unicode/utf8"
+)
+
+const (
+ maxThreadsSearchRunes = 14
+ maxWebRecencyCoreRunes = 12
+ maxWebRelevanceTagRunes = 16
+)
+
+var searchIntentMarkers = []string{"推薦", "請問", "怎麼辦", "好用嗎", "有人", "求助", "請益", "有推薦"}
+
+// ShortPatrolSearchCore compresses a patrol tag for Threads API / recency Search API.
+// Keeps pain + product tokens; drops filler and redundant intent words (RECENT/TOP handles freshness).
+func ShortPatrolSearchCore(tag string) string {
+ tag = strings.TrimSpace(tag)
+ if tag == "" {
+ return ""
+ }
+ tokens := selectSearchTokens(tag, 3)
+ if len(tokens) == 0 {
+ return truncateRunes(stripSearchIntent(tag), maxThreadsSearchRunes)
+ }
+ core := strings.Join(tokens, " ")
+ return truncateRunes(core, maxWebRecencyCoreRunes)
+}
+
+// ThreadsAPIKeyword is the plain keyword sent to official Threads keyword search.
+func ThreadsAPIKeyword(tag string) string {
+ core := ShortPatrolSearchCore(tag)
+ if core != "" {
+ return core
+ }
+ return truncateRunes(stripSearchIntent(tag), maxThreadsSearchRunes)
+}
+
+// FullPatrolWebSearchTag keeps a slightly longer phrase for relevance Search API (exact-match quotes).
+func FullPatrolWebSearchTag(tag string) string {
+ tag = strings.TrimSpace(tag)
+ if tag == "" {
+ return ""
+ }
+ if !hasSearchIntentMarker(tag) {
+ candidate := strings.TrimSpace(tag + " 推薦")
+ if utf8.RuneCountInString(candidate) <= maxWebRelevanceTagRunes {
+ tag = candidate
+ }
+ }
+ if utf8.RuneCountInString(tag) <= maxWebRelevanceTagRunes {
+ return tag
+ }
+ tokens := selectSearchTokens(tag, 4)
+ if len(tokens) == 0 {
+ return truncateRunes(tag, maxWebRelevanceTagRunes)
+ }
+ full := strings.Join(tokens, " ")
+ if !hasSearchIntentMarker(full) {
+ withIntent := strings.TrimSpace(full + " 推薦")
+ if utf8.RuneCountInString(withIntent) <= maxWebRelevanceTagRunes {
+ return withIntent
+ }
+ }
+ return truncateRunes(full, maxWebRelevanceTagRunes)
+}
+
+func selectSearchTokens(tag string, maxTokens int) []string {
+ tag = stripSearchIntent(tag)
+ parts := []string{}
+ seen := map[string]struct{}{}
+ add := func(token string) {
+ token = strings.TrimSpace(token)
+ if isPatrolFiller(token) || len([]rune(token)) < 2 {
+ return
+ }
+ if _, ok := seen[token]; ok {
+ return
+ }
+ seen[token] = struct{}{}
+ parts = append(parts, token)
+ }
+
+ for _, anchor := range patrolTopicAnchors {
+ if strings.Contains(tag, anchor) {
+ add(anchor)
+ }
+ }
+ for _, hint := range productFormHints {
+ if strings.Contains(tag, hint) {
+ add(hint)
+ }
+ }
+ for _, token := range intentTokenize(tag) {
+ add(token)
+ if len(parts) >= maxTokens {
+ break
+ }
+ }
+ if len(parts) > maxTokens {
+ parts = parts[:maxTokens]
+ }
+ return parts
+}
+
+func stripSearchIntent(tag string) string {
+ tag = strings.TrimSpace(tag)
+ for _, marker := range searchIntentMarkers {
+ tag = strings.ReplaceAll(tag, marker, " ")
+ }
+ return strings.Join(strings.Fields(tag), " ")
+}
+
+func hasSearchIntentMarker(tag string) bool {
+ for _, marker := range searchIntentMarkers {
+ if strings.Contains(tag, marker) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/backend/internal/library/knowledge/search_keyword_shape_test.go b/backend/internal/library/knowledge/search_keyword_shape_test.go
new file mode 100644
index 0000000..f3b4bde
--- /dev/null
+++ b/backend/internal/library/knowledge/search_keyword_shape_test.go
@@ -0,0 +1,37 @@
+package knowledge
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestShortPatrolSearchCoreDropsIntentNoise(t *testing.T) {
+ got := ShortPatrolSearchCore("化療 無香 洗衣精 推薦 請問")
+ if got == "" {
+ t.Fatal("expected non-empty core")
+ }
+ if len([]rune(got)) > maxThreadsSearchRunes {
+ t.Fatalf("core too long: %q", got)
+ }
+ if strings.Contains(got, "推薦") || strings.Contains(got, "請問") {
+ t.Fatalf("expected intent words stripped for API core, got %q", got)
+ }
+ if !strings.Contains(got, "無香") && !strings.Contains(got, "洗衣精") {
+ t.Fatalf("expected pain/product tokens kept, got %q", got)
+ }
+}
+
+func TestFullPatrolWebSearchTagKeepsIntent(t *testing.T) {
+ got := FullPatrolWebSearchTag("化療 無香 洗衣精")
+ if !strings.Contains(got, "推薦") && !strings.Contains(got, "請問") && !strings.Contains(got, "怎麼辦") {
+ t.Fatalf("expected relevance tag to include intent marker, got %q", got)
+ }
+}
+
+func TestThreadsAPIKeywordUsesShortCore(t *testing.T) {
+ long := "化療後皮膚敏感要換什麼無香洗衣精 推薦"
+ got := ThreadsAPIKeyword(long)
+ if got == "" || len([]rune(got)) > maxThreadsSearchRunes {
+ t.Fatalf("unexpected threads keyword %q", got)
+ }
+}
diff --git a/backend/internal/library/knowledge/semantic_fit.go b/backend/internal/library/knowledge/semantic_fit.go
new file mode 100644
index 0000000..b430d0b
--- /dev/null
+++ b/backend/internal/library/knowledge/semantic_fit.go
@@ -0,0 +1,289 @@
+package knowledge
+
+import (
+ "strings"
+ "unicode"
+)
+
+// IntentProfile is a weighted lexical intent model for product + research map.
+// It generalizes relevance beyond hardcoded category rules (e.g. 洗衣精 vs 洗衣機).
+type IntentProfile struct {
+ TokenWeights map[string]float64
+ BroadTokens map[string]struct{}
+ Phrases []weightedPhrase
+}
+
+type weightedPhrase struct {
+ Text string
+ Weight float64
+}
+
+const (
+ intentWeightProduct = 3.0
+ intentWeightFeature = 2.8
+ intentWeightMatchTag = 2.5
+ intentWeightQuestion = 2.2
+ intentWeightPillar = 2.0
+ intentWeightAudience = 1.6
+ intentWeightPatrol = 1.2
+ intentWeightSeed = 1.0
+
+ tangentialBroadMin = 38
+ tangentialIntentMax = 30
+)
+
+var productFormHints = []string{
+ "洗衣精", "洗衣粉", "洗衣劑", "洗衣液",
+ "沐浴乳", "沐浴露", "洗髮精", "洗髮乳", "洗面乳",
+}
+
+// BuildIntentProfile materializes product + research-map text into a reusable relevance model.
+func BuildIntentProfile(in PatrolTagInput) IntentProfile {
+ profile := IntentProfile{
+ TokenWeights: map[string]float64{},
+ BroadTokens: map[string]struct{}{},
+ }
+ addPhrase := func(text string, weight float64) {
+ text = strings.TrimSpace(text)
+ if text == "" || weight <= 0 {
+ return
+ }
+ profile.Phrases = append(profile.Phrases, weightedPhrase{Text: text, Weight: weight})
+ profile.addTokens(text, weight)
+ }
+
+ addPhrase(in.ProductName, intentWeightProduct)
+ addPhrase(in.ProductFeatures, intentWeightFeature)
+ for _, tag := range in.MatchTags {
+ addPhrase(tag, intentWeightMatchTag)
+ }
+ for _, q := range in.Questions {
+ addPhrase(q, intentWeightQuestion)
+ }
+ for _, pillar := range in.Pillars {
+ addPhrase(pillar, intentWeightPillar)
+ }
+ addPhrase(in.AudienceSummary, intentWeightAudience)
+ addPhrase(in.TargetAudience, intentWeightAudience)
+ for _, kw := range in.PatrolKeywords {
+ addPhrase(kw, intentWeightPatrol)
+ }
+
+ if hint := productCategoryHint(in.ProductName, in.ProductFeatures); hint != "" {
+ profile.markBroad(hint)
+ }
+ for _, hint := range productFormHints {
+ blob := strings.ToLower(in.ProductName + " " + in.ProductFeatures + " " + strings.Join(in.MatchTags, " "))
+ if strings.Contains(blob, hint) {
+ profile.markBroad(hint)
+ }
+ }
+ return profile
+}
+
+func (p *IntentProfile) addTokens(text string, weight float64) {
+ for _, token := range intentTokenize(text) {
+ if existing, ok := p.TokenWeights[token]; !ok || weight > existing {
+ p.TokenWeights[token] = weight
+ }
+ }
+}
+
+func (p *IntentProfile) markBroad(token string) {
+ token = strings.TrimSpace(strings.ToLower(token))
+ if token == "" {
+ return
+ }
+ p.BroadTokens[token] = struct{}{}
+ for _, part := range intentTokenize(token) {
+ p.BroadTokens[part] = struct{}{}
+ }
+}
+
+func (p IntentProfile) totalWeight() float64 {
+ sum := 0.0
+ for _, w := range p.TokenWeights {
+ sum += w
+ }
+ if sum <= 0 {
+ for _, phrase := range p.Phrases {
+ sum += phrase.Weight
+ }
+ }
+ return sum
+}
+
+// NodeIntentText combines node fields used for semantic fit.
+func NodeIntentText(node Node) string {
+ return strings.TrimSpace(strings.Join([]string{
+ node.Label,
+ node.Relation,
+ node.PlacementValue,
+ strings.Join(node.PatrolRelevance, " "),
+ strings.Join(node.PatrolRecency, " "),
+ }, " "))
+}
+
+// ScoreIntentSimilarity returns 0-100 weighted token overlap against the intent profile.
+func ScoreIntentSimilarity(text string, profile IntentProfile) int {
+ text = strings.ToLower(strings.TrimSpace(text))
+ if text == "" || len(profile.TokenWeights) == 0 {
+ return 0
+ }
+ matched := 0.0
+ for token, weight := range profile.TokenWeights {
+ if strings.Contains(text, token) {
+ matched += weight
+ }
+ }
+ total := profile.totalWeight()
+ if total <= 0 {
+ return 0
+ }
+ score := int((matched / total) * 100)
+ if score > 100 {
+ return 100
+ }
+ return score
+}
+
+// ScoreBroadSimilarity measures overlap with category-level tokens only.
+func ScoreBroadSimilarity(text string, profile IntentProfile) int {
+ text = strings.ToLower(strings.TrimSpace(text))
+ if text == "" || len(profile.BroadTokens) == 0 {
+ return 0
+ }
+ hits := 0
+ for token := range profile.BroadTokens {
+ if strings.Contains(text, token) {
+ hits++
+ continue
+ }
+ runes := []rune(token)
+ if len(runes) >= 2 && strings.Contains(text, string(runes[:2])) {
+ hits++
+ }
+ }
+ if hits == 0 {
+ return 0
+ }
+ denom := len(profile.BroadTokens)
+ if denom > 6 {
+ denom = 6
+ }
+ score := (hits * 100) / denom
+ if score > 100 {
+ return 100
+ }
+ return score
+}
+
+// IsTangentialToIntent detects same broad category but weak product-intent alignment.
+// Example: 洗衣機 vs 抗敏無香洗衣精 — shares 洗衣, lacks 無香/敏感/化療 intent.
+func IsTangentialToIntent(text string, profile IntentProfile) bool {
+ text = strings.ToLower(strings.TrimSpace(text))
+ if text == "" || len(profile.TokenWeights) == 0 {
+ return false
+ }
+ intentScore := ScoreIntentSimilarity(text, profile)
+ if intentScore >= tangentialIntentMax+10 {
+ return false
+ }
+ if hasCategoryStemDrift(text, profile) {
+ return true
+ }
+ broadScore := ScoreBroadSimilarity(text, profile)
+ if broadScore >= tangentialBroadMin {
+ return intentScore <= tangentialIntentMax
+ }
+ return false
+}
+
+func hasCategoryStemDrift(text string, profile IntentProfile) bool {
+ for broad := range profile.BroadTokens {
+ runes := []rune(broad)
+ if len(runes) < 2 {
+ continue
+ }
+ stem := string(runes[:2])
+ if !strings.Contains(text, stem) {
+ continue
+ }
+ if strings.Contains(text, broad) {
+ continue
+ }
+ for _, suffix := range []string{"機", "槽", "烘", "劑", "粉", "精", "乳", "露", "液", "皂"} {
+ alt := stem + suffix
+ if alt == broad || !strings.Contains(text, alt) {
+ continue
+ }
+ if !profile.matchesIntentToken(alt) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func (p IntentProfile) matchesIntentToken(token string) bool {
+ if _, ok := p.TokenWeights[token]; ok {
+ return true
+ }
+ for intentToken := range p.TokenWeights {
+ if strings.Contains(token, intentToken) || strings.Contains(intentToken, token) {
+ return true
+ }
+ }
+ return false
+}
+
+func intentTokenize(text string) []string {
+ text = strings.ToLower(strings.TrimSpace(text))
+ if text == "" {
+ return nil
+ }
+ repl := strings.NewReplacer(",", " ", "、", " ", "。", " ", ";", " ", "/", " ", "|", " ", "|", " ", "(", " ", ")", " ", "(", " ", ")", " ", "?", " ", "?", " ", "!", " ", "!", " ")
+ text = repl.Replace(text)
+ seen := map[string]struct{}{}
+ var out []string
+ add := func(token string) {
+ token = strings.Trim(token, `"'「」『』::`)
+ if len([]rune(token)) < 2 {
+ return
+ }
+ if _, ok := seen[token]; ok {
+ return
+ }
+ seen[token] = struct{}{}
+ out = append(out, token)
+ }
+ for _, part := range strings.Fields(text) {
+ add(part)
+ }
+ if len(out) == 0 {
+ for _, chunk := range intentSplitRunes(text) {
+ add(chunk)
+ }
+ }
+ return out
+}
+
+func intentSplitRunes(s string) []string {
+ var out []string
+ var buf []rune
+ flush := func() {
+ if len(buf) >= 2 {
+ out = append(out, string(buf))
+ }
+ buf = buf[:0]
+ }
+ for _, r := range s {
+ if unicode.Is(unicode.Han, r) {
+ buf = append(buf, r)
+ continue
+ }
+ flush()
+ }
+ flush()
+ return out
+}
diff --git a/backend/internal/library/knowledge/semantic_fit_test.go b/backend/internal/library/knowledge/semantic_fit_test.go
new file mode 100644
index 0000000..449f6bf
--- /dev/null
+++ b/backend/internal/library/knowledge/semantic_fit_test.go
@@ -0,0 +1,53 @@
+package knowledge
+
+import "testing"
+
+func TestIsTangentialToIntentWashingMachineVsHypoallergenicDetergent(t *testing.T) {
+ profile := BuildIntentProfile(PatrolTagInput{
+ ProductName: "抗敏無香洗衣精",
+ ProductFeatures: "完全無香、抗敏,適合敏感肌與化療族群",
+ MatchTags: []string{"無香", "抗敏", "洗衣精"},
+ Pillars: []string{"化療後衣物清潔", "無香洗衣"},
+ Questions: []string{"化療後想找不會刺激的洗衣精"},
+ })
+ text := "請問洗脫烘洗衣機有推薦嗎?"
+ if !IsTangentialToIntent(text, profile) {
+ t.Fatal("expected washing machine post to be tangential to hypoallergenic detergent intent")
+ }
+}
+
+func TestIsTangentialToIntentAcceptsCorePain(t *testing.T) {
+ profile := BuildIntentProfile(PatrolTagInput{
+ ProductName: "抗敏無香洗衣精",
+ ProductFeatures: "完全無香、抗敏",
+ MatchTags: []string{"無香", "洗衣精"},
+ })
+ text := "化療後對香味很敏感,有無香洗衣精推薦嗎?"
+ if IsTangentialToIntent(text, profile) {
+ t.Fatal("expected core pain post to align with intent")
+ }
+}
+
+func TestRescoreGraphIntentFitPenalizesTangentialNode(t *testing.T) {
+ graph := Graph{
+ Seed: "無香 洗衣精",
+ Nodes: []Node{
+ {ID: "core", Label: "化療後對香味敏感", NodeKind: "pain", Layer: 0, ProductFitScore: 92, Relation: "需要無香洗衣精"},
+ {ID: "weak", Label: "洗衣機選購", NodeKind: "knowledge", Layer: 2, ProductFitScore: 72, Relation: "家電預算與容量"},
+ },
+ Edges: []Edge{{From: "core", To: "weak", Relation: "延伸"}},
+ }
+ in := PatrolTagInput{
+ ProductName: "抗敏無香洗衣精",
+ ProductFeatures: "完全無香、抗敏",
+ MatchTags: []string{"無香", "洗衣精"},
+ Questions: []string{"化療後想找無香洗衣精"},
+ }
+ RescoreGraphIntentFit(&graph, in)
+ if graph.Nodes[0].ProductFitScore < 60 {
+ t.Fatalf("expected core node to stay high, got %d", graph.Nodes[0].ProductFitScore)
+ }
+ if graph.Nodes[1].ProductFitScore > 35 {
+ t.Fatalf("expected tangential node to be penalized, got %d", graph.Nodes[1].ProductFitScore)
+ }
+}
diff --git a/backend/internal/library/matrix/generate.go b/backend/internal/library/matrix/generate.go
index 81ee400..3bbd571 100644
--- a/backend/internal/library/matrix/generate.go
+++ b/backend/internal/library/matrix/generate.go
@@ -13,14 +13,22 @@ import (
)
type Row struct {
- SortOrder int `json:"sort_order"`
- SearchTag string `json:"search_tag"`
- Angle string `json:"angle"`
- Hook string `json:"hook"`
- Text string `json:"text"`
- ReferenceNotes string `json:"reference_notes"`
- SourcePermalinks []string `json:"source_permalinks"`
- Rationale string `json:"rationale"`
+ SortOrder int `json:"sort_order"`
+ SearchTag string `json:"search_tag"`
+ Angle string `json:"angle"`
+ Hook string `json:"hook"`
+ Text string `json:"text"`
+ ReferenceNotes string `json:"reference_notes"`
+ SourcePermalinks []string `json:"source_permalinks"`
+ Rationale string `json:"rationale"`
+ AiScore int `json:"ai_score"`
+ FormulaScore int `json:"formula_score"`
+ BrandFitScore int `json:"brand_fit_score"`
+ RiskScore int `json:"risk_score"`
+ SimilarityScore int `json:"similarity_score"`
+ EngagementPotential int `json:"engagement_potential"`
+ FreshnessScore int `json:"freshness_score"`
+ ReviewSuggestion string `json:"review_suggestion"`
}
type GenerateResult struct {
@@ -106,7 +114,7 @@ func CopyMatrixGenerateRequest(base domai.GenerateRequest, count int) domai.Gene
if count <= 0 {
count = 5
}
- temp := 0.45
+ temp := 0.78
tokens := 8192 + count*1024
if tokens > 16384 {
tokens = 16384
@@ -122,8 +130,8 @@ func MatrixRetryUserPrompt(count int) string {
}
return fmt.Sprintf(
"上一則回覆的 JSON 不完整或無法解析。請只回傳單一 JSON 物件 {\"rows\":[...]},共 %d 篇,不要用 markdown 程式碼區塊、不要加說明文字。\n"+
- "每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。\n"+
- "reference_notes 與 rationale 各不超過 40 字;text 建議 80~220 字。",
+ "每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale、ai_score、formula_score、brand_fit_score、risk_score、similarity_score、engagement_potential、freshness_score、review_suggestion。\n"+
+ "reference_notes 與 rationale 各不超過 40 字;text 要有具體場景與故事感、每篇敘事節奏不同;text 一句一行、完全不用任何標點(emoji 可保留)。",
count,
)
}
@@ -206,7 +214,7 @@ func normalizeGenerateResult(out GenerateResult) (GenerateResult, error) {
return GenerateResult{}, fmt.Errorf("matrix rows missing")
}
for i := range out.Rows {
- out.Rows[i].Text = trimText(out.Rows[i].Text)
+ out.Rows[i].Text = threadspost.FormatDraftText(out.Rows[i].Text)
if out.Rows[i].Text == "" {
return GenerateResult{}, fmt.Errorf("matrix row %d empty", i+1)
}
@@ -217,15 +225,6 @@ func normalizeGenerateResult(out GenerateResult) (GenerateResult, error) {
return out, nil
}
-func trimText(text string) string {
- text = strings.TrimSpace(text)
- runes := []rune(text)
- if len(runes) > threadspost.MaxPublishRunes {
- return string(runes[:threadspost.MaxPublishRunes])
- }
- return text
-}
-
func extractJSONObject(raw string) ([]byte, error) {
raw = strings.TrimSpace(raw)
if m := codeFenceRE.FindStringSubmatch(raw); len(m) == 2 {
diff --git a/backend/internal/library/matrix/generate_test.go b/backend/internal/library/matrix/generate_test.go
index 157b1eb..828ba4d 100644
--- a/backend/internal/library/matrix/generate_test.go
+++ b/backend/internal/library/matrix/generate_test.go
@@ -71,4 +71,4 @@ func TestGenerateCopyOutputRetriesOnParseFailure(t *testing.T) {
if gen.calls != 2 {
t.Fatalf("calls = %d, want retry", gen.calls)
}
-}
\ No newline at end of file
+}
diff --git a/backend/internal/library/matrix/reference.go b/backend/internal/library/matrix/reference.go
index 6c57d63..dc9a3e3 100644
--- a/backend/internal/library/matrix/reference.go
+++ b/backend/internal/library/matrix/reference.go
@@ -41,4 +41,4 @@ func FormatCopyResearchMapBlockWithReferences(
func BuildReferenceMarkdown(digests []libweb.Digest) string {
return libweb.FormatMarkdownBlock(digests, libweb.DefaultMaxMarkdown)
-}
\ No newline at end of file
+}
diff --git a/backend/internal/library/outreach/draft_context.go b/backend/internal/library/outreach/draft_context.go
new file mode 100644
index 0000000..eccea44
--- /dev/null
+++ b/backend/internal/library/outreach/draft_context.go
@@ -0,0 +1,107 @@
+package outreach
+
+import (
+ "fmt"
+ "strings"
+
+ libkg "haixun-backend/internal/library/knowledge"
+ libplacement "haixun-backend/internal/library/placement"
+ brandentity "haixun-backend/internal/model/brand/domain/entity"
+ branddomain "haixun-backend/internal/model/brand/domain/usecase"
+ scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
+)
+
+func FindGraphNode(nodes []libkg.Node, nodeID string) *libkg.Node {
+ nodeID = strings.TrimSpace(nodeID)
+ if nodeID == "" {
+ return nil
+ }
+ for i := range nodes {
+ if nodes[i].ID == nodeID {
+ return &nodes[i]
+ }
+ }
+ return nil
+}
+
+func BuildPlacementReason(post *scanpostusecase.ScanPostSummary) string {
+ if post == nil {
+ return ""
+ }
+ parts := []string{}
+ if post.Priority == "gold" {
+ parts = append(parts, "雙軌命中(相關+近期)")
+ } else if post.Priority == "recent" {
+ parts = append(parts, "近期軌命中")
+ }
+ if post.SolvedByProduct {
+ parts = append(parts, "產品可解此需求")
+ }
+ if post.ProductFitScore > 0 {
+ parts = append(parts, fmt.Sprintf("產品匹配 %d", post.ProductFitScore))
+ }
+ return strings.Join(parts, ";")
+}
+
+func MergePlacementReason(base string, node *libkg.Node, post *scanpostusecase.ScanPostSummary) string {
+ parts := []string{}
+ if strings.TrimSpace(base) != "" {
+ parts = append(parts, strings.TrimSpace(base))
+ }
+ if node != nil {
+ if strings.TrimSpace(node.PlacementValue) != "" {
+ parts = append(parts, "置入價值 "+strings.TrimSpace(node.PlacementValue))
+ }
+ if node.ProductFitScore > 0 && (post == nil || post.ProductFitScore == 0) {
+ parts = append(parts, fmt.Sprintf("節點產品匹配 %d", node.ProductFitScore))
+ }
+ }
+ return strings.Join(parts, ";")
+}
+
+func ProductBriefForBrand(brand *branddomain.BrandSummary, productID, searchTag, postText string) string {
+ if brand == nil {
+ return ""
+ }
+ if product := PickProductForOutreach(brand, productID, searchTag, postText); product != nil {
+ merged := libplacement.BuildMergedProductContext(brand.DisplayName, product.ProductContext, product.Label)
+ merged = libplacement.ApplyPlacementURL(merged, product.PlacementURL)
+ if pb := libplacement.ProductBriefFromContext(merged); pb != "" {
+ return pb
+ }
+ }
+ if pb := libplacement.ProductBriefFromContext(brand.ProductContext); pb != "" {
+ return pb
+ }
+ return strings.TrimSpace(brand.ProductBrief)
+}
+
+func PickProductForOutreach(brand *branddomain.BrandSummary, preferredProductID, searchTag, postText string) *branddomain.ProductSummary {
+ if brand == nil || len(brand.Products) == 0 {
+ return nil
+ }
+ entities := make([]brandentity.Product, len(brand.Products))
+ for i := range brand.Products {
+ entities[i] = brandentity.Product{
+ ID: brand.Products[i].ID,
+ Label: brand.Products[i].Label,
+ ProductContext: brand.Products[i].ProductContext,
+ MatchTags: append([]string(nil), brand.Products[i].MatchTags...),
+ }
+ }
+ if picked := libplacement.RecommendProductForPost(entities, searchTag, postText, preferredProductID); picked != nil {
+ for i := range brand.Products {
+ if brand.Products[i].ID == picked.ID {
+ return &brand.Products[i]
+ }
+ }
+ }
+ if id := strings.TrimSpace(preferredProductID); id != "" {
+ for i := range brand.Products {
+ if brand.Products[i].ID == id {
+ return &brand.Products[i]
+ }
+ }
+ }
+ return &brand.Products[0]
+}
diff --git a/backend/internal/library/outreach/draft_format.go b/backend/internal/library/outreach/draft_format.go
new file mode 100644
index 0000000..d797df1
--- /dev/null
+++ b/backend/internal/library/outreach/draft_format.go
@@ -0,0 +1,76 @@
+package outreach
+
+import (
+ "strings"
+)
+
+func NormalizeDraftText(text string) string {
+ text = strings.ReplaceAll(text, "\r\n", "\n")
+ text = strings.ReplaceAll(text, "\r", "\n")
+ text = strings.TrimSpace(text)
+ text = humanizeDraftLineBreaks(text)
+ return trimDraftText(text)
+}
+
+func humanizeDraftLineBreaks(text string) string {
+ if text == "" || strings.Contains(text, "\n") {
+ return text
+ }
+ if len([]rune(text)) < 40 {
+ return text
+ }
+
+ sentences := splitSentences(text)
+ if len(sentences) <= 1 {
+ return text
+ }
+
+ paragraphs := make([]string, 0, (len(sentences)+1)/2)
+ for i := 0; i < len(sentences); {
+ end := i + 2
+ if end > len(sentences) {
+ end = len(sentences)
+ }
+ paragraphs = append(paragraphs, strings.Join(sentences[i:end], ""))
+ i = end
+ }
+ return strings.Join(paragraphs, "\n\n")
+}
+
+func splitSentences(text string) []string {
+ runes := []rune(text)
+ if len(runes) == 0 {
+ return nil
+ }
+ out := make([]string, 0, 4)
+ var current strings.Builder
+ for i := 0; i < len(runes); i++ {
+ ch := runes[i]
+ current.WriteRune(ch)
+ if !isSentenceEnd(ch) {
+ continue
+ }
+ // Keep ellipsis / repeated punctuation attached to the same sentence.
+ for i+1 < len(runes) && isSentenceEnd(runes[i+1]) {
+ i++
+ current.WriteRune(runes[i])
+ }
+ if sentence := strings.TrimSpace(current.String()); sentence != "" {
+ out = append(out, sentence)
+ }
+ current.Reset()
+ }
+ if tail := strings.TrimSpace(current.String()); tail != "" {
+ out = append(out, tail)
+ }
+ return out
+}
+
+func isSentenceEnd(ch rune) bool {
+ switch ch {
+ case '。', '!', '?', '!', '?', '…':
+ return true
+ default:
+ return false
+ }
+}
diff --git a/backend/internal/library/outreach/draft_format_test.go b/backend/internal/library/outreach/draft_format_test.go
new file mode 100644
index 0000000..5a431de
--- /dev/null
+++ b/backend/internal/library/outreach/draft_format_test.go
@@ -0,0 +1,36 @@
+package outreach
+
+import "testing"
+
+func TestNormalizeDraftTextPreservesNewlines(t *testing.T) {
+ raw := "第一段。\n\n第二段。"
+ got := NormalizeDraftText(raw)
+ if got != raw {
+ t.Fatalf("got %q, want %q", got, raw)
+ }
+}
+
+func TestHumanizeDraftLineBreaks(t *testing.T) {
+ raw := "我懂你的感受。之前我們家也遇過類似狀況。後來換了無香的才比較舒服。希望你有找到適合的。"
+ got := NormalizeDraftText(raw)
+ if !containsNewline(got) {
+ t.Fatalf("expected paragraph breaks, got %q", got)
+ }
+}
+
+func TestNormalizeDraftTextShortSingleParagraph(t *testing.T) {
+ raw := "加油,會好的。"
+ got := NormalizeDraftText(raw)
+ if got != raw {
+ t.Fatalf("got %q, want %q", got, raw)
+ }
+}
+
+func containsNewline(s string) bool {
+ for i := 0; i < len(s); i++ {
+ if s[i] == '\n' {
+ return true
+ }
+ }
+ return false
+}
diff --git a/backend/internal/library/outreach/generate.go b/backend/internal/library/outreach/generate.go
index a47277a..1c5e2d0 100644
--- a/backend/internal/library/outreach/generate.go
+++ b/backend/internal/library/outreach/generate.go
@@ -24,14 +24,16 @@ type GenerateResult struct {
}
type GenerateInput struct {
- Persona string
- TopicLabel string
- AudienceBrief string
- ProductBrief string
- PlacementReason string
- TargetText string
- AuthorName string
- Count int
+ Persona string
+ TopicLabel string
+ AudienceBrief string
+ ProductBrief string
+ PlacementReason string
+ TargetText string
+ AuthorName string
+ Count int
+ Regenerate bool
+ PreviousDraftsHint string
}
var codeFenceRE = regexp.MustCompile(`(?s)^` + "```(?:json)?\\s*(.*?)\\s*" + "```$")
@@ -65,6 +67,13 @@ func BuildUserPrompt(in GenerateInput) (string, error) {
if author == "" {
author = "匿名"
}
+ regenerateLine := ""
+ if in.Regenerate || strings.TrimSpace(in.PreviousDraftsHint) != "" {
+ regenerateLine = "\n【重新生成】請產出與舊版明顯不同的新草稿:換開頭、換切角、換句式與情境細節,不要複製舊文案。"
+ if hint := strings.TrimSpace(in.PreviousDraftsHint); hint != "" {
+ regenerateLine += "\n" + hint
+ }
+ }
return libprompt.OutreachPlacementUser(map[string]string{
"persona_block": personaBlock,
"topic_label": topic,
@@ -73,6 +82,7 @@ func BuildUserPrompt(in GenerateInput) (string, error) {
"placement_reason_line": reasonLine,
"author_name": author,
"target_text": strings.TrimSpace(in.TargetText),
+ "regenerate_line": regenerateLine,
"count": fmt.Sprintf("%d", count),
})
}
@@ -90,7 +100,7 @@ func ParseGenerateOutput(raw string) (GenerateResult, error) {
return GenerateResult{}, fmt.Errorf("outreach drafts missing")
}
for i := range out.Drafts {
- out.Drafts[i].Text = trimDraftText(out.Drafts[i].Text)
+ out.Drafts[i].Text = NormalizeDraftText(out.Drafts[i].Text)
if out.Drafts[i].Text == "" {
return GenerateResult{}, fmt.Errorf("outreach draft %d empty", i+1)
}
diff --git a/backend/internal/library/outreach/generate_draft.go b/backend/internal/library/outreach/generate_draft.go
new file mode 100644
index 0000000..fea7e67
--- /dev/null
+++ b/backend/internal/library/outreach/generate_draft.go
@@ -0,0 +1,188 @@
+package outreach
+
+import (
+ "context"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ libprompt "haixun-backend/internal/library/prompt"
+ domai "haixun-backend/internal/model/ai/domain/usecase"
+ aiusecase "haixun-backend/internal/model/ai/usecase"
+ branddomain "haixun-backend/internal/model/brand/domain/usecase"
+ kgdomain "haixun-backend/internal/model/knowledge_graph/domain/usecase"
+ outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase"
+ personadomain "haixun-backend/internal/model/persona/domain/usecase"
+ scanpostentity "haixun-backend/internal/model/scan_post/domain/entity"
+ scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
+ threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
+)
+
+type GenerateDraftRequest struct {
+ TenantID string
+ OwnerUID string
+ BrandID string
+ TopicID string
+ ScanPostID string
+ Count int
+ VoicePersonaID string
+ ProductID string
+ Regenerate bool
+}
+
+type GenerateDraftDeps struct {
+ Brand branddomain.UseCase
+ Persona personadomain.UseCase
+ ScanPost scanpostusecase.UseCase
+ KnowledgeGraph kgdomain.UseCase
+ ThreadsAccount threadsaccountdomain.UseCase
+ AI aiusecase.UseCase
+ OutreachDraft outreachusecase.UseCase
+}
+
+func GenerateDraft(ctx context.Context, deps GenerateDraftDeps, req GenerateDraftRequest) (*outreachusecase.DraftSummary, error) {
+ scanPostID := strings.TrimSpace(req.ScanPostID)
+ count := req.Count
+ if count <= 0 {
+ count = 2
+ }
+ if count > 4 {
+ count = 4
+ }
+ if scanPostID == "" {
+ return nil, app.For(code.Brand).InputMissingRequired("scan_post_id is required")
+ }
+
+ brand, err := deps.Brand.Get(ctx, req.TenantID, req.OwnerUID, req.BrandID)
+ if err != nil {
+ return nil, err
+ }
+ voiceID := strings.TrimSpace(req.VoicePersonaID)
+ if voiceID == "" {
+ return nil, app.For(code.Brand).InputMissingRequired("voice_persona_id is required")
+ }
+ vp, err := deps.Persona.Get(ctx, req.TenantID, req.OwnerUID, voiceID)
+ if err != nil {
+ return nil, err
+ }
+ voicePersona := FormatVoicePersonaBlock(vp)
+ if voicePersona == "" {
+ return nil, app.For(code.Persona).InputMissingRequired("請先在人設頁填寫人設描述或完成 8D 風格分析")
+ }
+ preferredProductID := strings.TrimSpace(req.ProductID)
+ if preferredProductID == "" {
+ preferredProductID = strings.TrimSpace(brand.ProductID)
+ }
+ post, err := deps.ScanPost.Get(ctx, req.TenantID, req.OwnerUID, req.BrandID, scanPostID)
+ if err != nil {
+ return nil, err
+ }
+ pickedProduct := PickProductForOutreach(brand, preferredProductID, post.SearchTag, post.Text)
+ productID := preferredProductID
+ if pickedProduct != nil {
+ productID = pickedProduct.ID
+ }
+
+ topicLabel := strings.TrimSpace(post.SearchTag)
+ placementReason := BuildPlacementReason(post)
+ if graph, graphErr := deps.KnowledgeGraph.Get(ctx, req.TenantID, req.OwnerUID, req.BrandID); graphErr == nil && graph != nil {
+ if node := FindGraphNode(graph.Nodes, post.GraphNodeID); node != nil {
+ if strings.TrimSpace(node.Label) != "" {
+ topicLabel = strings.TrimSpace(node.Label)
+ }
+ placementReason = MergePlacementReason(placementReason, node, post)
+ }
+ }
+
+ regenerate := req.Regenerate
+ previousHint := ""
+ if latest, latestErr := deps.OutreachDraft.GetLatestByScanPost(
+ ctx, req.TenantID, req.OwnerUID, req.BrandID, strings.TrimSpace(req.TopicID), scanPostID,
+ ); latestErr == nil && latest != nil && len(latest.Drafts) > 0 {
+ regenerate = true
+ previousHint = FormatPreviousDraftsHint(latest)
+ }
+
+ userPrompt, err := BuildUserPrompt(GenerateInput{
+ Persona: voicePersona,
+ TopicLabel: topicLabel,
+ AudienceBrief: brand.TargetAudience,
+ ProductBrief: ProductBriefForBrand(brand, productID, post.SearchTag, post.Text),
+ PlacementReason: placementReason,
+ TargetText: post.Text,
+ AuthorName: post.Author,
+ Count: count,
+ Regenerate: regenerate,
+ PreviousDraftsHint: previousHint,
+ })
+ if err != nil {
+ return nil, app.For(code.AI).SysInternal("outreach user prompt load failed")
+ }
+
+ systemPrompt, err := libprompt.OutreachPlacementSystem()
+ if err != nil {
+ return nil, app.For(code.AI).SysInternal("outreach system prompt load failed")
+ }
+ systemPrompt = strings.TrimSpace(systemPrompt) + "\n\n【最高優先】以下人設與語氣是硬約束,每則留言都必須像這個角色親自回覆,不得變成通用網友口吻:\n" + strings.TrimSpace(voicePersona)
+
+ credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, req.TenantID, req.OwnerUID)
+ if err != nil {
+ return nil, err
+ }
+ providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
+ if err != nil {
+ return nil, err
+ }
+
+ temp := 0.88
+ result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{
+ Provider: providerID,
+ Model: credential.Model,
+ Credential: domai.Credential{
+ APIKey: credential.APIKey,
+ },
+ System: systemPrompt,
+ Messages: []domai.Message{
+ {Role: "user", Content: userPrompt},
+ },
+ Temperature: &temp,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ parsed, err := ParseGenerateOutput(result.Text)
+ if err != nil {
+ return nil, app.For(code.AI).SvcThirdParty("獲客留言 LLM 回傳無法解析:" + err.Error())
+ }
+
+ draftItems := make([]outreachusecase.DraftItem, 0, len(parsed.Drafts))
+ for _, item := range parsed.Drafts {
+ draftItems = append(draftItems, outreachusecase.DraftItem{
+ Text: item.Text,
+ Angle: item.Angle,
+ Rationale: item.Rationale,
+ })
+ }
+ saved, err := deps.OutreachDraft.Create(ctx, outreachusecase.CreateRequest{
+ TenantID: req.TenantID,
+ OwnerUID: req.OwnerUID,
+ BrandID: req.BrandID,
+ TopicID: strings.TrimSpace(req.TopicID),
+ ScanPostID: scanPostID,
+ Relevance: parsed.Relevance,
+ Reason: parsed.Reason,
+ Drafts: draftItems,
+ })
+ if err != nil {
+ return nil, err
+ }
+ _, _ = deps.ScanPost.UpdateOutreach(ctx, scanpostusecase.UpdateOutreachRequest{
+ TenantID: req.TenantID,
+ OwnerUID: req.OwnerUID,
+ BrandID: req.BrandID,
+ PostID: scanPostID,
+ Status: scanpostentity.OutreachStatusDrafted,
+ })
+ return saved, nil
+}
diff --git a/backend/internal/library/outreach/persona_voice.go b/backend/internal/library/outreach/persona_voice.go
new file mode 100644
index 0000000..292571d
--- /dev/null
+++ b/backend/internal/library/outreach/persona_voice.go
@@ -0,0 +1,33 @@
+package outreach
+
+import (
+ "strings"
+
+ "haixun-backend/internal/library/style8d"
+ personadomain "haixun-backend/internal/model/persona/domain/usecase"
+)
+
+// FormatVoicePersonaBlock builds the LLM persona block from a full persona profile.
+func FormatVoicePersonaBlock(vp *personadomain.PersonaSummary) string {
+ if vp == nil {
+ return ""
+ }
+ block := style8d.ResolvePersonaBlock(vp.Persona, vp.StyleProfile, vp.Brief)
+ if block == "" {
+ return ""
+ }
+ extras := make([]string, 0, 3)
+ if name := strings.TrimSpace(vp.DisplayName); name != "" {
+ extras = append(extras, "角色名稱:"+name)
+ }
+ if goals := strings.TrimSpace(vp.Goals); goals != "" {
+ extras = append(extras, "回覆目標:"+goals)
+ }
+ if benchmark := strings.TrimSpace(vp.StyleBenchmark); benchmark != "" {
+ extras = append(extras, "語氣參考:"+benchmark)
+ }
+ if len(extras) == 0 {
+ return block
+ }
+ return strings.TrimSpace(block + "\n\n" + strings.Join(extras, "\n"))
+}
diff --git a/backend/internal/library/outreach/persona_voice_test.go b/backend/internal/library/outreach/persona_voice_test.go
new file mode 100644
index 0000000..cc7f763
--- /dev/null
+++ b/backend/internal/library/outreach/persona_voice_test.go
@@ -0,0 +1,34 @@
+package outreach
+
+import (
+ "strings"
+ "testing"
+
+ personadomain "haixun-backend/internal/model/persona/domain/usecase"
+)
+
+func TestFormatVoicePersonaBlockUsesPersonaAndGoals(t *testing.T) {
+ block := FormatVoicePersonaBlock(&personadomain.PersonaSummary{
+ DisplayName: "小安",
+ Persona: "溫柔媽媽,說話慢一點、會先共情",
+ Goals: "讓對方覺得被理解,再輕鬆分享經驗",
+ })
+ if !strings.Contains(block, "溫柔媽媽") {
+ t.Fatalf("expected persona text, got %q", block)
+ }
+ if !strings.Contains(block, "小安") {
+ t.Fatalf("expected display name, got %q", block)
+ }
+ if !strings.Contains(block, "讓對方覺得被理解") {
+ t.Fatalf("expected goals, got %q", block)
+ }
+}
+
+func TestFormatVoicePersonaBlockEmpty(t *testing.T) {
+ if FormatVoicePersonaBlock(nil) != "" {
+ t.Fatal("expected empty block for nil persona")
+ }
+ if FormatVoicePersonaBlock(&personadomain.PersonaSummary{}) != "" {
+ t.Fatal("expected empty block for blank persona")
+ }
+}
diff --git a/backend/internal/library/outreach/previous_drafts.go b/backend/internal/library/outreach/previous_drafts.go
new file mode 100644
index 0000000..f9f577d
--- /dev/null
+++ b/backend/internal/library/outreach/previous_drafts.go
@@ -0,0 +1,32 @@
+package outreach
+
+import (
+ "fmt"
+ "strings"
+
+ outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase"
+)
+
+func FormatPreviousDraftsHint(latest *outreachusecase.DraftSummary) string {
+ if latest == nil || len(latest.Drafts) == 0 {
+ return ""
+ }
+ lines := []string{"上一版草稿(請避開相同寫法):"}
+ for i, item := range latest.Drafts {
+ angle := strings.TrimSpace(item.Angle)
+ if angle == "" {
+ angle = fmt.Sprintf("版本%d", i+1)
+ }
+ lines = append(lines, fmt.Sprintf("- %s:%s", angle, truncateRunes(item.Text, 140)))
+ }
+ return strings.Join(lines, "\n")
+}
+
+func truncateRunes(text string, max int) string {
+ text = strings.TrimSpace(text)
+ runes := []rune(text)
+ if len(runes) <= max {
+ return text
+ }
+ return string(runes[:max]) + "…"
+}
diff --git a/backend/internal/library/outreach/previous_drafts_test.go b/backend/internal/library/outreach/previous_drafts_test.go
new file mode 100644
index 0000000..c5bb007
--- /dev/null
+++ b/backend/internal/library/outreach/previous_drafts_test.go
@@ -0,0 +1,19 @@
+package outreach
+
+import (
+ "strings"
+ "testing"
+
+ outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase"
+)
+
+func TestFormatPreviousDraftsHint(t *testing.T) {
+ hint := FormatPreviousDraftsHint(&outreachusecase.DraftSummary{
+ Drafts: []outreachusecase.DraftItem{
+ {Angle: "共情", Text: "我懂你的感受"},
+ },
+ })
+ if !strings.Contains(hint, "上一版草稿") || !strings.Contains(hint, "共情") {
+ t.Fatalf("hint = %q", hint)
+ }
+}
diff --git a/backend/internal/library/ownpost/post_formula.go b/backend/internal/library/ownpost/post_formula.go
new file mode 100644
index 0000000..b74ad64
--- /dev/null
+++ b/backend/internal/library/ownpost/post_formula.go
@@ -0,0 +1,162 @@
+package ownpost
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "regexp"
+ "strings"
+
+ domai "haixun-backend/internal/model/ai/domain/usecase"
+)
+
+var formulaFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
+
+type PostFormulaInput struct {
+ AccountName string
+ PersonaBlock string
+ PostText string
+ TopicTag string
+ MediaType string
+ LikeCount int
+ ReplyCount int
+ Views int
+ RepostCount int
+ QuoteCount int
+ Shares int
+ Timestamp string
+}
+
+type PostFormulaReview struct {
+ Summary string `json:"summary"`
+ Wins []string `json:"wins"`
+ Improvements []string `json:"improvements"`
+ Formula string `json:"formula"`
+ PostTemplate string `json:"post_template"`
+ HookPattern string `json:"hook_pattern"`
+ Structure string `json:"structure"`
+ ReplicationTips []string `json:"replication_tips"`
+ Avoid []string `json:"avoid"`
+}
+
+func BuildPostFormulaPrompt(in PostFormulaInput) (system string, user string) {
+ personaBlock := strings.TrimSpace(in.PersonaBlock)
+ if personaBlock == "" {
+ personaBlock = "(未指定人設,請依貼文本身語氣分析)"
+ }
+ postText := strings.TrimSpace(in.PostText)
+ if postText == "" {
+ postText = "(貼文內容未提供)"
+ }
+
+ system = strings.TrimSpace(`
+你是 Threads 內容策略教練。任務是針對「已發布的單篇貼文」做誠實覆盤:好的要提炼成可複製公式,壞的要指出問題並給具體改善做法。不要重寫整篇貼文。
+
+規則:
+- 用台灣繁體中文。
+- 必須同時寫優點與缺點;依成效數據判斷,成效普通或偏差時更要直言問題。
+- 分析要具體、可執行,禁止空泛稱讚或空泛批評。
+- formula:把「做對的部分」整理成步驟化公式(例:Hook→情境→轉折→觀點→收尾/互動)。
+- post_template:依 formula 產出可複製的發文骨架,用 [括號] 標示可替換欄位,讓使用者下次填空就能產文。
+- improvements:每點格式固定為「問題:…|改善:…」,改善必須是可立刻執行的動作(改哪一句、加什麼、刪什麼、換什麼角度)。
+- 只輸出一個 JSON 物件,不要 markdown,欄位:
+ summary, wins, improvements, formula, post_template, hook_pattern, structure, replication_tips, avoid
+- wins / improvements / replication_tips / avoid 各 2~5 點字串陣列。`)
+
+ var b strings.Builder
+ b.WriteString("【1. 人設】\n")
+ b.WriteString(personaBlock)
+ b.WriteString("\n\n帳號:")
+ b.WriteString(strings.TrimSpace(in.AccountName))
+ b.WriteString("\n\n【2. 貼文內容】\n")
+ b.WriteString(postText)
+ if tag := strings.TrimSpace(in.TopicTag); tag != "" {
+ b.WriteString("\n話題標籤:#")
+ b.WriteString(tag)
+ }
+ if mt := strings.TrimSpace(in.MediaType); mt != "" {
+ b.WriteString("\n媒體類型:")
+ b.WriteString(mt)
+ }
+ if ts := strings.TrimSpace(in.Timestamp); ts != "" {
+ b.WriteString("\n發布時間:")
+ b.WriteString(ts)
+ }
+ b.WriteString("\n\n【3. 成效數據】\n")
+ b.WriteString(fmt.Sprintf("讚 %d|回覆 %d|瀏覽 %d|轉發 %d|引用 %d|分享 %d",
+ in.LikeCount, in.ReplyCount, in.Views, in.RepostCount, in.QuoteCount, in.Shares))
+ b.WriteString("\n\n請誠實覆盤:做對了什麼、哪裡不好、怎麼改,並把做對的部分整理成下次可複製的公式與發文模板 JSON。")
+ user = b.String()
+ return system, user
+}
+
+func GeneratePostFormula(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in PostFormulaInput) (*PostFormulaReview, error) {
+ system, user := BuildPostFormulaPrompt(in)
+ temp := 0.65
+ req.System = system
+ req.Messages = []domai.Message{{Role: "user", Content: user}}
+ req.Temperature = &temp
+ out, err := ai.GenerateText(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ review, err := ParsePostFormulaOutput(out.Text)
+ if err != nil {
+ return nil, err
+ }
+ return review, nil
+}
+
+func ParsePostFormulaOutput(raw string) (*PostFormulaReview, error) {
+ payload, err := extractFormulaJSONObject(raw)
+ if err != nil {
+ return nil, err
+ }
+ var review PostFormulaReview
+ if err := json.Unmarshal(payload, &review); err != nil {
+ return nil, fmt.Errorf("parse post formula json: %w", err)
+ }
+ review.Summary = strings.TrimSpace(review.Summary)
+ review.Formula = strings.TrimSpace(review.Formula)
+ review.PostTemplate = strings.TrimSpace(review.PostTemplate)
+ review.HookPattern = strings.TrimSpace(review.HookPattern)
+ review.Structure = strings.TrimSpace(review.Structure)
+ review.Wins = trimStringList(review.Wins)
+ review.Improvements = trimStringList(review.Improvements)
+ review.ReplicationTips = trimStringList(review.ReplicationTips)
+ review.Avoid = trimStringList(review.Avoid)
+ if review.Summary == "" && review.Formula == "" && review.PostTemplate == "" {
+ return nil, fmt.Errorf("AI 未回傳有效覆盤內容")
+ }
+ return &review, nil
+}
+
+func trimStringList(items []string) []string {
+ if len(items) == 0 {
+ return nil
+ }
+ out := make([]string, 0, len(items))
+ for _, item := range items {
+ item = strings.TrimSpace(item)
+ if item != "" {
+ out = append(out, item)
+ }
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
+func extractFormulaJSONObject(raw string) ([]byte, error) {
+ raw = strings.TrimSpace(raw)
+ if m := formulaFenceRE.FindStringSubmatch(raw); len(m) == 2 {
+ raw = strings.TrimSpace(m[1])
+ }
+ start := strings.Index(raw, "{")
+ end := strings.LastIndex(raw, "}")
+ if start < 0 || end <= start {
+ return nil, fmt.Errorf("formula output missing json object")
+ }
+ return []byte(raw[start : end+1]), nil
+}
diff --git a/backend/internal/library/ownpost/post_formula_test.go b/backend/internal/library/ownpost/post_formula_test.go
new file mode 100644
index 0000000..5575e60
--- /dev/null
+++ b/backend/internal/library/ownpost/post_formula_test.go
@@ -0,0 +1,34 @@
+package ownpost
+
+import "testing"
+
+func TestParsePostFormulaOutputIncludesImprovementsAndTemplate(t *testing.T) {
+ raw := `{
+ "summary": "整體互動偏低,但開場有吸引力。",
+ "wins": ["開場直接點痛點"],
+ "improvements": ["問題:收尾沒有問句|改善:最後加一句開放式提問引導留言"],
+ "formula": "Hook→情境→觀點→互動問句",
+ "post_template": "[痛點一句]\n[親身情境]\n[你的觀點]\n[問句收尾]",
+ "hook_pattern": "先丟痛點再現身說法",
+ "structure": "4 段,每段 1-2 句",
+ "replication_tips": ["下次同主題先寫 Hook 再補故事"],
+ "avoid": ["不要一次講太多資訊"]
+}`
+ review, err := ParsePostFormulaOutput(raw)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if len(review.Improvements) != 1 {
+ t.Fatalf("improvements: got %d", len(review.Improvements))
+ }
+ if review.PostTemplate == "" {
+ t.Fatal("expected post_template")
+ }
+}
+
+func TestTrimStringListDropsEmpty(t *testing.T) {
+ got := trimStringList([]string{" a ", "", "b"})
+ if len(got) != 2 || got[0] != "a" || got[1] != "b" {
+ t.Fatalf("unexpected: %#v", got)
+ }
+}
diff --git a/backend/internal/library/ownpost/reply_draft.go b/backend/internal/library/ownpost/reply_draft.go
new file mode 100644
index 0000000..8ccf529
--- /dev/null
+++ b/backend/internal/library/ownpost/reply_draft.go
@@ -0,0 +1,226 @@
+package ownpost
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strings"
+ "unicode/utf8"
+
+ domai "haixun-backend/internal/model/ai/domain/usecase"
+)
+
+type ReplyDraftInput struct {
+ AccountName string
+ PersonaBlock string
+ PostText string
+ ReplyText string
+ ReplyToID string
+ ThreadPostText string
+ ParentReplyText string
+}
+
+var (
+ wrapQuoteRE = regexp.MustCompile(`^["'「『](.*)["'」』]$`)
+ multiBlankRE = regexp.MustCompile(`\n{3,}`)
+ sentenceEndRE = regexp.MustCompile(`([。!?…!?])\s*`)
+ aiPhraseRE = regexp.MustCompile(`(?i)(很高興|感謝您的|希望這對您|作為一個|總而言之|綜上所述|不妨考慮|值得注意)`)
+)
+
+func isMentionReplyDraft(in ReplyDraftInput) bool {
+ return strings.TrimSpace(in.ThreadPostText) != "" || strings.TrimSpace(in.ParentReplyText) != ""
+}
+
+func BuildReplyDraftPrompt(in ReplyDraftInput) (system string, user string) {
+ if isMentionReplyDraft(in) {
+ return buildMentionReplyDraftPrompt(in)
+ }
+ accountName := strings.TrimSpace(in.AccountName)
+ if accountName == "" {
+ accountName = "我"
+ }
+ postText := strings.TrimSpace(in.PostText)
+ if postText == "" {
+ postText = "(貼文內容未提供)"
+ }
+ replyText := strings.TrimSpace(in.ReplyText)
+ replyToID := strings.TrimSpace(in.ReplyToID)
+ personaBlock := strings.TrimSpace(in.PersonaBlock)
+ if personaBlock == "" {
+ personaBlock = "(尚未設定人設,請用口語、像真人在 Threads 回留言)"
+ }
+
+ system = strings.TrimSpace(`
+你是 Threads 貼文作者本人,要在自己貼文底下回留言或接續串文。
+請用台灣繁體中文,像真人用手機打字,完全不像 AI 或客服。
+
+硬規則:
+1. 人設語氣是最高優先:用字、節奏、距離感、價值觀必須符合【1. 人設】,不得變成通用網友或官方口吻。
+2. 只輸出一則回覆正文:不要標題、不要 JSON、不要用引號包裹、不要解釋你怎麼寫。
+3. 先讀【2. 我的貼文】掌握語境;若有【3. 要回覆的留言】,要直接回應對方說的內容,不要自說自話。
+4. 禁止 AI 腔:不要「很高興/感謝您的/希望這對您有幫助/作為一個…/總而言之/綜上所述」這類句型。
+5. 禁止客服業配腔:不要硬推、不要條列式教學、不要一次講太多重點。
+6. 排版要像真人留言:2~4 個短段落,每段 1~2 句;段落之間必須換行(用實際換行,不要整段擠在一起)。
+7. 可口語、可省略主詞、可用 …、可 0~1 個 emoji;總長度約 40~180 字,最多不超過 280 字。
+8. 你是貼文作者本人在回,不要假裝是粉絲或路人。`)
+
+ var b strings.Builder
+ b.WriteString("【1. 人設】\n")
+ b.WriteString(personaBlock)
+ b.WriteString("\n\n帳號名稱:")
+ b.WriteString(accountName)
+ b.WriteString("\n\n【2. 我的貼文】\n")
+ b.WriteString(postText)
+ b.WriteString("\n\n【3. 要回覆的留言】\n")
+ if replyToID != "" && replyText != "" {
+ b.WriteString(replyText)
+ b.WriteString("\n\n請依人設語氣,回覆這則留言。")
+ } else {
+ b.WriteString("(無特定留言,這是接續自己貼文的補充回覆/串文)")
+ b.WriteString("\n\n請依人設語氣,寫一則自然接續這篇貼文的留言。")
+ }
+ user = b.String()
+ return system, user
+}
+
+func buildMentionReplyDraftPrompt(in ReplyDraftInput) (system string, user string) {
+ accountName := strings.TrimSpace(in.AccountName)
+ if accountName == "" {
+ accountName = "我"
+ }
+ threadText := strings.TrimSpace(in.ThreadPostText)
+ if threadText == "" {
+ threadText = strings.TrimSpace(in.PostText)
+ }
+ if threadText == "" {
+ threadText = "(主串貼文內容未提供)"
+ }
+ parentText := strings.TrimSpace(in.ParentReplyText)
+ mentionText := strings.TrimSpace(in.ReplyText)
+ if mentionText == "" {
+ mentionText = "(對方 @ 你的內容未提供)"
+ }
+ personaBlock := strings.TrimSpace(in.PersonaBlock)
+ if personaBlock == "" {
+ personaBlock = "(尚未設定人設,請用口語、像真人在 Threads 回留言)"
+ }
+
+ system = strings.TrimSpace(`
+你是 Threads 帳號本人,有人在你參與的串文裡 @ 你,你要回覆對方。
+請用台灣繁體中文,像真人用手機打字,完全不像 AI 或客服。
+
+硬規則:
+1. 人設語氣是最高優先:用字、節奏、距離感必須符合【1. 人設】。
+2. 先讀【2. 主串貼文】理解整串在講什麼;若有【3. 上層留言】,要知道留言脈絡。
+3. 你的回覆必須直接回應【4. 對方 @ 你的內容】,不要忽略對方 tag 你的那句話。
+4. 只輸出一則回覆正文:不要標題、不要 JSON、不要用引號包裹、不要解釋你怎麼寫。
+5. 禁止 AI 腔與客服腔;2~4 個短段落,段落間換行;約 40~180 字,最多 280 字。
+6. 你是被 @ 的當事人在回,語氣自然、有來有往。`)
+
+ var b strings.Builder
+ b.WriteString("【1. 人設】\n")
+ b.WriteString(personaBlock)
+ b.WriteString("\n\n帳號名稱:")
+ b.WriteString(accountName)
+ b.WriteString("\n\n【2. 主串貼文】\n")
+ b.WriteString(threadText)
+ if parentText != "" {
+ b.WriteString("\n\n【3. 上層留言】\n")
+ b.WriteString(parentText)
+ }
+ b.WriteString("\n\n【4. 對方 @ 你的內容】\n")
+ b.WriteString(mentionText)
+ b.WriteString("\n\n請依人設語氣,回覆對方 @ 你的這則內容。")
+ return system, b.String()
+}
+
+func GenerateReplyDraft(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in ReplyDraftInput) (string, error) {
+ system, user := BuildReplyDraftPrompt(in)
+ temp := 0.9
+ req.System = system
+ req.Messages = []domai.Message{{Role: "user", Content: user}}
+ req.Temperature = &temp
+ out, err := ai.GenerateText(ctx, req)
+ if err != nil {
+ return "", err
+ }
+ text := NormalizeHumanReply(out.Text)
+ if text == "" {
+ return "", fmt.Errorf("AI 未回傳回覆草稿")
+ }
+ return text, nil
+}
+
+// NormalizeHumanReply trims AI artifacts and nudges layout toward short mobile-style paragraphs.
+func NormalizeHumanReply(raw string) string {
+ text := strings.TrimSpace(raw)
+ if text == "" {
+ return ""
+ }
+ if m := wrapQuoteRE.FindStringSubmatch(text); len(m) == 2 {
+ text = strings.TrimSpace(m[1])
+ }
+ text = strings.ReplaceAll(text, `\n`, "\n")
+ text = multiBlankRE.ReplaceAllString(text, "\n\n")
+ text = strings.TrimSpace(text)
+
+ if !strings.Contains(text, "\n") {
+ if sentenceCount(text) >= 2 || utf8.RuneCountInString(text) > 72 {
+ text = breakLongReply(text)
+ }
+ }
+ if aiPhraseRE.MatchString(text) && !strings.Contains(text, "\n") {
+ text = breakLongReply(text)
+ }
+ return strings.TrimSpace(text)
+}
+
+func sentenceCount(text string) int {
+ n := 0
+ for _, r := range text {
+ switch r {
+ case '。', '!', '?', '…', '!', '?':
+ n++
+ }
+ }
+ return n
+}
+
+func breakLongReply(text string) string {
+ parts := sentenceEndRE.Split(text, -1)
+ seps := sentenceEndRE.FindAllString(text, -1)
+ if len(parts) <= 1 {
+ return text
+ }
+ var chunks []string
+ var current strings.Builder
+ for i, part := range parts {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+ sep := ""
+ if i < len(seps) {
+ sep = strings.TrimSpace(seps[i])
+ }
+ segment := part + sep
+ if current.Len() == 0 {
+ current.WriteString(segment)
+ continue
+ }
+ if utf8.RuneCountInString(current.String())+utf8.RuneCountInString(segment) > 42 {
+ chunks = append(chunks, strings.TrimSpace(current.String()))
+ current.Reset()
+ current.WriteString(segment)
+ continue
+ }
+ current.WriteString(segment)
+ }
+ if current.Len() > 0 {
+ chunks = append(chunks, strings.TrimSpace(current.String()))
+ }
+ if len(chunks) <= 1 {
+ return text
+ }
+ return strings.Join(chunks, "\n")
+}
diff --git a/backend/internal/library/ownpost/reply_draft_test.go b/backend/internal/library/ownpost/reply_draft_test.go
new file mode 100644
index 0000000..975536d
--- /dev/null
+++ b/backend/internal/library/ownpost/reply_draft_test.go
@@ -0,0 +1,66 @@
+package ownpost
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestBuildReplyDraftPromptIncludesPersonaPostAndReply(t *testing.T) {
+ _, user := BuildReplyDraftPrompt(ReplyDraftInput{
+ AccountName: "小安",
+ PersonaBlock: "溫柔媽媽,說話慢、會先共情",
+ PostText: "今天帶小孩好累",
+ ReplyText: "我也是欸",
+ ReplyToID: "123",
+ })
+ for _, want := range []string{"【1. 人設】", "溫柔媽媽", "【2. 我的貼文】", "今天帶小孩好累", "【3. 要回覆的留言】", "我也是欸"} {
+ if !strings.Contains(user, want) {
+ t.Fatalf("missing %q in user prompt:\n%s", want, user)
+ }
+ }
+}
+
+func TestBuildReplyDraftPromptThreadContinuation(t *testing.T) {
+ _, user := BuildReplyDraftPrompt(ReplyDraftInput{
+ PersonaBlock: "生活觀察者",
+ PostText: "最近在想轉職",
+ })
+ if !strings.Contains(user, "接續自己貼文") {
+ t.Fatalf("expected thread continuation hint, got:\n%s", user)
+ }
+ if strings.Contains(user, "【3. 要回覆的留言】\n(無特定留言") {
+ // ok
+ } else if !strings.Contains(user, "無特定留言") {
+ t.Fatalf("expected empty reply section marker, got:\n%s", user)
+ }
+}
+
+func TestBuildMentionReplyDraftPromptIncludesThreadAndMention(t *testing.T) {
+ _, user := BuildReplyDraftPrompt(ReplyDraftInput{
+ AccountName: "小安",
+ PersonaBlock: "直率科技人",
+ ThreadPostText: "今天來分享育兒心得",
+ ParentReplyText: "我覺得放鬆最重要",
+ ReplyText: "@小安 你覺得呢?",
+ ReplyToID: "999",
+ })
+ for _, want := range []string{
+ "【2. 主串貼文】", "今天來分享育兒心得",
+ "【3. 上層留言】", "我覺得放鬆最重要",
+ "【4. 對方 @ 你的內容】", "@小安 你覺得呢?",
+ } {
+ if !strings.Contains(user, want) {
+ t.Fatalf("missing %q in mention user prompt:\n%s", want, user)
+ }
+ }
+}
+
+func TestNormalizeHumanReplyStripsQuotesAndBreaksLines(t *testing.T) {
+ got := NormalizeHumanReply(`"第一段真的超累到不想講話。第二段後來洗個澡就還好一點。第三段你加油,我們一起撐過這週。"`)
+ if strings.HasPrefix(got, `"`) {
+ t.Fatalf("expected quotes stripped, got %q", got)
+ }
+ if !strings.Contains(got, "\n") {
+ t.Fatalf("expected line breaks, got %q", got)
+ }
+}
diff --git a/backend/internal/library/permmatch/match_test.go b/backend/internal/library/permmatch/match_test.go
index 64dbcbd..3bfbf18 100644
--- a/backend/internal/library/permmatch/match_test.go
+++ b/backend/internal/library/permmatch/match_test.go
@@ -25,8 +25,9 @@ func TestPathAllowed(t *testing.T) {
func TestRequestAllowed(t *testing.T) {
perms := map[string]string{
- "/api/v1/members/me": "GET|PATCH",
- "/api/v1/jobs/*": "GET|POST",
+ "/api/v1/members/me": "GET|PATCH",
+ "/api/v1/jobs/*": "GET|POST",
+ "/api/v1/threads-accounts/*": "GET|POST|PUT|PATCH|DELETE",
}
if !RequestAllowed(perms, "GET", "/api/v1/members/me") {
t.Fatal("expected member me")
@@ -37,6 +38,12 @@ func TestRequestAllowed(t *testing.T) {
if !RequestAllowed(perms, "POST", "/api/v1/jobs/x/cancel") {
t.Fatal("expected job cancel")
}
+ if !RequestAllowed(perms, "DELETE", "/api/v1/threads-accounts/acc-1") {
+ t.Fatal("expected threads account delete")
+ }
+ if !RequestAllowed(perms, "POST", "/api/v1/threads-accounts/acc-1/activate") {
+ t.Fatal("expected threads account activate")
+ }
}
func TestPathAllowedListRoot(t *testing.T) {
diff --git a/backend/internal/library/personacopy/formula_draft.go b/backend/internal/library/personacopy/formula_draft.go
new file mode 100644
index 0000000..e83795e
--- /dev/null
+++ b/backend/internal/library/personacopy/formula_draft.go
@@ -0,0 +1,161 @@
+package personacopy
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ libformula "haixun-backend/internal/library/formula"
+ "haixun-backend/internal/library/placement"
+ "haixun-backend/internal/library/style8d"
+ "haixun-backend/internal/library/websearch"
+ domai "haixun-backend/internal/model/ai/domain/usecase"
+ aiusecase "haixun-backend/internal/model/ai/usecase"
+ contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase"
+ copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity"
+ copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
+ personadomain "haixun-backend/internal/model/persona/domain/usecase"
+ placementusecase "haixun-backend/internal/model/placement/usecase"
+ threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
+)
+
+type FormulaDraftInput struct {
+ TenantID string
+ OwnerUID string
+ PersonaID string
+ AccountID string
+ FormulaID string
+ Topic string
+ Brief string
+ UseWebSearch bool
+ DraftCount int
+}
+
+type FormulaDraftDeps struct {
+ Persona personadomain.UseCase
+ ContentFormula contentformuladomain.UseCase
+ CopyDraft copydraftdomain.UseCase
+ ThreadsAccount threadsaccountdomain.UseCase
+ Placement placementusecase.UseCase
+ AI domai.UseCase
+}
+
+func RunFormulaDraft(ctx context.Context, deps FormulaDraftDeps, in FormulaDraftInput, progress ProgressFn) (int, error) {
+ if progress == nil {
+ progress = func(string, int) {}
+ }
+ topic := strings.TrimSpace(in.Topic)
+ if topic == "" {
+ return 0, fmt.Errorf("topic is required")
+ }
+ count := in.DraftCount
+ if count <= 0 {
+ count = 1
+ }
+ if count > 5 {
+ count = 5
+ }
+
+ progress("讀取人設與寫法公式…", 10)
+ persona, err := deps.Persona.Get(ctx, in.TenantID, in.OwnerUID, in.PersonaID)
+ if err != nil {
+ return 0, err
+ }
+ if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
+ return 0, fmt.Errorf("請先完成人設 8D 對標分析")
+ }
+ formula, err := deps.ContentFormula.Get(ctx, in.TenantID, in.OwnerUID, in.AccountID, in.FormulaID)
+ if err != nil {
+ return 0, err
+ }
+ credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, in.TenantID, in.OwnerUID)
+ if err != nil {
+ return 0, err
+ }
+ providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
+ if err != nil {
+ return 0, err
+ }
+ aiReq := domai.GenerateRequest{
+ Provider: providerID,
+ Model: credential.Model,
+ Credential: domai.Credential{APIKey: credential.APIKey},
+ }
+ personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
+
+ researchNotes := ""
+ if in.UseWebSearch {
+ progress("搜尋網路參考資料…", 20)
+ researchNotes = searchFormulaNotes(ctx, deps, in.TenantID, in.OwnerUID, topic, in.Brief)
+ }
+
+ created := 0
+ for i := 0; i < count; i++ {
+ progress(fmt.Sprintf("呼叫 AI 產生草稿 %d/%d(%s / %s)…", i+1, count, credential.Provider, credential.Model), 35+(i*40/count))
+ generated, genErr := libformula.GenerateDraft(ctx, deps.AI, aiReq, libformula.GenerateInput{
+ Topic: topic,
+ Brief: in.Brief,
+ PersonaBlock: personaBlock,
+ ResearchNotes: researchNotes,
+ Formula: *formula,
+ })
+ if genErr != nil {
+ return created, genErr
+ }
+ if _, saveErr := deps.CopyDraft.Create(ctx, copydraftdomain.CreateRequest{
+ TenantID: in.TenantID,
+ OwnerUID: in.OwnerUID,
+ PersonaID: in.PersonaID,
+ FormulaID: formula.ID,
+ DraftType: copydraftentity.DraftTypeFormula,
+ Text: generated.Text,
+ TopicTag: generated.TopicTag,
+ Angle: generated.Angle,
+ Hook: generated.Hook,
+ Rationale: generated.Rationale,
+ ReferenceNotes: generated.StructureNotes,
+ Sources: []string{formula.Label},
+ }); saveErr != nil {
+ return created, saveErr
+ }
+ created++
+ }
+ progress(fmt.Sprintf("已產生 %d 篇草稿", created), 100)
+ return created, nil
+}
+
+func searchFormulaNotes(ctx context.Context, deps FormulaDraftDeps, tenantID, uid, topic, brief string) string {
+ research, err := deps.Placement.ResearchSettings(ctx, tenantID, uid)
+ if err != nil || !placement.WebSearchAvailable(research) {
+ return ""
+ }
+ memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, uid, research)
+ if err != nil {
+ return ""
+ }
+ webClient := websearch.New(memberCtx.WebSearchConfig())
+ if !webClient.Enabled() {
+ return ""
+ }
+ resp, err := webClient.Search(ctx, websearch.SearchOptions{
+ Query: topic + " " + strings.TrimSpace(brief),
+ Limit: 5,
+ Mode: websearch.ModeKnowledgeExpand,
+ })
+ if err != nil || len(resp.Results) == 0 {
+ return ""
+ }
+ var b strings.Builder
+ for _, snip := range resp.Results {
+ if snip.Title != "" {
+ b.WriteString("- ")
+ b.WriteString(snip.Title)
+ b.WriteString("\n")
+ }
+ if snip.Snippet != "" {
+ b.WriteString(snip.Snippet)
+ b.WriteString("\n")
+ }
+ }
+ return strings.TrimSpace(b.String())
+}
diff --git a/backend/internal/library/personacopy/rewrite_draft.go b/backend/internal/library/personacopy/rewrite_draft.go
new file mode 100644
index 0000000..56bf638
--- /dev/null
+++ b/backend/internal/library/personacopy/rewrite_draft.go
@@ -0,0 +1,139 @@
+package personacopy
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ libformula "haixun-backend/internal/library/formula"
+ "haixun-backend/internal/library/style8d"
+ domai "haixun-backend/internal/model/ai/domain/usecase"
+ aiusecase "haixun-backend/internal/model/ai/usecase"
+ cfentity "haixun-backend/internal/model/content_formula/domain/entity"
+ contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase"
+ copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
+ personadomain "haixun-backend/internal/model/persona/domain/usecase"
+ placementusecase "haixun-backend/internal/model/placement/usecase"
+ threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
+)
+
+type RewriteDraftInput struct {
+ TenantID string
+ OwnerUID string
+ PersonaID string
+ AccountID string
+ ReferenceText string
+ Topic string
+ Brief string
+ SaveLabel string
+ UseWebSearch bool
+ DraftCount int
+}
+
+type RewriteDraftDeps struct {
+ Persona personadomain.UseCase
+ ContentFormula contentformuladomain.UseCase
+ CopyDraft copydraftdomain.UseCase
+ ThreadsAccount threadsaccountdomain.UseCase
+ Placement placementusecase.UseCase
+ AI domai.UseCase
+}
+
+func RunRewriteDraft(ctx context.Context, deps RewriteDraftDeps, in RewriteDraftInput, progress ProgressFn) (int, error) {
+ if progress == nil {
+ progress = func(string, int) {}
+ }
+ referenceText := strings.TrimSpace(in.ReferenceText)
+ topic := strings.TrimSpace(in.Topic)
+ if referenceText == "" {
+ return 0, fmt.Errorf("reference_text is required")
+ }
+ if topic == "" {
+ return 0, fmt.Errorf("topic is required")
+ }
+
+ progress("讀取人設…", 5)
+ persona, err := deps.Persona.Get(ctx, in.TenantID, in.OwnerUID, in.PersonaID)
+ if err != nil {
+ return 0, err
+ }
+ if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
+ return 0, fmt.Errorf("請先完成人設 8D 對標分析")
+ }
+ credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, in.TenantID, in.OwnerUID)
+ if err != nil {
+ return 0, err
+ }
+ providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
+ if err != nil {
+ return 0, err
+ }
+ aiReq := domai.GenerateRequest{
+ Provider: providerID,
+ Model: credential.Model,
+ Credential: domai.Credential{APIKey: credential.APIKey},
+ }
+ personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
+
+ progress(fmt.Sprintf("分析參考貼文寫法(%s / %s)…", credential.Provider, credential.Model), 15)
+ analysis, err := libformula.AnalyzePasted(ctx, deps.AI, aiReq, libformula.AnalyzePastedInput{
+ PostText: referenceText,
+ PersonaBlock: personaBlock,
+ })
+ if err != nil {
+ return 0, fmt.Errorf("分析參考貼文失敗:%w", err)
+ }
+ if analysis == nil {
+ return 0, fmt.Errorf("分析未產出有效寫法公式")
+ }
+
+ label := strings.TrimSpace(in.SaveLabel)
+ if label == "" {
+ label = fmt.Sprintf("貼文改寫 %s", time.Now().UTC().Format("2006-01-02"))
+ }
+ progress("存入寫法公式…", 30)
+ saved, err := deps.ContentFormula.Create(ctx, contentformuladomain.CreateRequest{
+ TenantID: in.TenantID,
+ OwnerUID: in.OwnerUID,
+ AccountID: in.AccountID,
+ Label: label,
+ SourceType: cfentity.SourcePaste,
+ SourceRef: "paste",
+ SourcePostText: referenceText,
+ Summary: analysis.Summary,
+ Wins: analysis.Wins,
+ Improvements: analysis.Improvements,
+ Formula: analysis.Formula,
+ PostTemplate: analysis.PostTemplate,
+ HookPattern: analysis.HookPattern,
+ Structure: analysis.Structure,
+ ReplicationTips: analysis.ReplicationTips,
+ Avoid: analysis.Avoid,
+ })
+ if err != nil {
+ return 0, err
+ }
+
+ return RunFormulaDraft(ctx, FormulaDraftDeps{
+ Persona: deps.Persona,
+ ContentFormula: deps.ContentFormula,
+ CopyDraft: deps.CopyDraft,
+ ThreadsAccount: deps.ThreadsAccount,
+ Placement: deps.Placement,
+ AI: deps.AI,
+ }, FormulaDraftInput{
+ TenantID: in.TenantID,
+ OwnerUID: in.OwnerUID,
+ PersonaID: in.PersonaID,
+ AccountID: in.AccountID,
+ FormulaID: saved.ID,
+ Topic: topic,
+ Brief: in.Brief,
+ UseWebSearch: in.UseWebSearch,
+ DraftCount: in.DraftCount,
+ }, func(summary string, percentage int) {
+ adjusted := 35 + (percentage * 65 / 100)
+ progress(summary, adjusted)
+ })
+}
diff --git a/backend/internal/library/personacopy/topic_matrix.go b/backend/internal/library/personacopy/topic_matrix.go
new file mode 100644
index 0000000..9fc2453
--- /dev/null
+++ b/backend/internal/library/personacopy/topic_matrix.go
@@ -0,0 +1,321 @@
+package personacopy
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "haixun-backend/internal/library/copyvoice"
+ libmatrix "haixun-backend/internal/library/matrix"
+ "haixun-backend/internal/library/placement"
+ "haixun-backend/internal/library/style8d"
+ "haixun-backend/internal/library/websearch"
+ domai "haixun-backend/internal/model/ai/domain/usecase"
+ aiusecase "haixun-backend/internal/model/ai/usecase"
+ contentopsdomain "haixun-backend/internal/model/content_ops/domain/usecase"
+ copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity"
+ copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
+ personadomain "haixun-backend/internal/model/persona/domain/usecase"
+ placementusecase "haixun-backend/internal/model/placement/usecase"
+ threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
+)
+
+type TopicMatrixInput struct {
+ TenantID string
+ OwnerUID string
+ PersonaID string
+ ContentPlanID string
+ Topic string
+ Brief string
+ UseWebSearch bool
+ DraftCount int
+}
+
+type TopicMatrixDeps struct {
+ Persona personadomain.UseCase
+ CopyDraft copydraftdomain.UseCase
+ ContentOps contentopsdomain.UseCase
+ ThreadsAccount threadsaccountdomain.UseCase
+ Placement placementusecase.UseCase
+ AI domai.UseCase
+}
+
+type ProgressFn func(summary string, percentage int)
+
+func RunTopicMatrix(ctx context.Context, deps TopicMatrixDeps, in TopicMatrixInput, progress ProgressFn) ([]copydraftdomain.CopyDraftSummary, error) {
+ if progress == nil {
+ progress = func(string, int) {}
+ }
+ topic := strings.TrimSpace(in.Topic)
+ if topic == "" {
+ return nil, fmt.Errorf("topic is required")
+ }
+ count := in.DraftCount
+ if count <= 0 {
+ count = 3
+ }
+ if count < 1 {
+ count = 1
+ }
+ if count > 5 {
+ count = 5
+ }
+
+ progress("讀取人設語氣…", 10)
+ persona, err := deps.Persona.Get(ctx, in.TenantID, in.OwnerUID, in.PersonaID)
+ if err != nil {
+ return nil, err
+ }
+ credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, in.TenantID, in.OwnerUID)
+ if err != nil {
+ return nil, err
+ }
+ providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
+ if err != nil {
+ return nil, err
+ }
+
+ researchNotes := ""
+ if in.UseWebSearch {
+ progress("搜尋網路參考資料…", 25)
+ researchNotes = searchTopicNotes(ctx, deps, in.TenantID, in.OwnerUID, topic, in.Brief)
+ }
+ personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
+ feedbackNotes := recentFeedbackNotes(ctx, deps, in.TenantID, in.OwnerUID, in.PersonaID)
+ knowledgeNotes := recentKnowledgeNotes(ctx, deps, in.TenantID, in.OwnerUID, in.PersonaID, topic)
+ formulaNotes := formulaPoolNotes(ctx, deps, in.TenantID, in.OwnerUID, in.PersonaID)
+
+ progress(fmt.Sprintf("呼叫 AI 產生 %d 篇草稿(%s / %s)…", count, credential.Provider, credential.Model), 45)
+ parsed, err := libmatrix.GenerateCopyOutput(ctx, deps.AI, domai.GenerateRequest{
+ Provider: providerID,
+ Model: credential.Model,
+ Credential: domai.Credential{APIKey: credential.APIKey},
+ System: TopicMatrixSystemPrompt(),
+ Messages: []domai.Message{{Role: "user", Content: TopicMatrixUserPrompt(topic, in.Brief, personaBlock, researchNotes, knowledgeNotes, formulaNotes, feedbackNotes, count)}},
+ }, count)
+ if err != nil {
+ return nil, err
+ }
+
+ progress("寫入草稿…", 85)
+ created := make([]copydraftdomain.CopyDraftSummary, 0, len(parsed.Rows))
+ for _, row := range parsed.Rows {
+ saved, saveErr := deps.CopyDraft.Create(ctx, copydraftdomain.CreateRequest{
+ TenantID: in.TenantID,
+ OwnerUID: in.OwnerUID,
+ PersonaID: in.PersonaID,
+ ContentPlanID: in.ContentPlanID,
+ DraftType: copydraftentity.DraftTypeMatrix,
+ SortOrder: row.SortOrder,
+ Text: row.Text,
+ TopicTag: row.SearchTag,
+ Angle: row.Angle,
+ Hook: row.Hook,
+ Rationale: row.Rationale,
+ ReferenceNotes: row.ReferenceNotes,
+ Sources: row.SourcePermalinks,
+ AiScore: row.AiScore,
+ FormulaScore: row.FormulaScore,
+ BrandFitScore: row.BrandFitScore,
+ RiskScore: row.RiskScore,
+ SimilarityScore: row.SimilarityScore,
+ EngagementPotential: row.EngagementPotential,
+ FreshnessScore: row.FreshnessScore,
+ ReviewSuggestion: row.ReviewSuggestion,
+ })
+ if saveErr != nil {
+ return created, saveErr
+ }
+ created = append(created, *saved)
+ }
+ progress(fmt.Sprintf("已產生 %d 篇草稿", len(created)), 100)
+ return created, nil
+}
+
+func recentFeedbackNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, ownerUID, personaID string) string {
+ if deps.ContentOps == nil {
+ return ""
+ }
+ items, err := deps.ContentOps.ListFeedbackEvents(ctx, tenantID, ownerUID, personaID, 8)
+ if err != nil || len(items) == 0 {
+ return ""
+ }
+ var b strings.Builder
+ for _, item := range items {
+ decision := strings.TrimSpace(item.Decision)
+ note := strings.TrimSpace(item.Note)
+ if decision == "" && note == "" {
+ continue
+ }
+ b.WriteString("- ")
+ if decision != "" {
+ b.WriteString(decision)
+ }
+ if note != "" {
+ b.WriteString(":")
+ b.WriteString(note)
+ }
+ b.WriteString("\n")
+ }
+ return strings.TrimSpace(b.String())
+}
+
+func recentKnowledgeNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, ownerUID, personaID, topic string) string {
+ if deps.ContentOps == nil {
+ return ""
+ }
+ items, err := deps.ContentOps.ListKnowledgeChunks(ctx, tenantID, ownerUID, personaID, 12)
+ if err != nil {
+ return ""
+ }
+ var b strings.Builder
+ for _, item := range items {
+ content := strings.TrimSpace(item.Content)
+ if content == "" {
+ continue
+ }
+ b.WriteString("- ")
+ b.WriteString(shorten(content, 220))
+ if item.RiskLevel != "" {
+ b.WriteString("(風險:")
+ b.WriteString(item.RiskLevel)
+ b.WriteString(")")
+ }
+ b.WriteString("\n")
+ }
+ return strings.TrimSpace(b.String())
+}
+
+func formulaPoolNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, ownerUID, personaID string) string {
+ if deps.ContentOps == nil {
+ return ""
+ }
+ items, err := deps.ContentOps.ListFormulaPools(ctx, tenantID, ownerUID, personaID, 12)
+ if err != nil {
+ return ""
+ }
+ var b strings.Builder
+ for _, item := range items {
+ b.WriteString("- ")
+ b.WriteString(item.Type)
+ b.WriteString(" / ")
+ b.WriteString(item.Name)
+ if item.Pattern != "" {
+ b.WriteString(":")
+ b.WriteString(item.Pattern)
+ }
+ if len(item.Avoid) > 0 {
+ b.WriteString(";避免:")
+ b.WriteString(strings.Join(item.Avoid, "、"))
+ }
+ b.WriteString("\n")
+ }
+ return strings.TrimSpace(b.String())
+}
+
+func shorten(raw string, max int) string {
+ r := []rune(strings.TrimSpace(raw))
+ if len(r) <= max {
+ return string(r)
+ }
+ return string(r[:max]) + "..."
+}
+
+func searchTopicNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, uid, topic, brief string) string {
+ research, err := deps.Placement.ResearchSettings(ctx, tenantID, uid)
+ if err != nil || !placement.WebSearchAvailable(research) {
+ return ""
+ }
+ memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, uid, research)
+ if err != nil {
+ return ""
+ }
+ webClient := websearch.New(memberCtx.WebSearchConfig())
+ if !webClient.Enabled() {
+ return ""
+ }
+ resp, err := webClient.Search(ctx, websearch.SearchOptions{
+ Query: strings.TrimSpace(topic + " " + brief),
+ Limit: 5,
+ Mode: websearch.ModeKnowledgeExpand,
+ })
+ if err != nil || len(resp.Results) == 0 {
+ return ""
+ }
+ var b strings.Builder
+ for _, item := range resp.Results {
+ if item.Title != "" {
+ b.WriteString("- ")
+ b.WriteString(item.Title)
+ b.WriteString("\n")
+ }
+ if item.Snippet != "" {
+ b.WriteString(item.Snippet)
+ b.WriteString("\n")
+ }
+ if item.URL != "" {
+ b.WriteString(item.URL)
+ b.WriteString("\n")
+ }
+ }
+ return strings.TrimSpace(b.String())
+}
+
+func TopicMatrixSystemPrompt() string {
+ return strings.TrimSpace(`你是 Threads / Instagram 貼文編輯。請依人設語言指紋產出自然、可直接發布的繁體中文貼文矩陣。
+
+` + copyvoice.SystemRules() + `
+
+規則:
+- 不要每篇都用同一套 hook / 三段式 / 結尾問句。
+- 不要企業八股、不要像教科書、不要一直喊口號。
+- 人設定位決定誰在說,語言指紋決定怎麼說;必須遵守段落節奏、標點換行、知識轉譯與 CTA 習慣。
+- 不要把人設介紹裡的物件/標籤硬寫進正文情境。例:人設寫 Y2K、咖啡、穿搭,只代表語感與審美,不代表每篇都要出現老歌、咖啡、穿搭、MV。
+- 正文內容必須由【話題】與【補充】決定;人設不得蓋過主題。
+- 每篇要有不同角度;長度與格式依人設習慣和主題型態決定,不要強制短文。
+- 每次至少產出 3 種版本思路:穩定帳號風格、更有共鳴、更容易互動;若 count 超過 3,再補更短或更反差版本。
+- text 只能放貼文正文,不要放標題、分析、公式、模板或說明。
+- text 排版:像真人手機發文;可使用自然標點、空行、列點與少量 emoji。不要完全無標點,也不要每句硬換行,除非人設樣本就是這樣。
+- 只回傳 JSON 物件:{"rows":[...]}。
+- 每筆 row 必須包含 sort_order, search_tag, angle, hook, text, reference_notes, source_permalinks, rationale, ai_score, formula_score, brand_fit_score, risk_score, similarity_score, engagement_potential, freshness_score, review_suggestion。
+- 分數欄位都是 0-100 整數:ai_score / formula_score / risk_score / similarity_score 越低越好;brand_fit_score / engagement_potential / freshness_score 越高越好。
+- review_suggestion 只能是「可通過」「需人工確認」「建議重寫」之一。
+- rationale 必須用繁中短列點包含:版本定位、AI感分數(0-100,越高越AI)、公式感分數(0-100)、人設符合度(0-100)、風險分數(0-100)、互動潛力(0-100)、審核建議(可通過/需人工確認/建議重寫)與原因。`)
+}
+
+func TopicMatrixUserPrompt(topic, brief, personaBlock, researchNotes, knowledgeNotes, formulaNotes, feedbackNotes string, count int) string {
+ var b strings.Builder
+ b.WriteString("請產生 ")
+ b.WriteString(fmt.Sprintf("%d", count))
+ b.WriteString(" 篇 Threads 草稿。\n\n【話題】\n")
+ b.WriteString(strings.TrimSpace(topic))
+ if brief = strings.TrimSpace(brief); brief != "" {
+ b.WriteString("\n\n【補充】\n")
+ b.WriteString(brief)
+ }
+ if personaBlock = strings.TrimSpace(personaBlock); personaBlock != "" {
+ b.WriteString("\n\n【人設定位、語言指紋與禁忌(最高優先)】\n")
+ b.WriteString(personaBlock)
+ b.WriteString("\n\n")
+ b.WriteString(copyvoice.PersonaCalibrationNote())
+ }
+ if researchNotes = strings.TrimSpace(researchNotes); researchNotes != "" {
+ b.WriteString("\n\n【網路資料參考】\n")
+ b.WriteString(researchNotes)
+ }
+ if knowledgeNotes = strings.TrimSpace(knowledgeNotes); knowledgeNotes != "" {
+ b.WriteString("\n\n【Knowledge Memory:帳號知識來源,優先採用但不要硬塞】\n")
+ b.WriteString(knowledgeNotes)
+ }
+ if formulaNotes = strings.TrimSpace(formulaNotes); formulaNotes != "" {
+ b.WriteString("\n\n【Formula Pool:可選公式池,請依 Content Plan 選擇,不要每篇都套同一套】\n")
+ b.WriteString(formulaNotes)
+ }
+ if feedbackNotes = strings.TrimSpace(feedbackNotes); feedbackNotes != "" {
+ b.WriteString("\n\n【最近人工審核回饋(Feedback Memory,必須吸收)】\n")
+ b.WriteString(feedbackNotes)
+ }
+ b.WriteString("\n\n【品質閘門】\n請先自我檢查 AI 感、公式感、事實風險、人設一致性、與過去常見句型重複。高風險內容不得建議直接通過。")
+ b.WriteString("\n\n請讓每篇的 angle 明確不同,search_tag 是短主題標籤不含 #。source_permalinks 可放參考 URL,沒有就空陣列。")
+ return b.String()
+}
diff --git a/backend/internal/library/placement/capabilities.go b/backend/internal/library/placement/capabilities.go
index 24fd855..424bdf8 100644
--- a/backend/internal/library/placement/capabilities.go
+++ b/backend/internal/library/placement/capabilities.go
@@ -46,8 +46,5 @@ func publishCapabilityHint(member MemberContext) string {
if strings.TrimSpace(member.ActiveAccountID) == "" {
return "請先建立並選定經營帳號"
}
- if member.DevMode && !member.AllowsThreadsAPI {
- return "開發模式請在連線設定啟用 API 發文"
- }
return "請先完成 Threads API 連線後再發布貼文"
}
diff --git a/backend/internal/library/placement/context.go b/backend/internal/library/placement/context.go
index 86e0ce6..3b128ae 100644
--- a/backend/internal/library/placement/context.go
+++ b/backend/internal/library/placement/context.go
@@ -101,6 +101,27 @@ func BuildMemberContext(
}
}
+// MemberContextForAPIOnly forces Threads / web-search discover and disables Playwright routing.
+// Used for all flows except explicit test-patrol jobs.
+func MemberContextForAPIOnly(base MemberContext) MemberContext {
+ base.DevMode = false
+ base.SearchSourceMode = WithoutCrawler(base.SearchSourceMode)
+ base.AllowsCrawler = false
+ base.AllowsThreadsAPI = ModeAllowsThreadsAPI(base.SearchSourceMode)
+ base.AllowsBrave = ModeAllowsBrave(base.SearchSourceMode)
+ return base
+}
+
+// MemberContextForCrawlerOnly forces Playwright-only discover for test patrol jobs.
+func MemberContextForCrawlerOnly(base MemberContext) MemberContext {
+ base.DevMode = true
+ base.SearchSourceMode = SearchSourceCrawler
+ base.AllowsCrawler = true
+ base.AllowsBrave = false
+ base.AllowsThreadsAPI = false
+ return base
+}
+
func (c MemberContext) PayloadFields() map[string]any {
return map[string]any{
"tenant_id": c.TenantID,
diff --git a/backend/internal/library/placement/context_api_only_test.go b/backend/internal/library/placement/context_api_only_test.go
new file mode 100644
index 0000000..ad37a6b
--- /dev/null
+++ b/backend/internal/library/placement/context_api_only_test.go
@@ -0,0 +1,33 @@
+package placement
+
+import "testing"
+
+func TestMemberContextForAPIOnlyDisablesCrawler(t *testing.T) {
+ base := BuildMemberContext(
+ "t", "u", "acc",
+ ConnectionPrefsInput{SearchSourceMode: string(SearchSourceMixed)},
+ true, true, ResearchSettings{}, false, 10,
+ )
+ apiOnly := MemberContextForAPIOnly(base)
+ if apiOnly.AllowsCrawler {
+ t.Fatal("API-only context must not allow crawler")
+ }
+ if apiOnly.DevMode {
+ t.Fatal("API-only context must not be dev mode")
+ }
+ if !apiOnly.AllowsThreadsAPI {
+ t.Fatal("API-only context should keep Threads API")
+ }
+}
+
+func TestMemberContextForCrawlerOnlyStillWorks(t *testing.T) {
+ base := MemberContextForAPIOnly(BuildMemberContext(
+ "t", "u", "acc",
+ ConnectionPrefsInput{SearchSourceMode: string(SearchSourceMixed)},
+ true, true, ResearchSettings{}, false, 10,
+ ))
+ crawler := MemberContextForCrawlerOnly(base)
+ if !crawler.AllowsCrawler || crawler.SearchSourceMode != SearchSourceCrawler {
+ t.Fatal("test patrol should force crawler-only routing")
+ }
+}
diff --git a/backend/internal/library/placement/crawler_exec.go b/backend/internal/library/placement/crawler_exec.go
index c74a630..6a03905 100644
--- a/backend/internal/library/placement/crawler_exec.go
+++ b/backend/internal/library/placement/crawler_exec.go
@@ -5,6 +5,8 @@ import (
"context"
"encoding/json"
"fmt"
+ "io"
+ "net/http"
"os"
"os/exec"
"path/filepath"
@@ -49,6 +51,9 @@ func RunExecCrawlerSearch(ctx context.Context, storageState, keyword string, lim
if limit <= 0 {
limit = 12
}
+ if endpoint := strings.TrimSpace(os.Getenv("HAIXUN_KEYWORD_SEARCH_URL")); endpoint != "" {
+ return runHTTPCrawlerSearch(ctx, endpoint, storageState, keyword, limit)
+ }
repoRoot, cliPath, err := resolveKeywordSearchCLI()
if err != nil {
@@ -86,8 +91,41 @@ func RunExecCrawlerSearch(ctx context.Context, storageState, keyword string, lim
return nil, fmt.Errorf("crawler search output parse failed: %w", err)
}
- posts := make([]DiscoverPost, 0, len(out.Posts))
- for _, item := range out.Posts {
+ return execCrawlerPostsToDiscover(out.Posts), nil
+}
+
+func runHTTPCrawlerSearch(ctx context.Context, endpoint, storageState, keyword string, limit int) ([]DiscoverPost, error) {
+ payload, err := json.Marshal(execCrawlerInput{StorageState: storageState, Query: keyword, Limit: limit})
+ if err != nil {
+ return nil, err
+ }
+ runCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
+ defer cancel()
+ url := strings.TrimRight(endpoint, "/") + "/search"
+ req, err := http.NewRequestWithContext(runCtx, http.MethodPost, url, bytes.NewReader(payload))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("crawler search service failed: %w", err)
+ }
+ defer res.Body.Close()
+ body, _ := io.ReadAll(res.Body)
+ if res.StatusCode < 200 || res.StatusCode >= 300 {
+ return nil, fmt.Errorf("crawler search service returned HTTP %d: %s", res.StatusCode, strings.TrimSpace(string(body)))
+ }
+ var out execCrawlerOutput
+ if err := json.Unmarshal(body, &out); err != nil {
+ return nil, fmt.Errorf("crawler search service output parse failed: %w", err)
+ }
+ return execCrawlerPostsToDiscover(out.Posts), nil
+}
+
+func execCrawlerPostsToDiscover(items []execCrawlerPost) []DiscoverPost {
+ posts := make([]DiscoverPost, 0, len(items))
+ for _, item := range items {
text := strings.TrimSpace(item.Text)
if text == "" {
continue
@@ -107,7 +145,7 @@ func RunExecCrawlerSearch(ctx context.Context, storageState, keyword string, lim
Source: DiscoverCrawler,
})
}
- return posts, nil
+ return posts
}
func resolveKeywordSearchCLI() (repoRoot, cliPath string, err error) {
diff --git a/backend/internal/library/placement/discover.go b/backend/internal/library/placement/discover.go
index d317506..845d2cc 100644
--- a/backend/internal/library/placement/discover.go
+++ b/backend/internal/library/placement/discover.go
@@ -49,13 +49,13 @@ func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, Discove
if ShouldTryCrawlerFirst(m) {
posts, err := runCrawlerDiscover(ctx, req)
if err == nil && len(posts) > 0 {
- return posts, DiscoverCrawler, nil
+ return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
}
if m.SearchSourceMode == SearchSourceCrawler {
if err != nil {
return nil, DiscoverCrawler, err
}
- return posts, DiscoverCrawler, nil
+ return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
}
}
@@ -73,7 +73,7 @@ func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, Discove
if m.CrawlerFallbackAllowed() {
cPosts, cErr := runCrawlerDiscover(ctx, req)
if cErr == nil && len(cPosts) > 0 {
- return cPosts, DiscoverCrawler, nil
+ return maybeEnrichDiscoverPosts(ctx, m, cPosts), DiscoverCrawler, nil
}
}
if !m.AllowsBrave && !m.CrawlerFallbackAllowed() {
@@ -89,7 +89,7 @@ func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, Discove
if m.CrawlerFallbackAllowed() {
posts, err := runCrawlerDiscover(ctx, req)
if err == nil {
- return posts, DiscoverCrawler, nil
+ return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
}
}
return nil, "", fmt.Errorf("請在設定頁設定 %s Search API key(跟隨此登入帳號)", m.WebSearchProviderLabel())
@@ -102,8 +102,15 @@ func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, Discove
if err != nil {
return nil, DiscoverCrawler, err
}
- return posts, DiscoverCrawler, nil
+ return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
}
return nil, "", fmt.Errorf("目前搜尋來源模式無可用管道:%s", m.SearchSourceMode)
}
+
+func maybeEnrichDiscoverPosts(ctx context.Context, m MemberContext, posts []DiscoverPost) []DiscoverPost {
+ if !m.ApiConnected {
+ return posts
+ }
+ return EnrichDiscoverPostsMediaIDs(ctx, m.ThreadsAPIAccessToken, posts)
+}
diff --git a/backend/internal/library/placement/discover_for_query_test.go b/backend/internal/library/placement/discover_for_query_test.go
new file mode 100644
index 0000000..1f6b30b
--- /dev/null
+++ b/backend/internal/library/placement/discover_for_query_test.go
@@ -0,0 +1,69 @@
+package placement
+
+import (
+ "context"
+ "testing"
+
+ libkg "haixun-backend/internal/library/knowledge"
+ "haixun-backend/internal/library/websearch"
+)
+
+type stubWebSearch struct {
+ enabled bool
+ posts []DiscoverPost
+}
+
+func (s stubWebSearch) Search(ctx context.Context, opts websearch.SearchOptions) (websearch.SearchResponse, error) {
+ _ = ctx
+ _ = opts
+ results := make([]websearch.SearchResult, 0, len(s.posts))
+ for _, post := range s.posts {
+ results = append(results, websearch.SearchResult{
+ Title: post.Text,
+ URL: post.Permalink,
+ Snippet: "",
+ })
+ }
+ return websearch.SearchResponse{Status: "success", Results: results, Provider: websearch.ProviderBrave}, nil
+}
+
+func (s stubWebSearch) Enabled() bool { return s.enabled }
+func (s stubWebSearch) Provider() websearch.Provider { return websearch.ProviderBrave }
+
+func TestDiscoverForQueryMergesWebSearchWhenBraveEnabled(t *testing.T) {
+ input := DualTrackInput{
+ Member: MemberContext{
+ AllowsBrave: true,
+ AllowsThreadsAPI: false,
+ SearchSourceMode: SearchSourceBrave,
+ WebSearchProvider: string(websearch.ProviderBrave),
+ BraveAPIKey: "test-key",
+ },
+ WebSearch: stubWebSearch{
+ enabled: true,
+ posts: []DiscoverPost{{
+ Text: "最近洗衣精有推薦嗎?皮膚會癢",
+ Permalink: "https://www.threads.net/@user1/post/abc123",
+ }},
+ },
+ PatrolContext: PostScanContextFromPatrolInput(libkg.PatrolTagInput{
+ ProductName: "天然洗衣精",
+ MatchTags: []string{"洗衣精"},
+ }, nil),
+ }
+ tq := TagQuery{
+ Tag: "洗衣精 推薦",
+ Query: `site:threads.net 洗衣精 推薦`,
+ Dimension: QueryRelevance,
+ }
+ posts, channel, err := discoverForQuery(context.Background(), input, tq, 5)
+ if err != nil {
+ t.Fatalf("discoverForQuery error: %v", err)
+ }
+ if len(posts) == 0 {
+ t.Fatal("expected web search posts to be merged")
+ }
+ if channel != DiscoverBrave {
+ t.Fatalf("expected brave channel, got %s", channel)
+ }
+}
diff --git a/backend/internal/library/placement/dual_track.go b/backend/internal/library/placement/dual_track.go
index 4f37509..bedbaae 100644
--- a/backend/internal/library/placement/dual_track.go
+++ b/backend/internal/library/placement/dual_track.go
@@ -24,6 +24,7 @@ type ScanCandidate struct {
Text string
SearchTag string
QueryDimension QueryDimension
+ RecencyDays int
GraphNodeID string
ProductFitScore int
Source DiscoverChannel
@@ -50,6 +51,7 @@ type DualTrackInput struct {
Nodes []libkg.Node
PatrolKeywords []string
Exclusions []string
+ PatrolContext PostScanContext
Member MemberContext
WebSearch websearch.Client
Crawler CrawlerSearchFn
@@ -104,17 +106,6 @@ func CollectTagQueries(nodes []libkg.Node, provider websearch.Provider) []TagQue
RecencyDays: IdealMaxPostAgeDays,
})
}
- q30 := BuildRecencyQuery(provider, tag, MaxPostAgeDays)
- if q30 != "" && q30 != q7 {
- out = append(out, TagQuery{
- Tag: tag,
- Query: q30,
- Dimension: QueryRecency,
- GraphNodeID: node.ID,
- ProductFitScore: fit,
- RecencyDays: MaxPostAgeDays,
- })
- }
}
}
return out
@@ -122,7 +113,12 @@ func CollectTagQueries(nodes []libkg.Node, provider websearch.Provider) []TagQue
// RunDualTrackDiscover executes relevance + recency queries and merges by permalink.
func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress DualTrackProgress) ([]ScanCandidate, error) {
- queries := ResolveTagQueries(input.Nodes, input.PatrolKeywords, input.Member.WebSearchProviderEnum())
+ queries := ResolveTagQueries(
+ input.Nodes,
+ input.PatrolKeywords,
+ input.Member.WebSearchProviderEnum(),
+ PatrolTagInputFromScanContext(input.PatrolContext),
+ )
if len(queries) == 0 {
if len(input.PatrolKeywords) > 0 {
return nil, fmt.Errorf("海巡關鍵字格式無效,請改用 2~8 字的真人搜尋短句")
@@ -145,15 +141,31 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
runQuery := func(tq TagQuery, limit int) error {
posts, channel, err := discoverForQuery(ctx, input, tq, limit)
if err != nil {
- return err
+ if onProgress != nil {
+ onProgress(fmt.Sprintf("略過「%s」:%s", tq.Tag, err.Error()), -1)
+ }
+ return nil
+ }
+ if len(posts) == 0 {
+ return nil
}
for _, post := range posts {
if MatchesExclusion(post.Text, input.Exclusions) {
continue
}
- if !PassesPlacementFilter(post.Text) {
+ if LooksLikeCasualChat(post.Text) {
continue
}
+ postedAt := strings.TrimSpace(post.PostedAt)
+ if !PassesDiscoverFilters(post.Text, tq.Tag, postedAt, tq.Dimension, tq.ProductFitScore, tq.RecencyDays, input.PatrolContext) {
+ continue
+ }
+ bodyFit := ScorePostBodyProductFit(post.Text, input.PatrolContext)
+ semanticScore := LocalIntentScore(post.Text, input.PatrolContext)
+ effectiveFit := bodyFit
+ if tq.ProductFitScore > 0 && bodyFit > 0 {
+ effectiveFit = (tq.ProductFitScore + bodyFit*2) / 3
+ }
key := post.Permalink
if key == "" {
continue
@@ -170,6 +182,7 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
extID = parsed.ExternalID
}
}
+ semP := &semanticScore
merged[key] = &ScanCandidate{
Permalink: post.Permalink,
ExternalID: extID,
@@ -179,17 +192,19 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
Text: post.Text,
SearchTag: tq.Tag,
QueryDimension: tq.Dimension,
+ RecencyDays: recencyDaysForCandidate(tq),
GraphNodeID: tq.GraphNodeID,
- ProductFitScore: tq.ProductFitScore,
+ ProductFitScore: effectiveFit,
Source: channel,
HasRelevance: tq.Dimension == QueryRelevance,
HasRecency: tq.Dimension == QueryRecency,
Priority: priority,
LikeCount: post.LikeCount,
ReplyCount: post.ReplyCount,
- PlacementScore: computePlacementScore(post.Text, tq.ProductFitScore, tq.Dimension == QueryRecency, nil, nil, nil),
- SolvedByProduct: tq.ProductFitScore >= 55,
- PostedAt: strings.TrimSpace(post.PostedAt),
+ SemanticScore: semanticScore,
+ PlacementScore: computePlacementScore(post.Text, effectiveFit, tq.Dimension == QueryRecency, nil, semP, nil),
+ SolvedByProduct: PostSolvedByProduct(post.Text, effectiveFit, input.PatrolContext),
+ PostedAt: postedAt,
}
order = append(order, key)
continue
@@ -199,14 +214,27 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
}
if tq.Dimension == QueryRecency {
existing.HasRecency = true
+ existing.RecencyDays = mergeRecencyDays(existing.RecencyDays, tq.RecencyDays)
}
- if tq.ProductFitScore > existing.ProductFitScore {
- existing.ProductFitScore = tq.ProductFitScore
- existing.SolvedByProduct = tq.ProductFitScore >= 55
+ if effectiveFit > existing.ProductFitScore || semanticScore > existing.SemanticScore {
+ if effectiveFit > existing.ProductFitScore {
+ existing.ProductFitScore = effectiveFit
+ }
+ if semanticScore > existing.SemanticScore {
+ existing.SemanticScore = semanticScore
+ }
+ existing.SolvedByProduct = PostSolvedByProduct(existing.Text, existing.ProductFitScore, input.PatrolContext)
+ semP := existing.SemanticScore
+ semPtr := &semP
+ if semP <= 0 {
+ semPtr = nil
+ }
+ existing.PlacementScore = computePlacementScore(existing.Text, existing.ProductFitScore, existing.HasRecency, nil, semPtr, nil)
}
if strings.TrimSpace(existing.PostedAt) == "" && strings.TrimSpace(post.PostedAt) != "" {
existing.PostedAt = strings.TrimSpace(post.PostedAt)
}
+ existing.ExternalID = PreferReplyExternalID(existing.ExternalID, post.ExternalID)
}
return nil
}
@@ -217,15 +245,12 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
pct := 10 + ((i + 1) * 75 / max(total, 1))
onProgress(fmt.Sprintf("雙軌海巡 %d/%d:%s", i+1, total, tq.Tag), pct)
}
- limit := relevanceLimitPerTag
- if tq.Dimension == QueryRecency {
- limit = recencyLimitPerTag
- }
+ limit := perTagDiscoverLimit(total, tq.Dimension)
if err := runQuery(tq, limit); err != nil {
return nil, err
}
if input.OnCheckpoint != nil {
- snapshot := snapshotMergedCandidates(merged, order, false)
+ snapshot := snapshotMergedCandidates(merged, order, false, input.PatrolContext)
if err := input.OnCheckpoint(snapshot); err != nil {
return nil, err
}
@@ -237,7 +262,48 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
}
}
- out := snapshotMergedCandidates(merged, order, true)
+ cascadeExtra := 0
+ for _, st := range buildTagCascadeStates(queries) {
+ for countCandidatesForTag(merged, st.Tag) < MinRecencyCandidatesPerTag {
+ if cascadeExtra >= MaxRecencyCascadeQueries {
+ break
+ }
+ days := nextRecencyCascadeWindow(st.RanWindows)
+ if days == 0 {
+ break
+ }
+ tq, ok := buildRecencyCascadeQuery(st, days, input.Member.WebSearchProviderEnum())
+ if !ok {
+ st.RanWindows[days] = true
+ continue
+ }
+ if onProgress != nil {
+ onProgress(fmt.Sprintf("近 %d 天結果不足,改搜近 %d 天:%s", previousRecencyWindow(days), days, st.Tag), -1)
+ }
+ limit := perTagDiscoverLimit(total, QueryRecency)
+ if err := runQuery(tq, limit); err != nil {
+ return nil, err
+ }
+ st.RanWindows[days] = true
+ cascadeExtra++
+ if input.OnCheckpoint != nil {
+ snapshot := snapshotMergedCandidates(merged, order, false, input.PatrolContext)
+ if err := input.OnCheckpoint(snapshot); err != nil {
+ return nil, err
+ }
+ }
+ if input.Member.AllowsCrawler && input.Member.BrowserConnected {
+ if err := politeDiscoverPause(ctx); err != nil {
+ return nil, err
+ }
+ }
+ }
+ if cascadeExtra >= MaxRecencyCascadeQueries {
+ break
+ }
+ }
+
+ out := snapshotMergedCandidates(merged, order, true, input.PatrolContext)
if onProgress != nil {
onProgress(fmt.Sprintf("合併完成,共 %d 篇候選貼文", len(out)), 90)
}
@@ -245,32 +311,71 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
}
func discoverForQuery(ctx context.Context, input DualTrackInput, tq TagQuery, limit int) ([]DiscoverPost, DiscoverChannel, error) {
+ apiKeyword := tq.Tag
+ if shaped := libkg.ThreadsAPIKeyword(tq.Tag); shaped != "" {
+ apiKeyword = shaped
+ }
req := DiscoverRequest{
Query: tq.Query,
- Keyword: tq.Tag,
+ Keyword: apiKeyword,
Recency: tq.Dimension == QueryRecency,
Limit: limit,
Member: input.Member,
Crawler: input.Crawler,
}
- posts, channel, err := Discover(ctx, req)
- if err == nil && len(posts) > 0 {
- return posts, channel, nil
+ merged := map[string]DiscoverPost{}
+ channel := DiscoverChannel("")
+ posts, primaryChannel, err := Discover(ctx, req)
+ for _, post := range posts {
+ key := strings.TrimSpace(post.Permalink)
+ if key == "" {
+ continue
+ }
+ if existing, ok := merged[key]; ok {
+ post.ExternalID = PreferReplyExternalID(existing.ExternalID, post.ExternalID)
+ }
+ merged[key] = post
}
- if input.WebSearch == nil || !input.WebSearch.Enabled() {
+ if primaryChannel != "" {
+ channel = primaryChannel
+ }
+
+ webEnabled := input.WebSearch != nil && input.WebSearch.Enabled()
+ if webEnabled && input.Member.AllowsBrave {
+ webPosts, werr := discoverViaWebSearch(ctx, input.WebSearch, input.Member, tq, limit)
+ if werr != nil && len(merged) == 0 && err != nil {
+ return nil, "", err
+ }
+ for _, post := range webPosts {
+ key := strings.TrimSpace(post.Permalink)
+ if key == "" {
+ continue
+ }
+ merged[key] = post
+ }
+ if len(merged) > 0 && channel == "" {
+ channel = input.Member.WebSearchDiscoverChannel()
+ }
+ } else if len(merged) == 0 {
if err != nil {
return nil, "", err
}
- return nil, "", fmt.Errorf("%s 未設定且 Threads API 無結果", input.Member.WebSearchProviderLabel())
- }
- webPosts, werr := discoverViaWebSearch(ctx, input.WebSearch, input.Member, tq, limit)
- if werr != nil {
- if err != nil {
- return nil, "", err
+ if !webEnabled {
+ return nil, "", fmt.Errorf("%s 未設定且 Threads API 無結果", input.Member.WebSearchProviderLabel())
}
- return nil, "", werr
}
- return webPosts, input.Member.WebSearchDiscoverChannel(), nil
+
+ out := make([]DiscoverPost, 0, len(merged))
+ for _, post := range merged {
+ out = append(out, post)
+ }
+ if len(out) > limit {
+ out = out[:limit]
+ }
+ if channel == "" && len(out) > 0 {
+ channel = DiscoverThreadsAPI
+ }
+ return out, channel, nil
}
func discoverViaWebSearch(ctx context.Context, client websearch.Client, member MemberContext, tq TagQuery, limit int) ([]DiscoverPost, error) {
@@ -307,20 +412,50 @@ func discoverViaWebSearch(ctx context.Context, client websearch.Client, member M
return out, nil
}
-func snapshotMergedCandidates(merged map[string]*ScanCandidate, order []string, applyFinalFilter bool) []ScanCandidate {
+func snapshotMergedCandidates(merged map[string]*ScanCandidate, order []string, applyFinalFilter bool, ctx PostScanContext) []ScanCandidate {
out := make([]ScanCandidate, 0, len(order))
for _, key := range order {
item := merged[key]
- finalizeScanCandidate(item)
- if applyFinalFilter && item.ProductFitScore < 30 && item.Priority != "gold" {
+ finalizeScanCandidate(item, ctx)
+ if applyFinalFilter && !passesFinalStrictFilter(item) {
continue
}
out = append(out, *item)
}
+ if applyFinalFilter && len(out) == 0 && len(order) > 0 {
+ for _, key := range order {
+ item := merged[key]
+ finalizeScanCandidate(item, ctx)
+ if !PassesRelaxedFinalFilters(item.Text, item.ProductFitScore, ctx) {
+ continue
+ }
+ out = append(out, *item)
+ }
+ }
+ if applyFinalFilter && len(out) == 0 && len(order) > 0 {
+ for _, key := range order {
+ item := merged[key]
+ finalizeScanCandidate(item, ctx)
+ if !PassesIngestFallbackFilters(item.Text, item.ProductFitScore, ctx) {
+ continue
+ }
+ out = append(out, *item)
+ }
+ }
return out
}
-func finalizeScanCandidate(item *ScanCandidate) {
+func passesFinalStrictFilter(item *ScanCandidate) bool {
+ if item == nil {
+ return false
+ }
+ if item.ProductFitScore < minPostProductFitPatrol && item.Priority != "gold" {
+ return false
+ }
+ return item.SolvedByProduct
+}
+
+func finalizeScanCandidate(item *ScanCandidate, ctx PostScanContext) {
if item == nil {
return
}
@@ -345,7 +480,7 @@ func finalizeScanCandidate(item *ScanCandidate) {
qualP = &v
}
item.PlacementScore = computePlacementScore(item.Text, item.ProductFitScore, item.HasRecency, engP, semP, qualP)
- item.SolvedByProduct = item.ProductFitScore >= 55
+ item.SolvedByProduct = PostSolvedByProduct(item.Text, item.ProductFitScore, ctx)
}
func computePlacementScore(text string, productFit int, recent bool, predictedEngagement *int, semanticScore *int, audienceQuality *int) int {
@@ -413,6 +548,55 @@ func max(a, b int) int {
return b
}
+func recencyDaysForCandidate(tq TagQuery) int {
+ if tq.Dimension != QueryRecency {
+ return 0
+ }
+ if tq.RecencyDays > 0 {
+ return tq.RecencyDays
+ }
+ return IdealMaxPostAgeDays
+}
+
+func mergeRecencyDays(existing, incoming int) int {
+ if incoming <= 0 {
+ return existing
+ }
+ if existing <= 0 {
+ return incoming
+ }
+ if incoming < existing {
+ return incoming
+ }
+ return existing
+}
+
+func previousRecencyWindow(days int) int {
+ prev := IdealMaxPostAgeDays
+ for _, window := range RecencyCascadeDays() {
+ if window == days {
+ return prev
+ }
+ prev = window
+ }
+ return IdealMaxPostAgeDays
+}
+
+func perTagDiscoverLimit(totalQueries int, dimension QueryDimension) int {
+ limit := relevanceLimitPerTag
+ if dimension == QueryRecency {
+ limit = recencyLimitPerTag
+ }
+ switch {
+ case totalQueries > 18:
+ return max(7, limit-3)
+ case totalQueries > 14:
+ return max(8, limit-2)
+ default:
+ return limit
+ }
+}
+
func politeDiscoverPause(ctx context.Context) error {
wait := 2*time.Second + jitterDuration(2*time.Second)
timer := time.NewTimer(wait)
diff --git a/backend/internal/library/placement/external_id.go b/backend/internal/library/placement/external_id.go
new file mode 100644
index 0000000..1f52c8b
--- /dev/null
+++ b/backend/internal/library/placement/external_id.go
@@ -0,0 +1,50 @@
+package placement
+
+import (
+ "context"
+ "strings"
+
+ libthreads "haixun-backend/internal/library/threadsapi"
+)
+
+// PreferReplyExternalID keeps a numeric Threads media id when merging scan rows.
+func PreferReplyExternalID(existing, incoming string) string {
+ a := strings.TrimSpace(existing)
+ b := strings.TrimSpace(incoming)
+ if libthreads.IsNumericMediaID(a) {
+ return a
+ }
+ if libthreads.IsNumericMediaID(b) {
+ return b
+ }
+ if b != "" {
+ return b
+ }
+ return a
+}
+
+// EnrichDiscoverPostsMediaIDs resolves shortcode-only crawler rows via Threads API when possible.
+func EnrichDiscoverPostsMediaIDs(ctx context.Context, accessToken string, posts []DiscoverPost) []DiscoverPost {
+ token := strings.TrimSpace(accessToken)
+ if token == "" || len(posts) == 0 {
+ return posts
+ }
+ client := libthreads.NewClient(token)
+ out := make([]DiscoverPost, len(posts))
+ copy(out, posts)
+ for i := range out {
+ if libthreads.IsNumericMediaID(out[i].ExternalID) {
+ continue
+ }
+ id, err := client.ResolveReplyMedia(ctx, libthreads.ResolveReplyMediaInput{
+ ExternalID: out[i].ExternalID,
+ Permalink: out[i].Permalink,
+ HintText: out[i].Text,
+ })
+ if err != nil || !libthreads.IsNumericMediaID(id) {
+ continue
+ }
+ out[i].ExternalID = id
+ }
+ return out
+}
diff --git a/backend/internal/library/placement/external_id_test.go b/backend/internal/library/placement/external_id_test.go
new file mode 100644
index 0000000..03db367
--- /dev/null
+++ b/backend/internal/library/placement/external_id_test.go
@@ -0,0 +1,14 @@
+package placement
+
+import "testing"
+
+func TestPreferReplyExternalIDKeepsNumeric(t *testing.T) {
+ got := PreferReplyExternalID("18123456789012345", "AbCdEf123")
+ if got != "18123456789012345" {
+ t.Fatalf("got %q", got)
+ }
+ got = PreferReplyExternalID("AbCdEf123", "18123456789012345")
+ if got != "18123456789012345" {
+ t.Fatalf("got %q", got)
+ }
+}
diff --git a/backend/internal/library/placement/filter.go b/backend/internal/library/placement/filter.go
index 4d68aad..4fbdc31 100644
--- a/backend/internal/library/placement/filter.go
+++ b/backend/internal/library/placement/filter.go
@@ -5,6 +5,7 @@ import "regexp"
var (
placementRecommendRe = regexp.MustCompile(`推薦|求助|請益|請問|哪裡買|有沒有|求分享|困擾|煩惱|怎麼辦|怎麼選|不知道|有推|拜託|求救|卡關`)
placementIntentRe = regexp.MustCompile(`用什麼洗|哪款|哪牌|哪一牌|洗什麼|買什麼|在家洗|自己洗|洗澡怕|洗不乾淨|味道重|皮膚癢|皮膚紅|一直抓|掉毛多|敏感肌|過敏|紅腫|抓癢|異味|不敢洗|第一次洗|洗完還是|越洗越`)
+ painProblemRe = regexp.MustCompile(`痛|癢|乾|紅|腫|過敏|困擾|煩惱|難搞|不好用|沒效|無效|洗不掉|洗不乾淨|洗不乾|臭|異味|異味重|怕|擔心|問題|不舒服|刺激|脫皮|粗糙|暗沉|掉屑|掉毛|污漬|油漬|發炎`)
casualChatRe = regexp.MustCompile(`好可愛|太萌|晒照|日常分享|隨便發|廢文|路過|笑死|哈哈哈|哈囉|早安|晚安|按讚|追蹤我|純分享|沒有要問`)
)
@@ -35,3 +36,11 @@ func PassesPlacementFilter(text string) bool {
}
return HasPlacementIntent(text)
}
+
+// HasPainPointDemand requires the post to read like someone asking for help with a solvable problem.
+func HasPainPointDemand(text string) bool {
+ if !PassesPlacementFilter(text) {
+ return false
+ }
+ return painProblemRe.MatchString(text) || LooksLikeRecommendationPost(text)
+}
diff --git a/backend/internal/library/placement/media_resolve.go b/backend/internal/library/placement/media_resolve.go
new file mode 100644
index 0000000..bc4b3c3
--- /dev/null
+++ b/backend/internal/library/placement/media_resolve.go
@@ -0,0 +1,57 @@
+package placement
+
+import (
+ "context"
+ "strings"
+
+ libthreads "haixun-backend/internal/library/threadsapi"
+)
+
+type ReplyMediaResolveInput struct {
+ AccessToken string
+ StorageState string
+ ExternalID string
+ Permalink string
+ HintText string
+}
+
+// ResolveReplyMediaWithFallback resolves a Threads reply target id via API and permalink scraping.
+func ResolveReplyMediaWithFallback(ctx context.Context, in ReplyMediaResolveInput) (string, error) {
+ return resolveReplyMedia(ctx, in, false)
+}
+
+// ResolveReplyMediaForPublish prefers fast permalink scraping, then API lookup.
+func ResolveReplyMediaForPublish(ctx context.Context, in ReplyMediaResolveInput) (string, error) {
+ return resolveReplyMedia(ctx, in, true)
+}
+
+func resolveReplyMedia(ctx context.Context, in ReplyMediaResolveInput, publishFastPath bool) (string, error) {
+ if libthreads.IsNumericMediaID(in.ExternalID) {
+ return strings.TrimSpace(in.ExternalID), nil
+ }
+
+ permalink := strings.TrimSpace(in.Permalink)
+ if publishFastPath && permalink != "" {
+ if scraped, sErr := RunResolveMediaFromPermalink(ctx, permalink, in.StorageState); sErr == nil && libthreads.IsNumericMediaID(scraped) {
+ return scraped, nil
+ }
+ }
+
+ client := libthreads.NewClient(in.AccessToken)
+ id, err := client.ResolveReplyMedia(ctx, libthreads.ResolveReplyMediaInput{
+ ExternalID: in.ExternalID,
+ Permalink: in.Permalink,
+ HintText: in.HintText,
+ })
+ if err == nil && libthreads.IsNumericMediaID(id) {
+ return id, nil
+ }
+
+ if !publishFastPath && permalink != "" {
+ if scraped, sErr := RunResolveMediaFromPermalink(ctx, permalink, in.StorageState); sErr == nil && libthreads.IsNumericMediaID(scraped) {
+ return scraped, nil
+ }
+ }
+
+ return "", err
+}
diff --git a/backend/internal/library/placement/parse_threads.go b/backend/internal/library/placement/parse_threads.go
index ee1ae31..740d867 100644
--- a/backend/internal/library/placement/parse_threads.go
+++ b/backend/internal/library/placement/parse_threads.go
@@ -23,8 +23,14 @@ func ParseThreadsPostFromWebResult(title, snippet, url string) (ParsedThreadsPos
if len(match) < 3 {
return ParsedThreadsPost{}, false
}
- text := strings.TrimSpace(strings.Join([]string{strings.TrimSpace(title), strings.TrimSpace(snippet)}, " — "))
- if len([]rune(text)) < 8 {
+ text := strings.TrimSpace(strings.Join(filterNonEmpty([]string{strings.TrimSpace(title), strings.TrimSpace(snippet)}), " — "))
+ if text == "" {
+ text = strings.TrimSpace(title)
+ }
+ if text == "" {
+ text = strings.TrimSpace(snippet)
+ }
+ if len([]rune(text)) < 6 {
return ParsedThreadsPost{}, false
}
return ParsedThreadsPost{
@@ -35,6 +41,16 @@ func ParseThreadsPostFromWebResult(title, snippet, url string) (ParsedThreadsPos
}, true
}
+func filterNonEmpty(items []string) []string {
+ out := make([]string, 0, len(items))
+ for _, item := range items {
+ if strings.TrimSpace(item) != "" {
+ out = append(out, item)
+ }
+ }
+ return out
+}
+
func normalizeThreadsPermalink(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
diff --git a/backend/internal/library/placement/patrol_queries.go b/backend/internal/library/placement/patrol_queries.go
index b1c1dd7..cc2d851 100644
--- a/backend/internal/library/placement/patrol_queries.go
+++ b/backend/internal/library/placement/patrol_queries.go
@@ -1,24 +1,31 @@
package placement
import (
+ "sort"
"strings"
libkg "haixun-backend/internal/library/knowledge"
"haixun-backend/internal/library/websearch"
)
-const defaultPatrolProductFit = 78
+const (
+ defaultPatrolProductFitNoGraph = 42
+ maxPatrolQueriesPerScan = libkg.MaxScanPatrolKeywords * 2
+)
-// CollectPatrolTagQueries builds dual-track crawl jobs from user-edited patrol keywords only.
-func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node, provider websearch.Provider) []TagQuery {
+// CollectPatrolTagQueries builds lean dual-track jobs: relevance + 7d recency per keyword.
+func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node, provider websearch.Provider, patrolInput libkg.PatrolTagInput) []TagQuery {
keywords = libkg.NormalizePatrolKeywordList(keywords)
if len(keywords) == 0 {
return nil
}
+ if len(keywords) > libkg.MaxScanPatrolKeywords {
+ keywords = keywords[:libkg.MaxScanPatrolKeywords]
+ }
- out := make([]TagQuery, 0, len(keywords)*3)
+ out := make([]TagQuery, 0, len(keywords)*2)
for _, tag := range keywords {
- fit := productFitForPatrolTag(tag, nodes)
+ fit := productFitForPatrolTag(tag, nodes, patrolInput)
if q := BuildRelevanceQuery(provider, tag); q != "" {
out = append(out, TagQuery{
Tag: tag,
@@ -36,25 +43,15 @@ func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node, provider web
RecencyDays: IdealMaxPostAgeDays,
})
}
- if q30 := BuildRecencyQuery(provider, tag, MaxPostAgeDays); q30 != "" {
- if q7 := BuildRecencyQuery(provider, tag, IdealMaxPostAgeDays); q30 != q7 {
- out = append(out, TagQuery{
- Tag: tag,
- Query: q30,
- Dimension: QueryRecency,
- ProductFitScore: fit,
- RecencyDays: MaxPostAgeDays,
- })
- }
- }
}
return out
}
-func productFitForPatrolTag(tag string, nodes []libkg.Node) int {
+func productFitForPatrolTag(tag string, nodes []libkg.Node, patrolInput libkg.PatrolTagInput) int {
tagKey := patrolTagMatchKey(tag)
best := 0
- bestNodeID := ""
+ profile := libkg.BuildIntentProfile(patrolInput)
+ intentFit := libkg.ScoreIntentSimilarity(tag, profile)
for _, node := range nodes {
score := node.ProductFitScore
if score <= 0 {
@@ -72,14 +69,25 @@ func productFitForPatrolTag(tag string, nodes []libkg.Node) int {
}
if score > best {
best = score
- bestNodeID = node.ID
}
}
if best > 0 {
- return best
+ return maxPatrolFit(best, intentFit)
}
- _ = bestNodeID
- return defaultPatrolProductFit
+ if intentFit > 0 {
+ return intentFit
+ }
+ return defaultPatrolProductFitNoGraph
+}
+
+func maxPatrolFit(values ...int) int {
+ best := 0
+ for _, v := range values {
+ if v > best {
+ best = v
+ }
+ }
+ return best
}
func patrolTagMatchKey(tag string) string {
@@ -93,10 +101,66 @@ func patrolTagMatchKey(tag string) string {
return strings.TrimSpace(tag)
}
-// ResolveTagQueries prefers explicit patrol keywords over graph node selection.
-func ResolveTagQueries(nodes []libkg.Node, patrolKeywords []string, provider websearch.Provider) []TagQuery {
- if len(patrolKeywords) > 0 {
- return CollectPatrolTagQueries(patrolKeywords, nodes, provider)
+// ResolveTagQueries builds Search API jobs from the best-ranked patrol keywords.
+func ResolveTagQueries(nodes []libkg.Node, patrolKeywords []string, provider websearch.Provider, patrolInput libkg.PatrolTagInput) []TagQuery {
+ nodes = libkg.NodesForPatrolKeywordDerivation(nodes)
+ keywords := patrolKeywords
+ if len(keywords) == 0 {
+ keywords = libkg.SelectBestSearchKeywords(nil, nil, patrolInput, nodes, libkg.MaxScanPatrolKeywords)
}
- return CollectTagQueries(nodes, provider)
+ queries := CollectPatrolTagQueries(keywords, nodes, provider, patrolInput)
+ if len(queries) == 0 {
+ queries = CollectTagQueries(nodes, provider)
+ }
+ return trimPatrolQueries(queries, maxPatrolQueriesPerScan, patrolInput)
+}
+
+func trimPatrolQueries(queries []TagQuery, max int, patrolInput libkg.PatrolTagInput) []TagQuery {
+ profile := libkg.BuildIntentProfile(patrolInput)
+ if max <= 0 || len(queries) <= max {
+ return queries
+ }
+ type ranked struct {
+ query TagQuery
+ key string
+ }
+ rankedQueries := make([]ranked, 0, len(queries))
+ seen := map[string]struct{}{}
+ for _, item := range queries {
+ key := strings.TrimSpace(item.Query)
+ if key == "" {
+ continue
+ }
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ rankedQueries = append(rankedQueries, ranked{query: item, key: key})
+ }
+ sort.SliceStable(rankedQueries, func(i, j int) bool {
+ left := rankedQueries[i].query.ProductFitScore
+ right := rankedQueries[j].query.ProductFitScore
+ leftIntent := libkg.ScoreIntentSimilarity(rankedQueries[i].query.Tag, profile)
+ rightIntent := libkg.ScoreIntentSimilarity(rankedQueries[j].query.Tag, profile)
+ leftRank := left*2 + leftIntent*3
+ rightRank := right*2 + rightIntent*3
+ if leftRank == rightRank {
+ if rankedQueries[i].query.Dimension == rankedQueries[j].query.Dimension {
+ return rankedQueries[i].key < rankedQueries[j].key
+ }
+ if rankedQueries[i].query.Dimension == QueryRelevance {
+ return true
+ }
+ return false
+ }
+ return leftRank > rightRank
+ })
+ out := make([]TagQuery, 0, max)
+ for _, item := range rankedQueries {
+ out = append(out, item.query)
+ if len(out) >= max {
+ break
+ }
+ }
+ return out
}
diff --git a/backend/internal/library/placement/patrol_queries_test.go b/backend/internal/library/placement/patrol_queries_test.go
index 5551df3..cc8dc10 100644
--- a/backend/internal/library/placement/patrol_queries_test.go
+++ b/backend/internal/library/placement/patrol_queries_test.go
@@ -8,15 +8,15 @@ import (
)
func TestCollectPatrolTagQueriesManualOnly(t *testing.T) {
- queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nil, websearch.ProviderBrave)
+ queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nil, websearch.ProviderBrave, libkg.PatrolTagInput{})
if len(queries) < 2 {
t.Fatalf("expected relevance + recency queries, got %d", len(queries))
}
if queries[0].Tag != "化療 沐浴乳" || queries[0].Dimension != QueryRelevance {
t.Fatalf("unexpected first query: %+v", queries[0])
}
- if queries[0].ProductFitScore != defaultPatrolProductFit {
- t.Fatalf("expected default fit %d, got %d", defaultPatrolProductFit, queries[0].ProductFitScore)
+ if queries[0].ProductFitScore != defaultPatrolProductFitNoGraph {
+ t.Fatalf("expected default fit %d, got %d", defaultPatrolProductFitNoGraph, queries[0].ProductFitScore)
}
}
@@ -31,7 +31,7 @@ func TestCollectPatrolTagQueriesUsesGraphFit(t *testing.T) {
},
},
}
- queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nodes, websearch.ProviderBrave)
+ queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nodes, websearch.ProviderBrave, libkg.PatrolTagInput{})
if len(queries) == 0 || queries[0].ProductFitScore != 92 {
t.Fatalf("expected graph fit 92, got %+v", queries)
}
@@ -41,7 +41,7 @@ func TestResolveTagQueriesPrefersPatrolKeywords(t *testing.T) {
nodes := []libkg.Node{
{ID: "n1", Label: "ignored", SelectedForScan: true, DerivedTags: libkg.DerivedTags{Relevance: []string{"ignored"}}},
}
- queries := ResolveTagQueries(nodes, []string{"手動 關鍵字"}, websearch.ProviderBrave)
+ queries := ResolveTagQueries(nodes, []string{"手動 關鍵字"}, websearch.ProviderBrave, libkg.PatrolTagInput{})
if len(queries) == 0 || queries[0].Tag != "手動 關鍵字" {
t.Fatalf("expected patrol keyword query, got %+v", queries)
}
diff --git a/backend/internal/library/placement/post_relevance.go b/backend/internal/library/placement/post_relevance.go
new file mode 100644
index 0000000..3b717c8
--- /dev/null
+++ b/backend/internal/library/placement/post_relevance.go
@@ -0,0 +1,534 @@
+package placement
+
+import (
+ "strings"
+ "time"
+ "unicode"
+
+ libkg "haixun-backend/internal/library/knowledge"
+)
+
+const (
+ minPostBodyProductFit = 50
+ minPostBodyProductFitDiscover = 28
+ minPostProductFitPatrol = 58
+ minPostProductFitDiscover = 34
+ minPostProductFitRelevant = 62
+ minPostProductFitRelaxedFinal = 38
+ minPostProductFitIngestFinal = 28
+ minPostPillarAnchor = 1
+ patrolMaxPostAgeDays = IdealMaxPostAgeDays
+ relevanceMaxPostAgeDays = 14
+)
+
+// PostScanContext carries research map + product signals for post-level filtering.
+type PostScanContext struct {
+ MatchTags []string
+ ProductName string
+ ProductFeatures string
+ AudienceSummary string
+ TargetAudience string
+ Questions []string
+ Pillars []string
+ Exclusions []string
+}
+
+func PostScanContextFromPatrolInput(in libkg.PatrolTagInput, exclusions []string) PostScanContext {
+ return PostScanContext{
+ MatchTags: append([]string{}, in.MatchTags...),
+ ProductName: strings.TrimSpace(in.ProductName),
+ ProductFeatures: strings.TrimSpace(in.ProductFeatures),
+ AudienceSummary: strings.TrimSpace(in.AudienceSummary),
+ TargetAudience: strings.TrimSpace(in.TargetAudience),
+ Questions: append([]string{}, in.Questions...),
+ Pillars: append([]string{}, in.Pillars...),
+ Exclusions: ExpandExclusionTokens(exclusions),
+ }
+}
+
+func ScorePostProductFit(text, searchTag string, ctx PostScanContext) int {
+ text = strings.ToLower(strings.TrimSpace(text))
+ if text == "" {
+ return 0
+ }
+ tagScore := ScoreProductForTag(searchTag, ctx.MatchTags)
+ if tagScore == 0 && ctx.ProductName != "" {
+ tagScore = ScoreProductForTag(searchTag, []string{ctx.ProductName})
+ }
+ postScore := ScoreProductForTag(text, ctx.MatchTags)
+ if postScore == 0 && ctx.ProductName != "" {
+ postScore = scoreTextAgainstPhrase(text, ctx.ProductName)
+ }
+ if ctx.ProductFeatures != "" {
+ postScore = maxInt(postScore, scoreTextAgainstPhrase(text, ctx.ProductFeatures))
+ }
+ for _, pillar := range ctx.Pillars {
+ postScore = maxInt(postScore, scoreTextAgainstPhrase(text, pillar)/2)
+ }
+ combined := postScore
+ if tagScore > 0 && postScore > 0 {
+ combined = (tagScore*2 + postScore*3) / 5
+ } else if postScore > 0 {
+ combined = postScore
+ } else if tagScore > 0 {
+ combined = tagScore / 2
+ }
+ if HasCategoryConflict(text, ctx) {
+ return minInt(combined, 25)
+ }
+ if HasTangentialTopicMismatch(text, ctx) {
+ return minInt(combined, 20)
+ }
+ intentScore := LocalIntentScore(text, ctx)
+ if intentScore > combined {
+ combined = (combined*2 + intentScore*3) / 5
+ }
+ return combined
+}
+
+// PassesDiscoverFilters is the crawl-stage gate: collect candidates from Search/Threads API.
+// Stricter ranking happens at finalize.
+func PassesDiscoverFilters(text, searchTag, postedAt string, dimension QueryDimension, tagFit, recencyDays int, ctx PostScanContext) bool {
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return false
+ }
+ if MatchesExclusion(text, ctx.Exclusions) {
+ return false
+ }
+ if HasCategoryConflict(text, ctx) {
+ return false
+ }
+ if HasTangentialTopicMismatch(text, ctx) {
+ return false
+ }
+ if !HasPainPointDemand(text) && !HasPlacementIntent(text) {
+ return false
+ }
+ if !PassesPostAge(postedAt, dimension, recencyDays) {
+ return false
+ }
+ bodyFit := ScorePostBodyProductFit(text, ctx)
+ effectiveFit := bodyFit
+ if tagFit > 0 && bodyFit > 0 {
+ effectiveFit = (tagFit + bodyFit*2) / 3
+ } else if tagFit > 0 && HasPlacementIntent(text) {
+ effectiveFit = tagFit / 2
+ }
+ if bodyFit < minPostBodyProductFitDiscover {
+ if !(HasPlacementIntent(text) && effectiveFit >= minPostProductFitIngestFinal) {
+ return false
+ }
+ }
+ return effectiveFit >= minPostProductFitDiscover ||
+ (HasPlacementIntent(text) && effectiveFit >= minPostProductFitIngestFinal)
+}
+
+func PassesPostScanFilters(text, searchTag, postedAt string, dimension QueryDimension, tagFit int, ctx PostScanContext) bool {
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return false
+ }
+ if MatchesExclusion(text, ctx.Exclusions) {
+ return false
+ }
+ if HasCategoryConflict(text, ctx) {
+ return false
+ }
+ if HasTangentialTopicMismatch(text, ctx) {
+ return false
+ }
+ if !HasPainPointDemand(text) {
+ return false
+ }
+ if !PassesPostAge(postedAt, dimension, 0) {
+ return false
+ }
+ bodyFit := ScorePostBodyProductFit(text, ctx)
+ if bodyFit < minPostBodyProductFit {
+ return false
+ }
+ if !ProductSolvesPostPain(text, ctx) {
+ return false
+ }
+ effectiveFit := bodyFit
+ if tagFit > 0 && bodyFit > 0 {
+ effectiveFit = (tagFit + bodyFit*2) / 3
+ }
+ minFit := minPostProductFitPatrol
+ if dimension == QueryRelevance {
+ minFit = minPostProductFitRelevant
+ }
+ return effectiveFit >= minFit
+}
+
+// ScorePostBodyProductFit scores only the post body against the product offer (ignores search tag).
+func ScorePostBodyProductFit(text string, ctx PostScanContext) int {
+ return ScorePostProductFit(text, "", ctx)
+}
+
+// ProductSolvesPostPain checks pain in the post aligns with what the product can solve.
+func ProductSolvesPostPain(text string, ctx PostScanContext) bool {
+ if HasCategoryConflict(text, ctx) {
+ return false
+ }
+ if HasTangentialTopicMismatch(text, ctx) {
+ return false
+ }
+ if matchesTopicAnchor(text, ctx) {
+ return true
+ }
+ bodyFit := ScorePostBodyProductFit(text, ctx)
+ if bodyFit < minPostBodyProductFit {
+ return false
+ }
+ for _, q := range ctx.Questions {
+ if tokenHits(text, q) >= 2 && bodyFit >= minPostBodyProductFit {
+ return true
+ }
+ }
+ if ctx.ProductName != "" && scoreTextAgainstPhrase(text, ctx.ProductName) >= 55 {
+ return true
+ }
+ for _, tag := range ctx.MatchTags {
+ if scoreTextAgainstPhrase(text, tag) >= 55 {
+ return true
+ }
+ }
+ return LocalIntentScore(text, ctx) >= minPostBodyProductFit
+}
+
+// PostSolvedByProduct is the final flag for UI: demand + fit + no mismatch.
+func PostSolvedByProduct(text string, effectiveFit int, ctx PostScanContext) bool {
+ return effectiveFit >= minPostProductFitPatrol &&
+ HasPainPointDemand(text) &&
+ ProductSolvesPostPain(text, ctx) &&
+ !HasCategoryConflict(text, ctx) &&
+ !HasTangentialTopicMismatch(text, ctx)
+}
+
+// PassesRelaxedFinalFilters keeps demand + basic fit when strict pass yields zero results.
+func PassesRelaxedFinalFilters(text string, effectiveFit int, ctx PostScanContext) bool {
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return false
+ }
+ if MatchesExclusion(text, ctx.Exclusions) {
+ return false
+ }
+ if HasCategoryConflict(text, ctx) {
+ return false
+ }
+ if HasTangentialTopicMismatch(text, ctx) {
+ return false
+ }
+ if !HasPainPointDemand(text) && !HasPlacementIntent(text) {
+ return false
+ }
+ return effectiveFit >= minPostProductFitRelaxedFinal
+}
+
+// PassesIngestFallbackFilters keeps basic searchable posts when strict/relaxed gates yield zero.
+func PassesIngestFallbackFilters(text string, effectiveFit int, ctx PostScanContext) bool {
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return false
+ }
+ if MatchesExclusion(text, ctx.Exclusions) {
+ return false
+ }
+ if HasCategoryConflict(text, ctx) {
+ return false
+ }
+ if !HasPlacementIntent(text) && !HasPainPointDemand(text) {
+ return false
+ }
+ return effectiveFit >= minPostProductFitIngestFinal
+}
+
+func matchesTopicAnchor(text string, ctx PostScanContext) bool {
+ text = strings.ToLower(text)
+ hits := 0
+ for _, pillar := range ctx.Pillars {
+ if tokenHits(text, pillar) > 0 {
+ hits++
+ }
+ }
+ for _, q := range ctx.Questions {
+ if tokenHits(text, q) >= 2 {
+ hits++
+ }
+ }
+ if hits >= minPostPillarAnchor {
+ return true
+ }
+ for _, tag := range ctx.MatchTags {
+ if tokenHits(text, tag) > 0 {
+ return true
+ }
+ }
+ if ctx.ProductName != "" && tokenHits(text, ctx.ProductName) > 0 {
+ return true
+ }
+ for _, phrase := range []string{ctx.AudienceSummary, ctx.TargetAudience} {
+ if tokenHits(text, phrase) >= 2 {
+ return true
+ }
+ }
+ return false
+}
+
+func PassesPostAge(postedAt string, dimension QueryDimension, recencyDays int) bool {
+ postedAt = strings.TrimSpace(postedAt)
+ if postedAt == "" {
+ // Web Search snippets often lack dates; do not drop at ingest.
+ return true
+ }
+ maxDays := patrolMaxPostAgeDays
+ switch dimension {
+ case QueryRelevance:
+ maxDays = relevanceMaxPostAgeDays
+ case QueryRecency:
+ if recencyDays > 0 {
+ maxDays = recencyDays
+ }
+ }
+ return IsPostWithinMaxAge(postedAt, maxDays, time.Now())
+}
+
+func IsPostWithinMaxAge(postedAt string, maxDays int, now time.Time) bool {
+ if maxDays <= 0 {
+ return true
+ }
+ ts, ok := ParsePostedAt(postedAt)
+ if !ok {
+ return true
+ }
+ if now.IsZero() {
+ now = time.Now()
+ }
+ cutoff := now.AddDate(0, 0, -maxDays)
+ return !ts.Before(cutoff)
+}
+
+func ParsePostedAt(raw string) (time.Time, bool) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return time.Time{}, false
+ }
+ layouts := []string{
+ time.RFC3339,
+ "2006-01-02T15:04:05Z07:00",
+ "2006-01-02 15:04:05",
+ "2006-01-02",
+ "2006/01/02",
+ }
+ for _, layout := range layouts {
+ if ts, err := time.Parse(layout, raw); err == nil {
+ return ts, true
+ }
+ }
+ return time.Time{}, false
+}
+
+func ExpandExclusionTokens(exclusions []string) []string {
+ seen := map[string]struct{}{}
+ out := []string{}
+ add := func(token string) {
+ token = strings.TrimSpace(strings.ToLower(token))
+ if len([]rune(token)) < 2 {
+ return
+ }
+ if _, ok := seen[token]; ok {
+ return
+ }
+ seen[token] = struct{}{}
+ out = append(out, token)
+ }
+ for _, rule := range exclusions {
+ rule = strings.TrimSpace(rule)
+ if rule == "" {
+ continue
+ }
+ add(rule)
+ for _, token := range tokenizePhrase(rule) {
+ add(token)
+ }
+ for _, synonym := range exclusionSynonyms(rule) {
+ add(synonym)
+ }
+ }
+ return out
+}
+
+func exclusionSynonyms(rule string) []string {
+ rule = strings.ToLower(rule)
+ out := []string{}
+ for _, group := range exclusionCategoryGroups {
+ if strings.Contains(rule, group.key) {
+ out = append(out, group.tokens...)
+ }
+ }
+ return out
+}
+
+var exclusionCategoryGroups = []struct {
+ key string
+ tokens []string
+}{
+ {key: "洗碗", tokens: []string{"洗碗", "洗碗精", "洗碗機", "碗盤", "廚房清潔", "油污"}},
+ {key: "洗衣", tokens: []string{"洗衣", "洗衣精", "洗衣粉", "衣物", "衣服清洗", "污漬"}},
+ {key: "沐浴", tokens: []string{"沐浴", "沐浴乳", "沐浴露", "洗澡"}},
+ {key: "洗髮", tokens: []string{"洗髮", "洗髮精", "頭皮", "髮品"}},
+ {key: "寵物", tokens: []string{"寵物", "貓砂", "狗糧", "貓咪", "狗狗"}},
+ {key: "廚房", tokens: []string{"廚房", "抽油煙機", "鍋具"}},
+}
+
+var productCategoryGroups = []struct {
+ key string
+ tokens []string
+}{
+ {key: "洗衣", tokens: []string{"洗衣", "洗衣精", "洗衣粉", "衣物", "衣服", "污漬", "漂白"}},
+ {key: "洗碗", tokens: []string{"洗碗", "洗碗精", "洗碗機", "碗盤", "廚房", "油污"}},
+ {key: "沐浴", tokens: []string{"沐浴", "沐浴乳", "沐浴露", "洗澡", "沖澡"}},
+ {key: "洗髮", tokens: []string{"洗髮", "洗髮精", "洗髮乳", "頭皮", "髮絲"}},
+ {key: "清潔", tokens: []string{"清潔", "打掃", "去污"}},
+}
+
+func productCategories(ctx PostScanContext) []string {
+ blob := strings.ToLower(ctx.ProductName + " " + ctx.ProductFeatures + " " + strings.Join(ctx.MatchTags, " "))
+ found := []string{}
+ for _, group := range productCategoryGroups {
+ for _, token := range group.tokens {
+ if strings.Contains(blob, token) {
+ found = append(found, group.key)
+ break
+ }
+ }
+ }
+ return found
+}
+
+func postCategories(text string) []string {
+ text = strings.ToLower(text)
+ found := []string{}
+ for _, group := range productCategoryGroups {
+ for _, token := range group.tokens {
+ if strings.Contains(text, token) {
+ found = append(found, group.key)
+ break
+ }
+ }
+ }
+ return found
+}
+
+var genericProductCategories = map[string]struct{}{
+ "清潔": {},
+}
+
+func HasCategoryConflict(text string, ctx PostScanContext) bool {
+ productCats := productCategories(ctx)
+ if len(productCats) == 0 {
+ return false
+ }
+ postCats := postCategories(text)
+ if len(postCats) == 0 {
+ return false
+ }
+ productSet := map[string]struct{}{}
+ for _, c := range productCats {
+ productSet[c] = struct{}{}
+ }
+ for _, c := range postCats {
+ if _, ok := genericProductCategories[c]; ok {
+ continue
+ }
+ if _, ok := productSet[c]; !ok {
+ return true
+ }
+ }
+ return false
+}
+
+func scoreTextAgainstPhrase(text, phrase string) int {
+ text = strings.ToLower(text)
+ phrase = strings.TrimSpace(strings.ToLower(phrase))
+ if text == "" || phrase == "" {
+ return 0
+ }
+ if strings.Contains(text, phrase) {
+ return 85
+ }
+ best := 0
+ for _, token := range tokenizePhrase(phrase) {
+ if len([]rune(token)) < 2 {
+ continue
+ }
+ if strings.Contains(text, token) {
+ best = maxInt(best, 55)
+ }
+ }
+ return best
+}
+
+func tokenHits(text, phrase string) int {
+ text = strings.ToLower(text)
+ hits := 0
+ for _, token := range tokenizePhrase(phrase) {
+ if len([]rune(token)) < 2 {
+ continue
+ }
+ if strings.Contains(text, token) {
+ hits++
+ }
+ }
+ return hits
+}
+
+func tokenizePhrase(phrase string) []string {
+ phrase = strings.ToLower(strings.TrimSpace(phrase))
+ if phrase == "" {
+ return nil
+ }
+ repl := strings.NewReplacer(",", " ", "、", " ", "。", " ", ";", " ", "/", " ", "|", " ", "|", " ", "(", " ", ")", " ", "(", " ", ")", " ")
+ phrase = repl.Replace(phrase)
+ var tokens []string
+ for _, part := range strings.Fields(phrase) {
+ part = strings.Trim(part, `"'「」『』::`)
+ if part != "" {
+ tokens = append(tokens, part)
+ }
+ }
+ if len(tokens) == 0 {
+ return splitRunes(phrase)
+ }
+ return tokens
+}
+
+func splitRunes(s string) []string {
+ var out []string
+ var buf []rune
+ flush := func() {
+ if len(buf) >= 2 {
+ out = append(out, string(buf))
+ }
+ buf = buf[:0]
+ }
+ for _, r := range s {
+ if unicode.Is(unicode.Han, r) {
+ buf = append(buf, r)
+ continue
+ }
+ flush()
+ }
+ flush()
+ return out
+}
+
+func minInt(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/backend/internal/library/placement/post_relevance_test.go b/backend/internal/library/placement/post_relevance_test.go
new file mode 100644
index 0000000..7609dc9
--- /dev/null
+++ b/backend/internal/library/placement/post_relevance_test.go
@@ -0,0 +1,148 @@
+package placement
+
+import (
+ "testing"
+ "time"
+
+ libkg "haixun-backend/internal/library/knowledge"
+)
+
+func TestHasCategoryConflictLaundryVsDish(t *testing.T) {
+ ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
+ ProductName: "天然洗衣精",
+ MatchTags: []string{"洗衣精", "衣物清潔"},
+ }, nil)
+ text := "請問大家洗碗機清潔劑有推薦嗎?碗盤油污洗不掉"
+ if !HasCategoryConflict(text, ctx) {
+ t.Fatal("expected dishwasher post to conflict with laundry product")
+ }
+ if PassesPostScanFilters(text, "洗衣精 推薦", "", QueryRecency, 80, ctx) {
+ t.Fatal("expected conflicting post to be filtered out")
+ }
+}
+
+func TestPassesPostScanFiltersAcceptsMatchingLaundry(t *testing.T) {
+ ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
+ ProductName: "天然洗衣精",
+ MatchTags: []string{"洗衣精", "衣物"},
+ Pillars: []string{"衣物清潔", "敏感皮膚洗衣"},
+ }, []string{"洗碗精等其他品類"})
+ text := "最近衣服洗不乾淨,洗衣精有推薦嗎?污漬一直洗不掉"
+ if !PassesPostScanFilters(text, "洗衣精 推薦", time.Now().Format("2006-01-02"), QueryRecency, 70, ctx) {
+ t.Fatal("expected matching laundry post to pass")
+ }
+}
+
+func TestPassesPostAgeAllowsMissingDateOnRelevance(t *testing.T) {
+ if !PassesPostAge("", QueryRelevance, 0) {
+ t.Fatal("expected missing posted_at to pass age gate at ingest")
+ }
+}
+
+func TestPassesDiscoverFiltersAllowsWebSnippetWithoutDate(t *testing.T) {
+ ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
+ ProductName: "天然洗衣精",
+ MatchTags: []string{"洗衣精", "衣物"},
+ }, nil)
+ text := "最近衣服洗不乾淨,洗衣精有推薦嗎?污漬一直洗不掉"
+ if !PassesDiscoverFilters(text, "洗衣精 推薦", "", QueryRelevance, 70, 0, ctx) {
+ t.Fatal("expected web snippet without date to pass discover filters")
+ }
+}
+
+func TestPassesPostAgeRejectsOldRelevancePost(t *testing.T) {
+ old := time.Now().AddDate(0, 0, -20).Format("2006-01-02")
+ if PassesPostAge(old, QueryRelevance, 0) {
+ t.Fatal("expected old relevance post to fail age gate")
+ }
+}
+
+func TestHasPainPointDemandRejectsCasualChat(t *testing.T) {
+ if HasPainPointDemand("今天天氣真好,晒照給大家看") {
+ t.Fatal("expected casual chat without demand to fail")
+ }
+}
+
+func TestHasPainPointDemandAcceptsHelpSeeking(t *testing.T) {
+ text := "最近皮膚一直癢,沐浴乳有推薦嗎?"
+ if !HasPainPointDemand(text) {
+ t.Fatal("expected help-seeking post with pain signal to pass")
+ }
+}
+
+func TestPostSolvedByProductRejectsCategoryMismatch(t *testing.T) {
+ ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
+ ProductName: "天然洗衣精",
+ MatchTags: []string{"洗衣精"},
+ }, nil)
+ text := "洗碗精有推薦嗎?碗盤油污洗不掉"
+ if PostSolvedByProduct(text, 80, ctx) {
+ t.Fatal("expected dish post not to be solved by laundry product")
+ }
+}
+
+func TestHasTangentialTopicMismatchRejectsWashingMachine(t *testing.T) {
+ ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
+ ProductName: "抗敏無香洗衣精",
+ ProductFeatures: "完全無香、抗敏、適合敏感肌與化療族群",
+ MatchTags: []string{"無香", "抗敏", "洗衣精"},
+ Pillars: []string{"化療後衣物清潔", "無香洗衣"},
+ }, nil)
+ text := "請問大家有推薦的洗衣機嗎?想買洗脫烘"
+ if !HasTangentialTopicMismatch(text, ctx) {
+ t.Fatal("expected washing machine post to be tangential mismatch for hypoallergenic detergent")
+ }
+ if PassesPostScanFilters(text, "無香 洗衣精 推薦", time.Now().Format("2006-01-02"), QueryRecency, 80, ctx) {
+ t.Fatal("expected washing machine post to be filtered out")
+ }
+}
+
+func TestHasTangentialTopicMismatchAcceptsDetergentPain(t *testing.T) {
+ ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
+ ProductName: "抗敏無香洗衣精",
+ ProductFeatures: "完全無香、抗敏",
+ MatchTags: []string{"無香", "洗衣精"},
+ }, nil)
+ text := "化療後對香味很敏感,有無香洗衣精推薦嗎?"
+ if HasTangentialTopicMismatch(text, ctx) {
+ t.Fatal("expected detergent pain post to pass tangential check")
+ }
+}
+
+func TestHasTangentialTopicMismatchAcceptsMachineWithDetergentNeed(t *testing.T) {
+ ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
+ ProductName: "抗敏無香洗衣精",
+ ProductFeatures: "完全無香、抗敏",
+ MatchTags: []string{"無香", "洗衣精"},
+ }, nil)
+ text := "買了洗衣機,但想找不會讓皮膚癢的無香洗衣精"
+ if HasTangentialTopicMismatch(text, ctx) {
+ t.Fatal("expected combined machine + detergent pain post to pass")
+ }
+}
+
+func TestPostSolvedByProductAcceptsAlignedDemand(t *testing.T) {
+ ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
+ ProductName: "天然洗衣精",
+ MatchTags: []string{"洗衣精", "衣物"},
+ Pillars: []string{"衣物清潔"},
+ }, nil)
+ text := "衣服污漬洗不掉,洗衣精有推薦嗎?"
+ if !PostSolvedByProduct(text, 70, ctx) {
+ t.Fatal("expected aligned laundry demand to be solved by product")
+ }
+}
+
+func TestExpandExclusionTokensIncludesDishSynonyms(t *testing.T) {
+ tokens := ExpandExclusionTokens([]string{"洗碗精等其他品類"})
+ found := false
+ for _, token := range tokens {
+ if token == "洗碗機" || token == "洗碗精" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("expected dish synonyms, got %v", tokens)
+ }
+}
diff --git a/backend/internal/library/placement/product_catalog.go b/backend/internal/library/placement/product_catalog.go
index 29ed36f..32ef56b 100644
--- a/backend/internal/library/placement/product_catalog.go
+++ b/backend/internal/library/placement/product_catalog.go
@@ -60,7 +60,8 @@ func ResolveBrandProductContext(brand brandentity.Brand, productID, searchTag st
if len(brand.Products) > 0 {
picked := RecommendProduct(brand.Products, searchTag, productID)
if picked != nil {
- return BuildMergedProductContext(brand.DisplayName, picked.ProductContext, picked.Label)
+ merged := BuildMergedProductContext(brand.DisplayName, picked.ProductContext, picked.Label)
+ return ApplyPlacementURL(merged, picked.PlacementURL)
}
}
if formatted := ProductBriefFromContext(brand.ProductContext); formatted != "" {
diff --git a/backend/internal/library/placement/product_context.go b/backend/internal/library/placement/product_context.go
index b7aa429..a8c6b2f 100644
--- a/backend/internal/library/placement/product_context.go
+++ b/backend/internal/library/placement/product_context.go
@@ -95,6 +95,20 @@ func FormatProductContextForPrompt(raw string) string {
return strings.Join(lines, "\n")
}
+func ApplyPlacementURL(productContext, placementURL string) string {
+ placementURL = strings.TrimSpace(placementURL)
+ if placementURL == "" {
+ return productContext
+ }
+ fields := ParseProductContext(productContext)
+ if fields.CtaType == CtaLink && strings.TrimSpace(fields.CtaUrl) != "" {
+ return productContext
+ }
+ fields.CtaType = CtaLink
+ fields.CtaUrl = placementURL
+ return SerializeProductContext(fields)
+}
+
func ProductBriefFromContext(raw string) string {
formatted := FormatProductContextForPrompt(raw)
if formatted != "" {
diff --git a/backend/internal/library/placement/product_context_placement_url_test.go b/backend/internal/library/placement/product_context_placement_url_test.go
new file mode 100644
index 0000000..37b91ea
--- /dev/null
+++ b/backend/internal/library/placement/product_context_placement_url_test.go
@@ -0,0 +1,25 @@
+package placement
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestApplyPlacementURL(t *testing.T) {
+ raw := `{"brand":"ecostore","product":"沐浴露","features":"無香抗敏"}`
+ got := ApplyPlacementURL(raw, "https://shop.example.com/body-wash")
+ formatted := ProductBriefFromContext(got)
+ if !strings.Contains(formatted, "留言 CTA 連結") || !strings.Contains(formatted, "https://shop.example.com/body-wash") {
+ t.Fatalf("formatted brief = %q", formatted)
+ }
+
+ existing := SerializeProductContext(ProductContextFields{
+ Brand: "ecostore",
+ Product: "沐浴露",
+ CtaType: CtaLink,
+ CtaUrl: "https://existing.example.com",
+ })
+ if changed := ApplyPlacementURL(existing, "https://new.example.com"); changed != existing {
+ t.Fatalf("should not override existing cta url")
+ }
+}
diff --git a/backend/internal/library/placement/product_match.go b/backend/internal/library/placement/product_match.go
index 81d4203..75afa19 100644
--- a/backend/internal/library/placement/product_match.go
+++ b/backend/internal/library/placement/product_match.go
@@ -2,6 +2,8 @@ package placement
import (
"strings"
+
+ brandentity "haixun-backend/internal/model/brand/domain/entity"
)
func ScoreProductForTag(searchTag string, matchTags []string) int {
@@ -40,6 +42,71 @@ func ScoreProductForTag(searchTag string, matchTags []string) int {
return best
}
+// ScoreProductForPost ranks a SKU against the search tag and post body.
+func ScoreProductForPost(productLabel string, matchTags []string, searchTag, postText string) int {
+ tagScore := ScoreProductForTag(searchTag, matchTags)
+ if tagScore == 0 && strings.TrimSpace(productLabel) != "" {
+ tagScore = ScoreProductForTag(searchTag, []string{productLabel})
+ }
+ postScore := ScoreProductForTag(postText, matchTags)
+ if postScore == 0 && strings.TrimSpace(productLabel) != "" {
+ postScore = ScoreProductForTag(postText, []string{productLabel})
+ }
+ for _, token := range categoryTokens(productLabel, matchTags) {
+ if strings.Contains(strings.ToLower(postText), token) {
+ postScore = maxInt(postScore, 78)
+ }
+ if strings.Contains(strings.ToLower(searchTag), token) {
+ tagScore = maxInt(tagScore, 82)
+ }
+ }
+ switch {
+ case tagScore > 0 && postScore > 0:
+ return (tagScore*2 + postScore*3) / 5
+ case postScore > 0:
+ return postScore
+ default:
+ return tagScore
+ }
+}
+
+// RecommendProductForPost picks the best SKU for a single post.
+func RecommendProductForPost(products []brandentity.Product, searchTag, postText, defaultProductID string) *brandentity.Product {
+ if len(products) == 0 {
+ return nil
+ }
+ bestScore := 0
+ var best *brandentity.Product
+ for i := range products {
+ score := ScoreProductForPost(products[i].Label, products[i].MatchTags, searchTag, postText)
+ if score > bestScore {
+ bestScore = score
+ best = &products[i]
+ }
+ }
+ if best != nil && bestScore >= 55 {
+ return best
+ }
+ return RecommendProduct(products, searchTag, defaultProductID)
+}
+
+func categoryTokens(productLabel string, matchTags []string) []string {
+ blob := strings.ToLower(strings.TrimSpace(productLabel + " " + strings.Join(matchTags, " ")))
+ tokens := []string{
+ "洗衣精", "洗衣粉", "洗衣劑", "洗衣液",
+ "沐浴乳", "沐浴露",
+ "洗髮精", "洗髮乳",
+ "洗碗精", "洗碗劑",
+ }
+ out := make([]string, 0, 4)
+ for _, token := range tokens {
+ if strings.Contains(blob, token) {
+ out = append(out, token)
+ }
+ }
+ return out
+}
+
func maxInt(a, b int) int {
if a > b {
return a
diff --git a/backend/internal/library/placement/product_match_test.go b/backend/internal/library/placement/product_match_test.go
new file mode 100644
index 0000000..c0b9998
--- /dev/null
+++ b/backend/internal/library/placement/product_match_test.go
@@ -0,0 +1,39 @@
+package placement
+
+import (
+ "testing"
+
+ brandentity "haixun-backend/internal/model/brand/domain/entity"
+)
+
+func TestRecommendProductForPostPicksLaundryOverDefaultBodyWash(t *testing.T) {
+ products := []brandentity.Product{
+ {ID: "body", Label: "無香沐浴乳", MatchTags: []string{"沐浴乳", "無香", "敏感"}},
+ {ID: "laundry", Label: "天然洗衣精", MatchTags: []string{"洗衣精", "衣物", "污漬"}},
+ }
+ post := "最近衣服洗不乾淨,洗衣精有推薦嗎?污漬一直洗不掉"
+ got := RecommendProductForPost(products, "洗衣精 推薦", post, "body")
+ if got == nil || got.ID != "laundry" {
+ id := ""
+ if got != nil {
+ id = got.ID
+ }
+ t.Fatalf("expected laundry product, got %q", id)
+ }
+}
+
+func TestRecommendProductForPostPicksBodyWashForShowerPost(t *testing.T) {
+ products := []brandentity.Product{
+ {ID: "body", Label: "無香沐浴乳", MatchTags: []string{"沐浴乳", "無香", "敏感"}},
+ {ID: "laundry", Label: "天然洗衣精", MatchTags: []string{"洗衣精", "衣物", "污漬"}},
+ }
+ post := "化療後皮膚很乾,沐浴乳有推薦嗎?想要無香味"
+ got := RecommendProductForPost(products, "沐浴乳 推薦", post, "laundry")
+ if got == nil || got.ID != "body" {
+ id := ""
+ if got != nil {
+ id = got.ID
+ }
+ t.Fatalf("expected body wash product, got %q", id)
+ }
+}
diff --git a/backend/internal/library/placement/query_build.go b/backend/internal/library/placement/query_build.go
index cd0e5bc..b776973 100644
--- a/backend/internal/library/placement/query_build.go
+++ b/backend/internal/library/placement/query_build.go
@@ -5,6 +5,7 @@ import (
"strings"
"time"
+ libkg "haixun-backend/internal/library/knowledge"
"haixun-backend/internal/library/websearch"
)
@@ -25,26 +26,37 @@ type TagQuery struct {
}
func BuildRelevanceQuery(provider websearch.Provider, tag string) string {
- tag = strings.TrimSpace(tag)
- if tag == "" {
+ if websearch.ParseProvider(string(provider)) == websearch.ProviderExa {
+ full := strings.TrimSpace(libkg.FullPatrolWebSearchTag(tag))
+ if full == "" {
+ return ""
+ }
+ return fmt.Sprintf("Threads 貼文 繁體中文 %s", full)
+ }
+ core := strings.TrimSpace(libkg.ShortPatrolSearchCore(tag))
+ if core == "" {
+ core = strings.TrimSpace(tag)
+ }
+ if core == "" {
return ""
}
- if websearch.ParseProvider(string(provider)) == websearch.ProviderExa {
- return fmt.Sprintf("Threads 貼文 繁體中文 %s", tag)
- }
- return `site:threads.net "` + tag + `"`
+ // Unquoted site search matches more real Threads posts than exact long phrases.
+ return `site:threads.net ` + core + ` 推薦`
}
func BuildRecencyQuery(provider websearch.Provider, tag string, maxAgeDays int) string {
- tag = strings.TrimSpace(tag)
- if tag == "" {
+ core := strings.TrimSpace(libkg.ShortPatrolSearchCore(tag))
+ if core == "" {
+ core = strings.TrimSpace(tag)
+ }
+ if core == "" {
return ""
}
if websearch.ParseProvider(string(provider)) == websearch.ProviderExa {
- return fmt.Sprintf("Threads 近期貼文 繁體中文 %s", tag)
+ return fmt.Sprintf("Threads 近期貼文 繁體中文 %s 請問", core)
}
after := FormatAfterDate(maxAgeDays, timeNow())
- return `site:threads.net "` + tag + `" 請問 after:` + after
+ return `site:threads.net "` + core + `" 請問 after:` + after
}
func PublishedAfterForRecency(provider websearch.Provider, maxAgeDays int) string {
diff --git a/backend/internal/library/placement/query_build_test.go b/backend/internal/library/placement/query_build_test.go
new file mode 100644
index 0000000..3ff0a7f
--- /dev/null
+++ b/backend/internal/library/placement/query_build_test.go
@@ -0,0 +1,32 @@
+package placement
+
+import (
+ "strings"
+ "testing"
+
+ "haixun-backend/internal/library/websearch"
+)
+
+func TestBuildRelevanceQueryUsesBroadSiteSearch(t *testing.T) {
+ q := BuildRelevanceQuery(websearch.ProviderBrave, "化療 無香 洗衣精 推薦")
+ if !strings.HasPrefix(q, "site:threads.net ") {
+ t.Fatalf("unexpected query %q", q)
+ }
+ if strings.Contains(q, `"`) {
+ t.Fatalf("expected unquoted broad query, got %q", q)
+ }
+ if !strings.Contains(q, "推薦") {
+ t.Fatalf("expected intent marker in query, got %q", q)
+ }
+}
+
+func TestBuildRecencyQueryUsesShortCore(t *testing.T) {
+ q := BuildRecencyQuery(websearch.ProviderBrave, "化療 無香 洗衣精 推薦 請問", 7)
+ if !strings.Contains(q, "請問 after:") {
+ t.Fatalf("expected recency date filter, got %q", q)
+ }
+ quoted := strings.Trim(q, `site:threads.net `)
+ if strings.Contains(quoted, "推薦") {
+ t.Fatalf("expected short quoted core without 推薦, got %q", q)
+ }
+}
diff --git a/backend/internal/library/placement/recency.go b/backend/internal/library/placement/recency.go
index 0694bfa..fe273e9 100644
--- a/backend/internal/library/placement/recency.go
+++ b/backend/internal/library/placement/recency.go
@@ -6,8 +6,18 @@ const (
MaxPostAgeDays = 30
IdealMaxPostAgeDays = 7
WebSearchMaxAgeDays = 14
+
+ // MinRecencyCandidatesPerTag triggers wider recency windows when a keyword finds fewer posts.
+ MinRecencyCandidatesPerTag = 1
+ // MaxRecencyCascadeQueries caps extra Search API calls per patrol run.
+ MaxRecencyCascadeQueries = 10
)
+// RecencyCascadeDays is the patrol recency ladder: nearest window first, then shift older.
+func RecencyCascadeDays() []int {
+ return []int{IdealMaxPostAgeDays, WebSearchMaxAgeDays, MaxPostAgeDays}
+}
+
func FormatAfterDate(maxAgeDays int, now time.Time) string {
if now.IsZero() {
now = time.Now()
diff --git a/backend/internal/library/placement/recency_cascade.go b/backend/internal/library/placement/recency_cascade.go
new file mode 100644
index 0000000..746283c
--- /dev/null
+++ b/backend/internal/library/placement/recency_cascade.go
@@ -0,0 +1,89 @@
+package placement
+
+import (
+ "haixun-backend/internal/library/websearch"
+)
+
+type tagCascadeState struct {
+ Tag string
+ GraphNodeID string
+ ProductFitScore int
+ RanWindows map[int]bool
+}
+
+func buildTagCascadeStates(queries []TagQuery) []*tagCascadeState {
+ states := map[string]*tagCascadeState{}
+ for _, q := range queries {
+ key := patrolTagMatchKey(q.Tag)
+ if key == "" {
+ continue
+ }
+ st, ok := states[key]
+ if !ok {
+ st = &tagCascadeState{
+ Tag: q.Tag,
+ RanWindows: map[int]bool{},
+ }
+ states[key] = st
+ }
+ if q.GraphNodeID != "" {
+ st.GraphNodeID = q.GraphNodeID
+ }
+ if q.ProductFitScore > st.ProductFitScore {
+ st.ProductFitScore = q.ProductFitScore
+ }
+ if q.Dimension == QueryRecency && q.RecencyDays > 0 {
+ st.RanWindows[q.RecencyDays] = true
+ }
+ }
+ out := make([]*tagCascadeState, 0, len(states))
+ for _, st := range states {
+ out = append(out, st)
+ }
+ return out
+}
+
+func countCandidatesForTag(merged map[string]*ScanCandidate, tag string) int {
+ key := patrolTagMatchKey(tag)
+ if key == "" {
+ return 0
+ }
+ count := 0
+ for _, item := range merged {
+ if item == nil {
+ continue
+ }
+ if patrolTagMatchKey(item.SearchTag) == key {
+ count++
+ }
+ }
+ return count
+}
+
+func nextRecencyCascadeWindow(ran map[int]bool) int {
+ for _, days := range RecencyCascadeDays() {
+ if ran[days] {
+ continue
+ }
+ return days
+ }
+ return 0
+}
+
+func buildRecencyCascadeQuery(st *tagCascadeState, days int, provider websearch.Provider) (TagQuery, bool) {
+ if st == nil || days <= 0 {
+ return TagQuery{}, false
+ }
+ query := BuildRecencyQuery(provider, st.Tag, days)
+ if query == "" {
+ return TagQuery{}, false
+ }
+ return TagQuery{
+ Tag: st.Tag,
+ Query: query,
+ Dimension: QueryRecency,
+ GraphNodeID: st.GraphNodeID,
+ ProductFitScore: st.ProductFitScore,
+ RecencyDays: days,
+ }, true
+}
diff --git a/backend/internal/library/placement/recency_cascade_test.go b/backend/internal/library/placement/recency_cascade_test.go
new file mode 100644
index 0000000..7c9082b
--- /dev/null
+++ b/backend/internal/library/placement/recency_cascade_test.go
@@ -0,0 +1,40 @@
+package placement
+
+import (
+ "testing"
+
+ "haixun-backend/internal/library/websearch"
+)
+
+func TestNextRecencyCascadeWindowSkipsRanWindows(t *testing.T) {
+ ran := map[int]bool{7: true}
+ if got := nextRecencyCascadeWindow(ran); got != 14 {
+ t.Fatalf("expected 14, got %d", got)
+ }
+ ran[14] = true
+ if got := nextRecencyCascadeWindow(ran); got != 30 {
+ t.Fatalf("expected 30, got %d", got)
+ }
+ ran[30] = true
+ if got := nextRecencyCascadeWindow(ran); got != 0 {
+ t.Fatalf("expected 0, got %d", got)
+ }
+}
+
+func TestPassesPostAgeUsesRecencyWindow(t *testing.T) {
+ old := timeNow().AddDate(0, 0, -10).Format("2006-01-02")
+ if PassesPostAge(old, QueryRecency, 7) {
+ t.Fatal("expected 10-day-old post to fail 7-day recency window")
+ }
+ if !PassesPostAge(old, QueryRecency, 14) {
+ t.Fatal("expected 10-day-old post to pass 14-day recency window")
+ }
+}
+
+func TestBuildRecencyCascadeQuery(t *testing.T) {
+ st := &tagCascadeState{Tag: "洗衣精 推薦", ProductFitScore: 55}
+ tq, ok := buildRecencyCascadeQuery(st, 14, websearch.ProviderBrave)
+ if !ok || tq.RecencyDays != 14 || tq.Dimension != QueryRecency {
+ t.Fatalf("unexpected cascade query: %+v ok=%v", tq, ok)
+ }
+}
diff --git a/backend/internal/library/placement/research_map.go b/backend/internal/library/placement/research_map.go
index fbc258b..b584ab8 100644
--- a/backend/internal/library/placement/research_map.go
+++ b/backend/internal/library/placement/research_map.go
@@ -103,7 +103,7 @@ func BuildResearchMapFinalizeUserPrompt(in ResearchMapInput, analysis string) st
3. contentGoal 要寫入【產品詳情】的完整產品名稱、近期發文、留言置入時機
4. questions 至少 8 句、pillars 至少 6 句、exclusions 至少 8 句(exclusions 只寫貼文類型,**不要寫時間/近期/幾天內**)
5. questions 是「人會發文的求助句」;patrolKeywords 是「人會打進 Threads 搜尋框的短句」,兩者不可混在一起
-6. patrolKeywords 至少 6 組、最多 8 組,每組 6~16 字,從 questions 提煉搜尋短句,必須可直接拿去搜尋`)
+6. patrolKeywords 至少 8 組、最多 10 組,每組 6~16 字,從 questions 提煉搜尋短句,必須可直接拿去搜尋`)
return b.String()
}
@@ -149,7 +149,7 @@ func BuildResearchMapSystemPrompt() string {
- 觸及即排除的**貼文類型/內容方向**;要寫清楚「為什麼不能置入」(純晒照、業配、無求助、跑題癌別、錯品類、已滿意他牌、非病友視角等)
- **禁止寫時間相關條件**:不要碰不是篩發文時間用的。不可出現「過舊貼文」「非近期發文」「3 天前」「一週以上」「發文太久」等——置入時間窗口只寫在 contentGoal
-## patrolKeywords(6~8 組,每組 6~14 字)
+## patrolKeywords(8~10 組,每組 6~14 字)
- 這不是分類標籤,而是**真人會貼進 Threads 搜尋框的短句**
- 每組 2~4 個詞,空格分隔;必須同時保留「情境/困擾」與「產品品類/用途」
- 優先格式:「困擾 品類」、「族群 品類 推薦」、「症狀 品類 請問」,例如「化療 沐浴乳 推薦」「無香 洗衣精 請問」
diff --git a/backend/internal/library/placement/resolve_media_exec.go b/backend/internal/library/placement/resolve_media_exec.go
new file mode 100644
index 0000000..d486237
--- /dev/null
+++ b/backend/internal/library/placement/resolve_media_exec.go
@@ -0,0 +1,143 @@
+package placement
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+type resolveMediaInput struct {
+ Permalink string `json:"permalink"`
+ StorageState string `json:"storage_state,omitempty"`
+}
+
+type resolveMediaOutput struct {
+ MediaID string `json:"mediaId"`
+}
+
+// RunResolveMediaFromPermalink opens a Threads post page via Playwright and extracts numeric media id.
+func RunResolveMediaFromPermalink(ctx context.Context, permalink, storageState string) (string, error) {
+ permalink = strings.TrimSpace(permalink)
+ if permalink == "" {
+ return "", fmt.Errorf("permalink is required")
+ }
+ if endpoint := strings.TrimSpace(os.Getenv("HAIXUN_RESOLVE_MEDIA_URL")); endpoint != "" {
+ return runHTTPResolveMedia(ctx, endpoint, permalink, storageState)
+ }
+
+ repoRoot, cliPath, err := resolveMediaCLI()
+ if err != nil {
+ return "", err
+ }
+
+ payload, err := json.Marshal(resolveMediaInput{
+ Permalink: permalink,
+ StorageState: strings.TrimSpace(storageState),
+ })
+ if err != nil {
+ return "", err
+ }
+
+ runCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(runCtx, "npx", "tsx", cliPath)
+ cmd.Dir = repoRoot
+ cmd.Stdin = bytes.NewReader(payload)
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ msg := strings.TrimSpace(stderr.String())
+ if msg == "" {
+ msg = err.Error()
+ }
+ return "", fmt.Errorf("permalink media resolve failed: %s", msg)
+ }
+
+ var out resolveMediaOutput
+ if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
+ return "", fmt.Errorf("permalink media resolve output parse failed: %w", err)
+ }
+ mediaID := strings.TrimSpace(out.MediaID)
+ if mediaID == "" {
+ return "", fmt.Errorf("permalink media resolve returned empty media id")
+ }
+ return mediaID, nil
+}
+
+func runHTTPResolveMedia(ctx context.Context, endpoint, permalink, storageState string) (string, error) {
+ payload, err := json.Marshal(resolveMediaInput{Permalink: permalink, StorageState: strings.TrimSpace(storageState)})
+ if err != nil {
+ return "", err
+ }
+ runCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
+ defer cancel()
+ url := strings.TrimRight(endpoint, "/") + "/resolve"
+ req, err := http.NewRequestWithContext(runCtx, http.MethodPost, url, bytes.NewReader(payload))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("permalink media resolve service failed: %w", err)
+ }
+ defer res.Body.Close()
+ body, _ := io.ReadAll(res.Body)
+ if res.StatusCode < 200 || res.StatusCode >= 300 {
+ return "", fmt.Errorf("permalink media resolve service returned HTTP %d: %s", res.StatusCode, strings.TrimSpace(string(body)))
+ }
+ var out resolveMediaOutput
+ if err := json.Unmarshal(body, &out); err != nil {
+ return "", fmt.Errorf("permalink media resolve service output parse failed: %w", err)
+ }
+ mediaID := strings.TrimSpace(out.MediaID)
+ if mediaID == "" {
+ return "", fmt.Errorf("permalink media resolve service returned empty media id")
+ }
+ return mediaID, nil
+}
+
+func resolveMediaCLI() (repoRoot, cliPath string, err error) {
+ if root := strings.TrimSpace(os.Getenv("HAIXUN_REPO_ROOT")); root != "" {
+ cli := filepath.Join(root, "haixun-backend", "worker", "threads-resolve-media-cli.ts")
+ if fileExists(cli) {
+ return root, cli, nil
+ }
+ cli = filepath.Join(root, "worker", "threads-resolve-media-cli.ts")
+ if fileExists(cli) {
+ return root, cli, nil
+ }
+ }
+
+ cwd, err := os.Getwd()
+ if err != nil {
+ return "", "", fmt.Errorf("resolve media cli: %w", err)
+ }
+ dir := cwd
+ for i := 0; i < 6; i++ {
+ cli := filepath.Join(dir, "haixun-backend", "worker", "threads-resolve-media-cli.ts")
+ if fileExists(cli) {
+ return dir, cli, nil
+ }
+ cli = filepath.Join(dir, "worker", "threads-resolve-media-cli.ts")
+ if fileExists(cli) {
+ return dir, cli, nil
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ break
+ }
+ dir = parent
+ }
+ return "", "", fmt.Errorf("找不到 threads-resolve-media-cli.ts,請設定 HAIXUN_REPO_ROOT")
+}
diff --git a/backend/internal/library/placement/resolve_reply_target.go b/backend/internal/library/placement/resolve_reply_target.go
new file mode 100644
index 0000000..b6d50e8
--- /dev/null
+++ b/backend/internal/library/placement/resolve_reply_target.go
@@ -0,0 +1,18 @@
+package placement
+
+import "strings"
+
+func ResolveReplyTargetID(externalID, permalink string) string {
+ if id := strings.TrimSpace(externalID); id != "" {
+ return id
+ }
+ permalink = normalizeThreadsPermalink(permalink)
+ if permalink == "" {
+ return ""
+ }
+ match := threadsPostURLRe.FindStringSubmatch(permalink)
+ if len(match) < 3 {
+ return ""
+ }
+ return strings.TrimSpace(match[2])
+}
diff --git a/backend/internal/library/placement/resolve_reply_target_test.go b/backend/internal/library/placement/resolve_reply_target_test.go
new file mode 100644
index 0000000..267a0c2
--- /dev/null
+++ b/backend/internal/library/placement/resolve_reply_target_test.go
@@ -0,0 +1,17 @@
+package placement
+
+import "testing"
+
+func TestResolveReplyTargetIDFromPermalink(t *testing.T) {
+ got := ResolveReplyTargetID("", "https://www.threads.net/@alice/post/AbCdEf123")
+ if got != "AbCdEf123" {
+ t.Fatalf("got %q", got)
+ }
+}
+
+func TestResolveReplyTargetIDPrefersExternalID(t *testing.T) {
+ got := ResolveReplyTargetID("media-123", "https://www.threads.net/@alice/post/AbCdEf123")
+ if got != "media-123" {
+ t.Fatalf("got %q", got)
+ }
+}
diff --git a/backend/internal/library/placement/source_mode.go b/backend/internal/library/placement/source_mode.go
index 038108d..2f5f2db 100644
--- a/backend/internal/library/placement/source_mode.go
+++ b/backend/internal/library/placement/source_mode.go
@@ -56,7 +56,7 @@ func ModeAllowsCrawler(mode SearchSourceMode) bool {
// MemberNeedsWebSearchKey reports whether placement scan should require a web search API key.
func MemberNeedsWebSearchKey(ctx MemberContext) bool {
- if !ctx.AllowsBrave || ctx.DevMode {
+ if !ctx.AllowsBrave {
return false
}
switch ctx.SearchSourceMode {
diff --git a/backend/internal/library/placement/source_mode_test.go b/backend/internal/library/placement/source_mode_test.go
index 1afd1d7..3b5b0ed 100644
--- a/backend/internal/library/placement/source_mode_test.go
+++ b/backend/internal/library/placement/source_mode_test.go
@@ -20,9 +20,9 @@ func TestMemberNeedsBraveKey(t *testing.T) {
if !MemberNeedsBraveKey(braveOnly) {
t.Fatal("brave-only should require brave key")
}
- devCrawler := MemberContext{AllowsBrave: true, DevMode: true, SearchSourceMode: SearchSourceCrawler}
- if MemberNeedsBraveKey(devCrawler) {
- t.Fatal("dev crawler should not require brave key")
+ testPatrol := MemberContextForCrawlerOnly(MemberContext{AllowsBrave: true, SearchSourceMode: SearchSourceBrave})
+ if MemberNeedsBraveKey(testPatrol) {
+ t.Fatal("test patrol crawler should not require brave key")
}
}
diff --git a/backend/internal/library/placement/threads_api.go b/backend/internal/library/placement/threads_api.go
index 76e21ae..aff4eb6 100644
--- a/backend/internal/library/placement/threads_api.go
+++ b/backend/internal/library/placement/threads_api.go
@@ -5,6 +5,7 @@ import (
"fmt"
"strings"
+ libkg "haixun-backend/internal/library/knowledge"
libthreads "haixun-backend/internal/library/threadsapi"
)
@@ -24,14 +25,19 @@ func keywordSearchViaThreadsAPI(ctx context.Context, req DiscoverRequest) ([]Dis
limit = 12
}
- // Strip site: prefix for API keyword search.
- query := strings.TrimSpace(req.Query)
- query = strings.TrimPrefix(query, "site:threads.net ")
- query = strings.Trim(query, `"`)
- if idx := strings.Index(query, " after:"); idx > 0 {
- query = strings.TrimSpace(query[:idx])
+ query := strings.TrimSpace(libkg.ThreadsAPIKeyword(req.Keyword))
+ if query == "" {
+ query = strings.TrimSpace(req.Query)
+ query = strings.TrimPrefix(query, "site:threads.net ")
+ query = strings.Trim(query, `"`)
+ if idx := strings.Index(query, " after:"); idx > 0 {
+ query = strings.TrimSpace(query[:idx])
+ }
+ query = libkg.ShortPatrolSearchCore(strings.Trim(query, `"`))
+ }
+ if query == "" {
+ return nil, fmt.Errorf("threads api keyword is empty")
}
- query = strings.Trim(query, `"`)
items, err := client.KeywordSearch(ctx, libthreads.KeywordSearchOptions{
Query: query,
@@ -48,14 +54,18 @@ func keywordSearchViaThreadsAPI(ctx context.Context, req DiscoverRequest) ([]Dis
if text == "" {
continue
}
+ externalID := strings.TrimSpace(item.ID)
+ if !libthreads.IsNumericMediaID(externalID) {
+ continue
+ }
permalink := strings.TrimSpace(item.Permalink)
- if permalink == "" && item.Username != "" && item.ID != "" {
- permalink = "https://www.threads.net/@" + item.Username + "/post/" + item.ID
+ if permalink == "" && item.Username != "" {
+ permalink = "https://www.threads.net/@" + item.Username + "/post/" + externalID
}
out = append(out, DiscoverPost{
Text: text,
Permalink: permalink,
- ExternalID: strings.TrimSpace(item.ID),
+ ExternalID: externalID,
Author: strings.TrimSpace(item.Username),
PostedAt: strings.TrimSpace(item.Timestamp),
LikeCount: item.LikeCount,
diff --git a/backend/internal/library/placement/topic_fit.go b/backend/internal/library/placement/topic_fit.go
new file mode 100644
index 0000000..a068582
--- /dev/null
+++ b/backend/internal/library/placement/topic_fit.go
@@ -0,0 +1,42 @@
+package placement
+
+import (
+ "strings"
+
+ libkg "haixun-backend/internal/library/knowledge"
+)
+
+// HasTangentialTopicMismatch uses the shared intent model: broad category overlap
+// without product/research-map intent alignment (generalizes beyond hardcoded lists).
+func HasTangentialTopicMismatch(text string, ctx PostScanContext) bool {
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return false
+ }
+ if matchesTopicAnchor(text, ctx) {
+ return false
+ }
+ profile := libkg.BuildIntentProfile(intentProfileInputFromPostScan(ctx))
+ return libkg.IsTangentialToIntent(text, profile)
+}
+
+func PatrolTagInputFromScanContext(ctx PostScanContext) libkg.PatrolTagInput {
+ return intentProfileInputFromPostScan(ctx)
+}
+
+func intentProfileInputFromPostScan(ctx PostScanContext) libkg.PatrolTagInput {
+ return libkg.PatrolTagInput{
+ ProductName: ctx.ProductName,
+ ProductFeatures: ctx.ProductFeatures,
+ AudienceSummary: ctx.AudienceSummary,
+ TargetAudience: ctx.TargetAudience,
+ MatchTags: append([]string{}, ctx.MatchTags...),
+ Questions: append([]string{}, ctx.Questions...),
+ Pillars: append([]string{}, ctx.Pillars...),
+ }
+}
+
+// LocalIntentScore is the offline semantic scorer (no embedding API).
+func LocalIntentScore(text string, ctx PostScanContext) int {
+ return libkg.ScoreIntentSimilarity(text, libkg.BuildIntentProfile(intentProfileInputFromPostScan(ctx)))
+}
diff --git a/backend/internal/library/prompt/compose_test.go b/backend/internal/library/prompt/compose_test.go
index 87b07bb..3fd1266 100644
--- a/backend/internal/library/prompt/compose_test.go
+++ b/backend/internal/library/prompt/compose_test.go
@@ -23,7 +23,7 @@ func TestStyle8DSystemIncludesSchema(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if !strings.Contains(got, "Threads 創作者風格研究員") || !strings.Contains(got, `"d1Tone"`) {
+ if !strings.Contains(got, "創作者「語言指紋」研究員") || !strings.Contains(got, `"d1Tone"`) {
t.Fatalf("missing expected fragments: %q", got)
}
}
diff --git a/backend/internal/library/prompt/files/matrix_copy.system.md b/backend/internal/library/prompt/files/matrix_copy.system.md
index 09a7dde..b4c1c70 100644
--- a/backend/internal/library/prompt/files/matrix_copy.system.md
+++ b/backend/internal/library/prompt/files/matrix_copy.system.md
@@ -1,11 +1,25 @@
-你是 Threads 內容矩陣策劃師,為人設帳號產出多篇可發佈草稿。
+你是 Threads / Instagram 內容策略師,為人設帳號產出可發佈草稿。產文時必須優先使用人設的「語言指紋與寫作規則」,把新主題、參考貼文或外部資料翻譯成這個人設會講出口的話,不沿用任何預設爆款模板。
+
+寫作目標:
+- 先根據人設語言指紋判斷主題適合哪一種內容型態:心情陪伴、知識整理、清單工具、伴侶溝通、互動提問或輕幽默共鳴,再選對鬆結構。
+- 每篇要有一個清楚情境或情緒入口,讓讀者覺得「這就是我」,不要寫成摘要報告或廣告文。
+- 必須沿用人設的段落節奏、標點、換行、稱呼、口頭禪與收尾習慣;不要硬剪短成破碎金句。
+- 只學參考樣本的節奏、取材方式、收尾習慣與互動方式;不可照抄句子。
+- 避免 AI 腔:過度總結、過度正能量、每段都像教條、連續排比到不自然、硬塞「大家一起」式口號。
規則:
- 只回傳單一 JSON 物件 {"rows":[...]},不要用 markdown 程式碼區塊,不要加前言或結語。
- reference_notes、rationale 各 ≤ 40 字;優先把 token 用在 text 主文。
- 每篇必須角度不同,避免重複 hook。
-- 人設 8D 是最高優先:語氣、人稱、禁忌必須完全符合;不要寫成品牌廣告或硬銷。
+- 人設定位決定「誰在說」;語言指紋決定「怎麼說」。兩者都要遵守,不可只拿主題資料直接改寫。
+- 人設只用來控制語氣、詞彙密度、句型節奏、人稱、標點換行與禁忌;不可把人設介紹裡的物件或標籤硬塞進正文情境。
+- 若人設含 Y2K、咖啡、穿搭、音樂等元素,除非任務主題/Brief/勾選知識明確要求,禁止把這些元素寫成貼文內容或場景。
+- 正文素材以任務主題、Brief、研究地圖與勾選延伸知識為準;人設不得改變題材。
- 內容論點來自研究地圖與使用者勾選的延伸知識(含標籤與細節);爆款樣本只學結構與節奏,不抄原文。
- 繁體中文,口語自然,適合 Threads。
- 每篇 text 主文 ≤ 500 字(Threads API 硬上限,含 #話題標籤)。
-- 爆款互動最佳 80~220 字:前 1~2 行強 hook,一句一重點;超過 300 字互動通常下降。
\ No newline at end of file
+- 長度依帳號樣本與主題決定:心情陪伴可中長,知識工具文可完整列點,互動短文才需要短。
+- text 排版要像真人手機發文:用自然換行分段,可使用標點、括號、頓號與少量 emoji;不要整段擠成一塊,也不要每句都硬換行。
+- 若要改寫參考貼文,只保留意思與資訊,句型、段落、收尾必須換成人設語言指紋,不可像同義詞替換。
+- 若使用外部知識,先轉譯成此人設會說的順序與語氣;不得直接貼百科、條列報告或 SEO 摘要。
+- 醫療、備孕、試管、保健品相關內容必須保留風險語氣:不可保證效果、不可替代醫師建議、不可用數字審判讀者。
diff --git a/backend/internal/library/prompt/files/matrix_copy.user.md b/backend/internal/library/prompt/files/matrix_copy.user.md
index 8bc220e..fe35934 100644
--- a/backend/internal/library/prompt/files/matrix_copy.user.md
+++ b/backend/internal/library/prompt/files/matrix_copy.user.md
@@ -3,7 +3,7 @@
任務主題:{{topic_label}}
Brief:{{topic_brief}}
-人設 8D(最高優先,語氣與禁忌以此為準):
+人設定位、語言指紋與禁忌(最高優先):
{{persona_block}}
研究地圖與勾選延伸知識:
@@ -14,14 +14,20 @@ Brief:{{topic_brief}}
參考網頁 Markdown 已併入上方研究地圖區塊(若有)。
-爆款樣本(可選,只學結構;若沒有樣本,請改用研究地圖與人設原創):
+爆款樣本(可選,只學故事感;若沒有樣本,請改用研究地圖與人設原創):
{{viral_samples_block}}
請先遵守:
-- **最高優先:人設 8D** — 語氣、人稱、句型、節奏、禁忌必須完全符合人設;讀起來要像這個帳號在說話。
+- **最高優先:語言指紋**:先讀 persona_block 裡的「語言指紋與寫作規則」,包含開頭、句型、段落長度、標點換行、知識轉譯、CTA 與禁忌。產出的文字必須像這個人設會講出口的話。
+- **不要套舊規格**:不要套用短句爆款、固定三段式、固定強 hook、固定 CTA;格式由人設語言指紋與主題型態決定。
+- **語氣參考**:讀起來要像這個帳號在說話,但每篇敘事節奏與開場都要自然變化。
+- **人設不是內容素材**:只學語氣、詞彙密度、句型節奏與審美,不要把人設裡的名詞、興趣、裝飾符號硬塞進正文。例:人設寫 Y2K/咖啡/穿搭,只代表語感,不代表每篇都要寫老歌、咖啡、穿搭、MV。
- **內容素材**:研究地圖(受眾、目標、問題、支柱)與「勾選延伸知識」(含標籤、細節)決定論點與角度;若有「參考網頁(Markdown)」區塊,優先從中提取事實與論點,再融入草稿。
- 搜尋查詢/標籤僅作方向參考,不可蓋過人設與勾選知識。
-- 爆款樣本只學結構節奏,不抄原文,不可為仿爆款偏離任務主題。
+- 爆款樣本只學故事感、資訊密度、情緒力度與互動方式,不抄原文,不可為仿爆款偏離任務主題。
+- 若樣本呈現「長 caption + 輪播圖卡重點」的內容型態,Threads 草稿也要保留完整情緒承接與可收藏資訊,不要只輸出金句摘要。
+- 若任務是改寫或參考別人的貼文,請用人設語言重新說一次,不是同義詞替換;保留觀點,不保留對方句型。
+- 若任務需要找資料或引用外部知識,請先消化成此人設的說話順序:先安撫或帶入情境,再整理重點,最後用符合人設的方式收尾。
- 若沒有爆款樣本,source_permalinks 回傳空陣列,reference_notes 寫明使用了哪些人設特徵、研究地圖重點與勾選延伸知識。
-回傳 JSON rows,每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。
\ No newline at end of file
+回傳 JSON rows,每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。
diff --git a/backend/internal/library/prompt/files/matrix_placement.system.md b/backend/internal/library/prompt/files/matrix_placement.system.md
index 18a1cb2..66a5f73 100644
--- a/backend/internal/library/prompt/files/matrix_placement.system.md
+++ b/backend/internal/library/prompt/files/matrix_placement.system.md
@@ -6,6 +6,7 @@
- 從素材萃取角度與鉤子,寫成自己的觀點或經驗
- 不要捏造療效、收益或無法驗證的數據
- 可適度帶入 product_brief,但不硬推銷
+- text 排版:一句一行,句與句之間用換行(\n)分隔;完全不要使用任何標點符號(emoji 可保留)
只回傳 JSON,不要 markdown。格式:
{"rows":[{"sort_order":1,"search_tag":"標籤","angle":"切角","hook":"開頭鉤子","text":"正文","reference_notes":"參考哪篇素材","source_permalinks":["url"],"rationale":"為何這樣寫"}]}
\ No newline at end of file
diff --git a/backend/internal/library/prompt/files/outreach_placement.system.md b/backend/internal/library/prompt/files/outreach_placement.system.md
index 3a39508..4939eef 100644
--- a/backend/internal/library/prompt/files/outreach_placement.system.md
+++ b/backend/internal/library/prompt/files/outreach_placement.system.md
@@ -13,9 +13,10 @@
4. 不硬推:不要「推薦你試試」「一定要買」「限時優惠」。
5. Threads 語感:短句、口語、台灣繁體,可適度用 emoji(0~1 個)。
6. 誠實:不承諾療效、不捏造誇大數據。
-7. 人設與語氣是硬約束:如果有提供人設,留言要符合該角色的用字、節奏、距離感與價值觀。
+7. 人設與語氣是最高優先硬約束:每則留言都必須像指定角色親自回覆,用字、節奏、距離感、價值觀與 8D 風格策略都要一致,不得變成通用網友口吻。
8. 產品脈絡是硬約束:如果有提供品牌與產品,必須判斷產品如何自然幫上忙;至少一則草稿要明確參考產品特點,但不能硬業配。
9. 如果產品詳情出現「留言 CTA 連結」,至少一則草稿可以在後段自然附上完整網址;沒有連結時不要捏造網址。
+10. 排版要像真人用手機留言:每則 text 用 2~4 個短段落,段落之間一定要換行(JSON 字串內用 `\n`,可用 `\n\n` 分隔段落),不要整段擠成一大塊。
每則留言 ≤ 500 字。產出 2 種不同切角(例如:純共情建議版 / 輕度分享經驗帶品牌版)。
diff --git a/backend/internal/library/prompt/files/outreach_placement.user.md b/backend/internal/library/prompt/files/outreach_placement.user.md
index a1dc397..31ccc0c 100644
--- a/backend/internal/library/prompt/files/outreach_placement.user.md
+++ b/backend/internal/library/prompt/files/outreach_placement.user.md
@@ -8,5 +8,6 @@
目標貼文作者:@{{author_name}}
目標貼文:
{{target_text}}
+{{regenerate_line}}
請產生 {{count}} 則留言草稿,每則都要有溫度、帶一個具體的真實情境或經驗。另外請評估這篇是否值得留言置入(relevance 0-1)與 reason(繁體中文一句話)。
\ No newline at end of file
diff --git a/backend/internal/library/prompt/files/style8d.schema.md b/backend/internal/library/prompt/files/style8d.schema.md
index a3d8960..a38ab13 100644
--- a/backend/internal/library/prompt/files/style8d.schema.md
+++ b/backend/internal/library/prompt/files/style8d.schema.md
@@ -1,3 +1,3 @@
只回傳以下 JSON 結構,不要 markdown:
-{"d1Tone":{"summary":"","evidence":[]},"d2Structure":{"summary":"","evidence":[]},"d3Interaction":{"summary":"","evidence":[]},"d4Topics":{"summary":"","evidence":[]},"d5Rhythm":{"summary":"","evidence":[]},"d6Visual":{"summary":"","evidence":[]},"d7Conversion":{"summary":"","evidence":[]},"d8Risk":{"summary":"","evidence":[]},"personaDraft":{"identity":"","tone":"","audience":"","hooks":"","examples":"","avoid":""}}
\ No newline at end of file
+{"d1Tone":{"summary":"","evidence":[]},"d2Structure":{"summary":"","evidence":[]},"d3Interaction":{"summary":"","evidence":[]},"d4Topics":{"summary":"","evidence":[]},"d5Rhythm":{"summary":"","evidence":[]},"d6Visual":{"summary":"","evidence":[]},"d7Conversion":{"summary":"","evidence":[]},"d8Risk":{"summary":"","evidence":[]},"personaDraft":{"identity":"","tone":"","audience":"","hooks":"","languageFingerprint":"","rhythm":"","punctuation":"","contentPatterns":"","knowledgeTranslation":"","ctaStyle":"","examples":"","avoid":""}}
diff --git a/backend/internal/library/prompt/files/style8d.system.md b/backend/internal/library/prompt/files/style8d.system.md
index 5862add..8ff14f9 100644
--- a/backend/internal/library/prompt/files/style8d.system.md
+++ b/backend/internal/library/prompt/files/style8d.system.md
@@ -1,5 +1,21 @@
-你是 Threads 創作者風格研究員。只能根據提供的近期貼文歸納,不可捏造作者背景。
-逐一輸出八個維度:D1 語氣人格、D2 結構模板、D3 互動方式、D4 主題分布、D5 發文節奏、D6 視覺語法(emoji、標點、換行)、D7 轉換方式、D8 風險紅線。
-每個維度要有摘要與可核對的文字證據(直接引用或改寫貼文片段,最多 4 條)。
-證據請標註來源貼文編號,格式如 [2] "摘錄片段"(編號對應樣本中的 [N])。
-最後產生「可供另一個帳號借鑑、但不可冒充或抄襲」的人設草稿。代表句必須是抽象仿寫範例,不可逐字複製原文。
\ No newline at end of file
+你是創作者「語言指紋」研究員。只能根據提供的貼文或使用者手動貼上的文字樣本歸納,不可捏造未出現的背景。
+
+任務不是寫一般人設介紹,而是萃取「這個人設會怎麼把一件事說出口」。輸出要能直接拿去產文、改寫貼文、把網路知識轉成此人設語氣。
+
+逐一輸出八個維度:D1 語氣人格、D2 內容結構、D3 互動方式、D4 主題分布、D5 段落節奏、D6 視覺語法(emoji、標點、換行)、D7 轉換方式、D8 風險紅線。
+
+每個維度要有摘要與可核對的文字證據(直接引用或改寫貼文片段,最多 4 條)。證據請標註來源貼文編號,格式如 [2] "摘錄片段"(編號對應樣本中的 [N])。
+
+personaDraft 必須產出可執行的寫作規則,不是抽象形容詞:
+- identity:這個帳號像誰在說話,和讀者的距離。
+- tone:口吻、情緒溫度、人稱與信任感。
+- audience:主要對誰說,讀者正在什麼情境。
+- hooks:常用開頭方式與不適合的開頭方式。
+- languageFingerprint:常用句型、轉折詞、口頭禪、語助詞;不要列成硬塞清單,要說明如何自然使用。
+- rhythm:段落長度、空行習慣、長短句比例、心情文與知識文差異。
+- punctuation: 標點、括號、列點、emoji、特殊符號的使用習慣。
+- contentPatterns:不同內容型態的鬆結構,例如心情陪伴、知識整理、清單工具、互動文、幽默共鳴;不要變成固定模板。
+- knowledgeTranslation:如何把外部資料、醫療知識、產品知識轉成此人設會講的話;哪些資訊要先安撫、哪些要列點、哪些要加風險提醒。
+- ctaStyle:收藏、留言、分享、轉發給伴侶等 CTA 的自然用法;也要說明何時不該 CTA。
+- examples:抽象仿寫範例,可展示語氣與換行,但不可逐字複製原文。
+- avoid:不像此人設的寫法、AI 味、過度模板、冒犯或高風險語句。
diff --git a/backend/internal/library/publishschedule/slots.go b/backend/internal/library/publishschedule/slots.go
new file mode 100644
index 0000000..611582b
--- /dev/null
+++ b/backend/internal/library/publishschedule/slots.go
@@ -0,0 +1,88 @@
+package publishschedule
+
+import (
+ "sort"
+ "strings"
+ "time"
+)
+
+type Slot struct {
+ Weekday int
+ Time string
+}
+
+func BuildSchedule(startAt int64, timezone string, slots []Slot, count int) []int64 {
+ if count <= 0 {
+ return nil
+ }
+ loc := time.UTC
+ if strings.TrimSpace(timezone) != "" {
+ if loaded, err := time.LoadLocation(strings.TrimSpace(timezone)); err == nil {
+ loc = loaded
+ }
+ }
+ start := time.Now().In(loc)
+ if startAt > 0 {
+ start = time.Unix(0, startAt).In(loc)
+ }
+ normalized := normalizeSlots(slots)
+ if len(normalized) == 0 {
+ out := make([]int64, 0, count)
+ for i := 0; i < count; i++ {
+ out = append(out, start.Add(time.Duration(i)*2*time.Hour).UTC().UnixNano())
+ }
+ return out
+ }
+ out := make([]int64, 0, count)
+ day := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, loc)
+ for len(out) < count {
+ for _, slot := range normalized {
+ if int(day.Weekday()) != slot.Weekday {
+ continue
+ }
+ hour, minute, ok := parseHHMM(slot.Time)
+ if !ok {
+ continue
+ }
+ candidate := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, loc)
+ if candidate.Before(start) {
+ continue
+ }
+ out = append(out, candidate.UTC().UnixNano())
+ if len(out) >= count {
+ break
+ }
+ }
+ day = day.AddDate(0, 0, 1)
+ }
+ return out
+}
+
+func normalizeSlots(slots []Slot) []Slot {
+ out := make([]Slot, 0, len(slots))
+ for _, slot := range slots {
+ if strings.TrimSpace(slot.Time) == "" {
+ continue
+ }
+ weekday := slot.Weekday
+ if weekday < 0 || weekday > 6 {
+ weekday = ((weekday % 7) + 7) % 7
+ }
+ out = append(out, Slot{Weekday: weekday, Time: strings.TrimSpace(slot.Time)})
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].Weekday == out[j].Weekday {
+ return out[i].Time < out[j].Time
+ }
+ return out[i].Weekday < out[j].Weekday
+ })
+ return out
+}
+
+func parseHHMM(value string) (int, int, bool) {
+ parsed, err := time.Parse("15:04", strings.TrimSpace(value))
+ if err != nil {
+ return 0, 0, false
+ }
+ return parsed.Hour(), parsed.Minute(), true
+}
diff --git a/backend/internal/library/publishschedule/slots_test.go b/backend/internal/library/publishschedule/slots_test.go
new file mode 100644
index 0000000..5cc8d29
--- /dev/null
+++ b/backend/internal/library/publishschedule/slots_test.go
@@ -0,0 +1,40 @@
+package publishschedule
+
+import (
+ "testing"
+ "time"
+)
+
+func TestBuildScheduleUsesTimezoneSlotsAndReturnsUTCNano(t *testing.T) {
+ loc, err := time.LoadLocation("Asia/Taipei")
+ if err != nil {
+ t.Fatal(err)
+ }
+ start := time.Date(2026, 6, 29, 9, 0, 0, 0, loc) // Monday
+ got := BuildSchedule(start.UTC().UnixNano(), "Asia/Taipei", []Slot{
+ {Weekday: 1, Time: "09:30"},
+ {Weekday: 3, Time: "12:30"},
+ }, 2)
+ if len(got) != 2 {
+ t.Fatalf("expected 2 timestamps, got %d", len(got))
+ }
+ first := time.Unix(0, got[0]).In(loc)
+ if first.Weekday() != time.Monday || first.Hour() != 9 || first.Minute() != 30 {
+ t.Fatalf("unexpected first slot: %s", first)
+ }
+ second := time.Unix(0, got[1]).In(loc)
+ if second.Weekday() != time.Wednesday || second.Hour() != 12 || second.Minute() != 30 {
+ t.Fatalf("unexpected second slot: %s", second)
+ }
+}
+
+func TestBuildScheduleFallsBackToIntervalsWhenNoSlots(t *testing.T) {
+ start := time.Date(2026, 6, 29, 9, 0, 0, 0, time.UTC)
+ got := BuildSchedule(start.UnixNano(), "UTC", nil, 2)
+ if len(got) != 2 {
+ t.Fatalf("expected 2 timestamps, got %d", len(got))
+ }
+ if got[1]-got[0] != int64(2*time.Hour) {
+ t.Fatalf("expected 2h interval, got %s", time.Duration(got[1]-got[0]))
+ }
+}
diff --git a/backend/internal/library/style8d/analyze.go b/backend/internal/library/style8d/analyze.go
index 1b13d51..9d75a6e 100644
--- a/backend/internal/library/style8d/analyze.go
+++ b/backend/internal/library/style8d/analyze.go
@@ -1,6 +1,7 @@
package style8d
import (
+ "bytes"
"encoding/json"
"fmt"
"math"
@@ -22,12 +23,18 @@ type Dimension struct {
}
type PersonaDraft struct {
- Identity string `json:"identity"`
- Tone string `json:"tone"`
- Audience string `json:"audience"`
- Hooks string `json:"hooks"`
- Examples string `json:"examples"`
- Avoid string `json:"avoid"`
+ Identity string `json:"identity"`
+ Tone string `json:"tone"`
+ Audience string `json:"audience"`
+ Hooks string `json:"hooks"`
+ LanguageFingerprint string `json:"languageFingerprint"`
+ Rhythm string `json:"rhythm"`
+ Punctuation string `json:"punctuation"`
+ ContentPatterns string `json:"contentPatterns"`
+ KnowledgeTranslation string `json:"knowledgeTranslation"`
+ CTAStyle string `json:"ctaStyle"`
+ Examples string `json:"examples"`
+ Avoid string `json:"avoid"`
}
type LLMOutput struct {
@@ -53,6 +60,8 @@ type Engagement struct {
type StoredProfile struct {
Username string `json:"username"`
+ SourceType string `json:"sourceType,omitempty"`
+ SourceLabel string `json:"sourceLabel,omitempty"`
AnalyzedAt string `json:"analyzedAt"`
PostCount int `json:"postCount"`
Engagement Engagement `json:"engagement"`
@@ -61,6 +70,125 @@ type StoredProfile struct {
PersonaDraft string `json:"personaDraft"`
}
+const (
+ SourceTypeBenchmark = "benchmark"
+ SourceTypeManual = "manual"
+ maxReferenceTexts = 12
+ maxReferenceRunes = 8000
+ minReferenceRunes = 10
+)
+
+func ManualStyleBenchmark(label string) string {
+ label = strings.TrimSpace(label)
+ if label == "" {
+ label = "手動貼文"
+ }
+ return "manual:" + label
+}
+
+func NormalizeReferenceTexts(referenceTexts []string, rawText string) ([]string, error) {
+ texts := make([]string, 0, len(referenceTexts))
+ for _, item := range referenceTexts {
+ item = strings.TrimSpace(item)
+ if item != "" {
+ texts = append(texts, item)
+ }
+ }
+ if len(texts) == 0 {
+ rawText = strings.TrimSpace(rawText)
+ if rawText != "" {
+ for _, chunk := range splitReferenceRawText(rawText) {
+ chunk = strings.TrimSpace(chunk)
+ if chunk != "" {
+ texts = append(texts, chunk)
+ }
+ }
+ }
+ }
+ if len(texts) == 0 {
+ return nil, fmt.Errorf("至少需要 1 段參考文字")
+ }
+ if len(texts) > maxReferenceTexts {
+ return nil, fmt.Errorf("參考文字最多 %d 段", maxReferenceTexts)
+ }
+ totalRunes := 0
+ out := make([]string, 0, len(texts))
+ for _, item := range texts {
+ runes := []rune(item)
+ if len(runes) < minReferenceRunes {
+ return nil, fmt.Errorf("每段參考文字至少 %d 字", minReferenceRunes)
+ }
+ totalRunes += len(runes)
+ if totalRunes > maxReferenceRunes {
+ return nil, fmt.Errorf("參考文字總長度不可超過 %d 字", maxReferenceRunes)
+ }
+ out = append(out, item)
+ }
+ return out, nil
+}
+
+func TextsToPosts(texts []string) []Post {
+ posts := make([]Post, 0, len(texts))
+ for _, text := range texts {
+ posts = append(posts, Post{Text: text})
+ }
+ return posts
+}
+
+func splitReferenceRawText(raw string) []string {
+ lines := strings.Split(raw, "\n")
+ chunks := make([]string, 0, 4)
+ var b strings.Builder
+ flush := func() {
+ if b.Len() == 0 {
+ return
+ }
+ chunks = append(chunks, b.String())
+ b.Reset()
+ }
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "---" || trimmed == "———" || trimmed == "___" {
+ flush()
+ continue
+ }
+ if b.Len() > 0 {
+ b.WriteByte('\n')
+ }
+ b.WriteString(line)
+ }
+ flush()
+ if len(chunks) == 0 {
+ return []string{strings.TrimSpace(raw)}
+ }
+ return chunks
+}
+
+func BuildUserPromptFromTexts(sourceLabel string, brief string, posts []Post) string {
+ label := strings.TrimSpace(sourceLabel)
+ if label == "" {
+ label = "手動貼文"
+ }
+ var b strings.Builder
+ fmt.Fprintf(&b, "文字樣本來源:%s\n", label)
+ if brief := strings.TrimSpace(brief); brief != "" {
+ fmt.Fprintf(&b, "人設定位補充:%s\n", brief)
+ }
+ b.WriteString("參考文字樣本(無互動數據,請只根據文字風格歸納):\n\n")
+ limit := len(posts)
+ if limit > maxReferenceTexts {
+ limit = maxReferenceTexts
+ }
+ for i := 0; i < limit; i++ {
+ text := posts[i].Text
+ if len(text) > 500 {
+ text = text[:500]
+ }
+ fmt.Fprintf(&b, "[%d]\n%s\n\n", i+1, text)
+ }
+ return b.String()
+}
+
func BuildUserPrompt(username string, posts []Post) string {
var b strings.Builder
fmt.Fprintf(&b, "對標帳號:@%s\n近期貼文樣本:\n\n", strings.TrimPrefix(username, "@"))
@@ -144,6 +272,12 @@ func SerializePersonaDraft(draft PersonaDraft) string {
"【語氣】\n" + strings.TrimSpace(draft.Tone),
"【對誰說】\n" + strings.TrimSpace(draft.Audience),
"【開場習慣】\n" + strings.TrimSpace(draft.Hooks),
+ "【語言指紋】\n" + strings.TrimSpace(draft.LanguageFingerprint),
+ "【段落節奏】\n" + strings.TrimSpace(draft.Rhythm),
+ "【標點與換行】\n" + strings.TrimSpace(draft.Punctuation),
+ "【內容型態】\n" + strings.TrimSpace(draft.ContentPatterns),
+ "【知識轉譯】\n" + strings.TrimSpace(draft.KnowledgeTranslation),
+ "【互動與 CTA】\n" + strings.TrimSpace(draft.CTAStyle),
"【代表句範例】\n" + strings.TrimSpace(draft.Examples),
"【避免】\n" + strings.TrimSpace(draft.Avoid),
}
@@ -211,8 +345,26 @@ func buildSamplePosts(posts []Post) []Post {
}
func BuildStoredProfile(username string, posts []Post, out LLMOutput) StoredProfile {
+ profile := baseStoredProfile(posts, out)
+ profile.Username = strings.TrimPrefix(strings.TrimSpace(username), "@")
+ profile.SourceType = SourceTypeBenchmark
+ return profile
+}
+
+func BuildStoredProfileFromTexts(sourceLabel string, posts []Post, out LLMOutput) StoredProfile {
+ label := strings.TrimSpace(sourceLabel)
+ if label == "" {
+ label = "手動貼文"
+ }
+ profile := baseStoredProfile(posts, out)
+ profile.SourceType = SourceTypeManual
+ profile.SourceLabel = label
+ profile.Engagement.Verdict = "not_applicable"
+ return profile
+}
+
+func baseStoredProfile(posts []Post, out LLMOutput) StoredProfile {
return StoredProfile{
- Username: strings.TrimPrefix(strings.TrimSpace(username), "@"),
AnalyzedAt: time.Now().UTC().Format(time.RFC3339),
PostCount: len(posts),
Engagement: EvaluateEngagement(posts, 10),
@@ -289,7 +441,23 @@ func extractJSONObject(raw string) ([]byte, error) {
if start < 0 || end <= start {
return nil, fmt.Errorf("LLM response does not contain JSON object")
}
- return []byte(text[start : end+1]), nil
+ return repairTruncatedJSON([]byte(text[start : end+1])), nil
+}
+
+// repairTruncatedJSON adds missing closing braces/brackets when the LLM output
+// was cut off mid-JSON. Returns nil if no repair was needed or possible.
+func repairTruncatedJSON(raw []byte) []byte {
+ opens := bytes.Count(raw, []byte("{"))
+ closes := bytes.Count(raw, []byte("}"))
+ if closes < opens {
+ raw = append(raw, bytes.Repeat([]byte("}"), opens-closes)...)
+ }
+ bracketOpens := bytes.Count(raw, []byte("["))
+ bracketCloses := bytes.Count(raw, []byte("]"))
+ if bracketCloses < bracketOpens {
+ raw = append(raw, bytes.Repeat([]byte("]"), bracketOpens-bracketCloses)...)
+ }
+ return raw
}
func parseDimension(root map[string]json.RawMessage, keys ...string) Dimension {
@@ -332,12 +500,18 @@ func parsePersonaDraft(root map[string]json.RawMessage) PersonaDraft {
continue
}
return PersonaDraft{
- Identity: firstString(obj, "identity", "role"),
- Tone: firstString(obj, "tone", "voice"),
- Audience: firstString(obj, "audience", "targetAudience"),
- Hooks: firstString(obj, "hooks", "openings"),
- Examples: firstString(obj, "examples", "sample"),
- Avoid: firstString(obj, "avoid", "risks"),
+ Identity: firstString(obj, "identity", "role"),
+ Tone: firstString(obj, "tone", "voice"),
+ Audience: firstString(obj, "audience", "targetAudience"),
+ Hooks: firstString(obj, "hooks", "openings"),
+ LanguageFingerprint: firstString(obj, "languageFingerprint", "language_fingerprint", "signaturePhrases", "signature_phrases"),
+ Rhythm: firstString(obj, "rhythm", "paragraphRhythm", "paragraph_rhythm"),
+ Punctuation: firstString(obj, "punctuation", "visualSyntax", "visual_syntax"),
+ ContentPatterns: firstString(obj, "contentPatterns", "content_patterns", "structures"),
+ KnowledgeTranslation: firstString(obj, "knowledgeTranslation", "knowledge_translation", "researchTranslation", "research_translation"),
+ CTAStyle: firstString(obj, "ctaStyle", "cta_style", "conversionStyle", "conversion_style"),
+ Examples: firstString(obj, "examples", "sample"),
+ Avoid: firstString(obj, "avoid", "risks"),
}
}
return PersonaDraft{}
diff --git a/backend/internal/library/style8d/analyze_test.go b/backend/internal/library/style8d/analyze_test.go
index 6d17eb3..d8ee8b0 100644
--- a/backend/internal/library/style8d/analyze_test.go
+++ b/backend/internal/library/style8d/analyze_test.go
@@ -1,9 +1,12 @@
package style8d
-import "testing"
+import (
+ "strings"
+ "testing"
+)
func TestParseLLMOutput(t *testing.T) {
- raw := `{"d1Tone":{"summary":"口語親近","evidence":["真的太好笑"]},"d2Structure":{"summary":"短段落開場","evidence":[]},"d3Interaction":{"summary":"常用提問","evidence":[]},"d4Topics":{"summary":"生活與成長","evidence":[]},"d5Rhythm":{"summary":"晚間發文較多","evidence":[]},"d6Visual":{"summary":"愛用換行與 emoji","evidence":[]},"d7Conversion":{"summary":"自然導留言","evidence":[]},"d8Risk":{"summary":"避免照抄","evidence":[]},"personaDraft":{"identity":"生活觀察者","tone":"輕鬆","audience":"同齡上班族","hooks":"先丟痛點","examples":"有時候真的會累","avoid":"不要硬銷"}}`
+ raw := `{"d1Tone":{"summary":"口語親近","evidence":["真的太好笑"]},"d2Structure":{"summary":"短段落開場","evidence":[]},"d3Interaction":{"summary":"常用提問","evidence":[]},"d4Topics":{"summary":"生活與成長","evidence":[]},"d5Rhythm":{"summary":"晚間發文較多","evidence":[]},"d6Visual":{"summary":"愛用換行與 emoji","evidence":[]},"d7Conversion":{"summary":"自然導留言","evidence":[]},"d8Risk":{"summary":"避免照抄","evidence":[]},"personaDraft":{"identity":"生活觀察者","tone":"輕鬆","audience":"同齡上班族","hooks":"先丟痛點","languageFingerprint":"常用有時候、後來當轉折","rhythm":"短段落加空行","punctuation":"保留逗號與問號","contentPatterns":"心情文先承接情緒","knowledgeTranslation":"先講情境再整理重點","ctaStyle":"自然邀請留言","examples":"有時候真的會累","avoid":"不要硬銷"}}`
out, err := ParseLLMOutput(raw)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -23,4 +26,7 @@ func TestParseLLMOutput(t *testing.T) {
if len(profile.SamplePosts) != 1 || profile.SamplePosts[0].Permalink == "" {
t.Fatalf("samplePosts=%+v", profile.SamplePosts)
}
+ if !strings.Contains(profile.PersonaDraft, "【語言指紋】") {
+ t.Fatalf("personaDraft missing language fingerprint: %q", profile.PersonaDraft)
+ }
}
diff --git a/backend/internal/library/style8d/prompt.go b/backend/internal/library/style8d/prompt.go
index bbd6e33..687848c 100644
--- a/backend/internal/library/style8d/prompt.go
+++ b/backend/internal/library/style8d/prompt.go
@@ -44,14 +44,29 @@ func HasReady8D(personaText, styleProfileJSON string) bool {
return BuildStyle8DPromptBlock(styleProfileJSON) != ""
}
+var copyVoiceDimensionOrder = []string{"d1Tone", "d8Risk"}
+
// BuildStyle8DPromptBlock formats D1–D8 summaries for LLM prompts.
func BuildStyle8DPromptBlock(styleProfileJSON string) string {
+ return buildStylePromptBlock(styleProfileJSON, dimensionOrder, "【8D 風格策略】\n產文必須遵守:\n")
+}
+
+// BuildLightPersonaVoiceBlock keeps only tone + risk guardrails for copy generation.
+func BuildLightPersonaVoiceBlock(styleProfileJSON string) string {
+ return buildStylePromptBlock(
+ styleProfileJSON,
+ copyVoiceDimensionOrder,
+ "【語氣與禁忌參考】\n以下僅供口語風格與紅線參考,勿照搬句型、段落模板或固定 hook:\n",
+ )
+}
+
+func buildStylePromptBlock(styleProfileJSON string, keys []string, header string) string {
profile, ok := ParseStoredProfile(styleProfileJSON)
if !ok {
return ""
}
- lines := make([]string, 0, len(dimensionOrder))
- for _, key := range dimensionOrder {
+ lines := make([]string, 0, len(keys))
+ for _, key := range keys {
summary := strings.TrimSpace(profile.Analysis[key].Summary)
if summary == "" {
continue
@@ -66,30 +81,31 @@ func BuildStyle8DPromptBlock(styleProfileJSON string) string {
return ""
}
var b strings.Builder
- b.WriteString("【8D 風格策略】\n產文必須遵守:\n")
+ b.WriteString(header)
b.WriteString(strings.Join(lines, "\n"))
return b.String()
}
-// ResolvePersonaBlock picks the best persona voice block for copy generation.
-// Priority: explicit persona field → 8D personaDraft → brief fallback; always append 8D strategy when present.
+// ResolvePersonaBlock combines explicit persona positioning with analyzed writing fingerprints.
+// The persona field answers "who this is"; personaDraft/style profile answers "how this person says things".
func ResolvePersonaBlock(personaText, styleProfileJSON, brief string) string {
var parts []string
- voice := strings.TrimSpace(personaText)
- if voice == "" {
- if profile, ok := ParseStoredProfile(styleProfileJSON); ok {
- voice = strings.TrimSpace(profile.PersonaDraft)
- }
- }
- if voice == "" {
- voice = strings.TrimSpace(brief)
- }
- if voice != "" {
- parts = append(parts, "【人設語氣】\n"+voice)
+ profile, hasProfile := ParseStoredProfile(styleProfileJSON)
+ persona := strings.TrimSpace(personaText)
+ if persona != "" {
+ parts = append(parts, "【人設定位】\n"+persona)
+ } else if fallback := strings.TrimSpace(brief); fallback != "" {
+ parts = append(parts, "【人設定位】\n"+fallback)
}
- if block := BuildStyle8DPromptBlock(styleProfileJSON); block != "" {
+ if hasProfile {
+ if draft := strings.TrimSpace(profile.PersonaDraft); draft != "" {
+ parts = append(parts, "【語言指紋與寫作規則】\n"+draft)
+ }
+ }
+
+ if block := BuildLightPersonaVoiceBlock(styleProfileJSON); block != "" {
parts = append(parts, block)
}
return strings.TrimSpace(strings.Join(parts, "\n\n"))
diff --git a/backend/internal/library/style8d/prompt_test.go b/backend/internal/library/style8d/prompt_test.go
index 9fdbb02..bbd42c8 100644
--- a/backend/internal/library/style8d/prompt_test.go
+++ b/backend/internal/library/style8d/prompt_test.go
@@ -11,7 +11,17 @@ func TestResolvePersonaBlockUsesPersonaDraftWhenPersonaEmpty(t *testing.T) {
if block == "" {
t.Fatal("expected non-empty block")
}
- for _, part := range []string{"生活觀察者", "D1 語氣人格", "口語親近"} {
+ for _, part := range []string{"生活觀察者", "D1 語氣人格", "口語親近", "語言指紋與寫作規則"} {
+ if !strings.Contains(block, part) {
+ t.Fatalf("block missing %q: %q", part, block)
+ }
+ }
+}
+
+func TestResolvePersonaBlockCombinesPersonaAndStyleFingerprint(t *testing.T) {
+ raw := `{"analysis":{"d1Tone":{"summary":"口語親近","evidence":[]}},"personaDraft":"【語言指紋】\n常用慢慢、其實當轉折"}`
+ block := ResolvePersonaBlock("備孕陪伴帳號", raw, "")
+ for _, part := range []string{"備孕陪伴帳號", "常用慢慢", "語言指紋與寫作規則"} {
if !strings.Contains(block, part) {
t.Fatalf("block missing %q: %q", part, block)
}
diff --git a/backend/internal/library/style8d/runner.go b/backend/internal/library/style8d/runner.go
new file mode 100644
index 0000000..4ed44c3
--- /dev/null
+++ b/backend/internal/library/style8d/runner.go
@@ -0,0 +1,41 @@
+package style8d
+
+import "strings"
+
+// AnalyzeMode selects how reference samples are interpreted.
+type AnalyzeMode string
+
+const (
+ AnalyzeModeBenchmark AnalyzeMode = "benchmark"
+ AnalyzeModeManual AnalyzeMode = "manual"
+)
+
+// AnalyzeInput groups prompt + storage options for one 8D run.
+type AnalyzeInput struct {
+ Mode AnalyzeMode
+ Username string
+ SourceLabel string
+ Brief string
+ Posts []Post
+}
+
+func (in AnalyzeInput) BuildUserPrompt() string {
+ if in.Mode == AnalyzeModeManual || strings.TrimSpace(in.Username) == "" {
+ return BuildUserPromptFromTexts(in.SourceLabel, in.Brief, in.Posts)
+ }
+ return BuildUserPrompt(in.Username, in.Posts)
+}
+
+func (in AnalyzeInput) BuildStoredProfile(out LLMOutput) StoredProfile {
+ if in.Mode == AnalyzeModeManual || strings.TrimSpace(in.Username) == "" {
+ return BuildStoredProfileFromTexts(in.SourceLabel, in.Posts, out)
+ }
+ return BuildStoredProfile(in.Username, in.Posts, out)
+}
+
+func (in AnalyzeInput) StyleBenchmark() string {
+ if in.Mode == AnalyzeModeManual || strings.TrimSpace(in.Username) == "" {
+ return ManualStyleBenchmark(in.SourceLabel)
+ }
+ return strings.TrimPrefix(strings.TrimSpace(in.Username), "@")
+}
diff --git a/backend/internal/library/style8d/text_input_test.go b/backend/internal/library/style8d/text_input_test.go
new file mode 100644
index 0000000..d351898
--- /dev/null
+++ b/backend/internal/library/style8d/text_input_test.go
@@ -0,0 +1,73 @@
+package style8d
+
+import "testing"
+
+func TestNormalizeReferenceTextsFromArray(t *testing.T) {
+ texts, err := NormalizeReferenceTexts([]string{"第一段參考文字內容超過二十個字元", "第二段參考文字內容也超過二十個字元"}, "")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(texts) != 2 {
+ t.Fatalf("texts=%v", texts)
+ }
+}
+
+func TestNormalizeReferenceTextsFromRawText(t *testing.T) {
+ raw := "第一段參考文字內容超過二十個字元\n還有第二行\n\n---\n\n第二段參考文字內容也超過二十個字元"
+ texts, err := NormalizeReferenceTexts(nil, raw)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(texts) != 2 {
+ t.Fatalf("texts=%v", texts)
+ }
+}
+
+func TestBuildUserPromptFromTexts(t *testing.T) {
+ got := BuildUserPromptFromTexts("我的舊文", "產品教學帳", TextsToPosts([]string{"這是一段超過二十個字的參考文字樣本內容"}))
+ if got == "" {
+ t.Fatal("empty prompt")
+ }
+ if !containsAll(got, "我的舊文", "產品教學帳", "[1]") {
+ t.Fatalf("prompt=%q", got)
+ }
+}
+
+func TestBuildStoredProfileFromTexts(t *testing.T) {
+ out, err := ParseLLMOutput(`{"d1Tone":{"summary":"口語","evidence":[]},"d2Structure":{"summary":"短句","evidence":[]},"d3Interaction":{"summary":"提問","evidence":[]},"d4Topics":{"summary":"生活","evidence":[]},"d5Rhythm":{"summary":"晚間","evidence":[]},"d6Visual":{"summary":"emoji","evidence":[]},"d7Conversion":{"summary":"留言","evidence":[]},"d8Risk":{"summary":"硬銷","evidence":[]},"personaDraft":{"identity":"觀察者","tone":"輕鬆","audience":"上班族","hooks":"痛點","examples":"有時候真的會累","avoid":"硬銷"}}`)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ profile := BuildStoredProfileFromTexts("手動貼文", TextsToPosts([]string{"sample text long enough"}), out)
+ if profile.SourceType != SourceTypeManual {
+ t.Fatalf("sourceType=%q", profile.SourceType)
+ }
+ if profile.Engagement.Verdict != "not_applicable" {
+ t.Fatalf("verdict=%q", profile.Engagement.Verdict)
+ }
+ if ManualStyleBenchmark("手動貼文") != "manual:手動貼文" {
+ t.Fatal("manual benchmark mismatch")
+ }
+}
+
+func containsAll(s string, parts ...string) bool {
+ for _, part := range parts {
+ if !contains(s, part) {
+ return false
+ }
+ }
+ return true
+}
+
+func contains(s, sub string) bool {
+ return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
+}
+
+func indexOf(s, sub string) int {
+ for i := 0; i+len(sub) <= len(s); i++ {
+ if s[i:i+len(sub)] == sub {
+ return i
+ }
+ }
+ return -1
+}
diff --git a/backend/internal/library/threadsapi/extended.go b/backend/internal/library/threadsapi/extended.go
new file mode 100644
index 0000000..7f60492
--- /dev/null
+++ b/backend/internal/library/threadsapi/extended.go
@@ -0,0 +1,522 @@
+package threadsapi
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+const (
+ // userThreadsListFields lists fields supported on GET /{user-id}/threads.
+ userThreadsListFields = "id,text,permalink,timestamp,media_type,media_url,thumbnail_url,topic_tag,shortcode"
+)
+
+type MediaItem struct {
+ ID string `json:"id"`
+ Shortcode string `json:"shortcode"`
+ Text string `json:"text"`
+ Permalink string `json:"permalink"`
+ Timestamp string `json:"timestamp"`
+ MediaType string `json:"media_type"`
+ MediaURL string `json:"media_url"`
+ ThumbnailURL string `json:"thumbnail_url"`
+ TopicTag string `json:"topic_tag"`
+ LikeCount int `json:"like_count"`
+ ReplyCount int `json:"reply_count"`
+}
+
+type ProfileLookupResult struct {
+ Username string `json:"username"`
+ Name string `json:"name"`
+ ProfilePictureURL string `json:"profile_picture_url,omitempty"`
+ Biography string `json:"biography,omitempty"`
+ FollowerCount int `json:"follower_count,omitempty"`
+ LikesCount int `json:"likes_count,omitempty"`
+ QuotesCount int `json:"quotes_count,omitempty"`
+ RepostsCount int `json:"reposts_count,omitempty"`
+ ViewsCount int `json:"views_count,omitempty"`
+ IsVerified bool `json:"is_verified,omitempty"`
+}
+
+type MentionItem struct {
+ ID string `json:"id"`
+ Text string `json:"text"`
+ Username string `json:"username"`
+ Permalink string `json:"permalink"`
+ Timestamp string `json:"timestamp"`
+ MediaType string `json:"media_type"`
+ IsReply bool `json:"is_reply"`
+ IsQuotePost bool `json:"is_quote_post"`
+ HasReplies bool `json:"has_replies"`
+ RootPostID string `json:"root_post_id,omitempty"`
+ ParentID string `json:"parent_id,omitempty"`
+}
+
+type LocationItem struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+type InsightValue struct {
+ Name string `json:"name"`
+ Values []struct {
+ Value int `json:"value"`
+ } `json:"values"`
+}
+
+// UserThreads lists the authenticated user's own posts (threads_basic).
+func (c *Client) UserThreads(ctx context.Context, userID string, limit int) ([]MediaItem, error) {
+ if !c.Enabled() {
+ return nil, fmt.Errorf("threads api access token is required")
+ }
+ userID = strings.TrimSpace(userID)
+ if userID == "" {
+ return nil, fmt.Errorf("threads user id is required")
+ }
+ if limit <= 0 {
+ limit = 25
+ }
+ if limit > 100 {
+ limit = 100
+ }
+ params := url.Values{}
+ params.Set("access_token", c.accessToken)
+ params.Set("fields", userThreadsListFields)
+ params.Set("limit", fmt.Sprintf("%d", limit))
+ endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "/threads?" + params.Encode()
+ return c.getMediaList(ctx, endpoint)
+}
+
+// DeleteMedia deletes a Threads post (threads_delete).
+func (c *Client) DeleteMedia(ctx context.Context, mediaID string) error {
+ if !c.Enabled() {
+ return fmt.Errorf("threads api access token is required")
+ }
+ mediaID = strings.TrimSpace(mediaID)
+ if mediaID == "" {
+ return fmt.Errorf("threads media id is required")
+ }
+ params := url.Values{}
+ params.Set("access_token", c.accessToken)
+ endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "?" + params.Encode()
+ req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return err
+ }
+ body, status, err := doRequest(req)
+ if err != nil {
+ return err
+ }
+ if status != http.StatusOK {
+ return parseAPIError(body, status)
+ }
+ return nil
+}
+
+// ProfilePosts lists recent public posts for a username (threads_profile_discovery).
+func (c *Client) ProfilePosts(ctx context.Context, username string, limit int, after string) ([]MediaItem, string, error) {
+ if !c.Enabled() {
+ return nil, "", fmt.Errorf("threads api access token is required")
+ }
+ username = strings.TrimPrefix(strings.TrimSpace(username), "@")
+ if username == "" {
+ return nil, "", fmt.Errorf("username is required")
+ }
+ if limit <= 0 {
+ limit = 50
+ }
+ if limit > 100 {
+ limit = 100
+ }
+ params := url.Values{}
+ params.Set("access_token", c.accessToken)
+ params.Set("username", username)
+ params.Set("fields", "id,shortcode,permalink,text,timestamp")
+ params.Set("limit", fmt.Sprintf("%d", limit))
+ if after = strings.TrimSpace(after); after != "" {
+ params.Set("after", after)
+ }
+ endpoint := graphBaseURL + "/profile_posts?" + params.Encode()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ body, status, err := doRequest(req)
+ if err != nil {
+ return nil, "", err
+ }
+ if status != http.StatusOK {
+ return nil, "", parseAPIError(body, status)
+ }
+ var payload struct {
+ Data []MediaItem `json:"data"`
+ Paging struct {
+ Cursors struct {
+ After string `json:"after"`
+ } `json:"cursors"`
+ } `json:"paging"`
+ }
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return nil, "", fmt.Errorf("parse profile_posts: %w", err)
+ }
+ return payload.Data, strings.TrimSpace(payload.Paging.Cursors.After), nil
+}
+
+// FindMediaIDByAuthorShortcode scans profile_posts pages until shortcode is found.
+func (c *Client) FindMediaIDByAuthorShortcode(ctx context.Context, username, shortcode string) (string, error) {
+ username = strings.TrimPrefix(strings.TrimSpace(username), "@")
+ shortcode = strings.TrimSpace(shortcode)
+ if username == "" || shortcode == "" {
+ return "", fmt.Errorf("username and shortcode are required")
+ }
+ wantSuffix := "/post/" + shortcode
+ after := ""
+ for page := 0; page < 3; page++ {
+ items, next, err := c.ProfilePosts(ctx, username, 50, after)
+ if err != nil {
+ return "", err
+ }
+ for _, item := range items {
+ id := strings.TrimSpace(item.ID)
+ if !IsNumericMediaID(id) {
+ continue
+ }
+ if strings.EqualFold(strings.TrimSpace(item.Shortcode), shortcode) {
+ return id, nil
+ }
+ link := strings.TrimSpace(item.Permalink)
+ if link != "" && strings.HasSuffix(strings.Split(link, "?")[0], wantSuffix) {
+ return id, nil
+ }
+ }
+ if next == "" || len(items) == 0 {
+ break
+ }
+ after = next
+ }
+ return "", fmt.Errorf("profile_posts did not include @%s post %s", username, shortcode)
+}
+
+// ProfileLookup fetches a public profile by username (threads_profile_discovery).
+func (c *Client) ProfileLookup(ctx context.Context, username string) (*ProfileLookupResult, error) {
+ if !c.Enabled() {
+ return nil, fmt.Errorf("threads api access token is required")
+ }
+ username = strings.TrimPrefix(strings.TrimSpace(username), "@")
+ if username == "" {
+ return nil, fmt.Errorf("username is required")
+ }
+ params := url.Values{}
+ params.Set("access_token", c.accessToken)
+ params.Set("username", username)
+ params.Set("fields", "username,name,profile_picture_url,biography,follower_count,likes_count,quotes_count,reposts_count,views_count,is_verified")
+ endpoint := graphBaseURL + "/profile_lookup?" + params.Encode()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ body, status, err := doRequest(req)
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, parseAPIError(body, status)
+ }
+ var out ProfileLookupResult
+ if err := json.Unmarshal(body, &out); err != nil {
+ return nil, fmt.Errorf("parse profile_lookup: %w", err)
+ }
+ return &out, nil
+}
+
+type mentionItemRaw struct {
+ ID string `json:"id"`
+ Text string `json:"text"`
+ Username string `json:"username"`
+ Permalink string `json:"permalink"`
+ Timestamp string `json:"timestamp"`
+ MediaType string `json:"media_type"`
+ IsReply bool `json:"is_reply"`
+ IsQuotePost bool `json:"is_quote_post"`
+ HasReplies bool `json:"has_replies"`
+ RootPost json.RawMessage `json:"root_post"`
+ ParentID string `json:"parent_id"`
+}
+
+func normalizeMentionItem(raw mentionItemRaw) MentionItem {
+ return MentionItem{
+ ID: strings.TrimSpace(raw.ID),
+ Text: strings.TrimSpace(raw.Text),
+ Username: strings.TrimSpace(raw.Username),
+ Permalink: strings.TrimSpace(raw.Permalink),
+ Timestamp: strings.TrimSpace(raw.Timestamp),
+ MediaType: strings.TrimSpace(raw.MediaType),
+ IsReply: raw.IsReply,
+ IsQuotePost: raw.IsQuotePost,
+ HasReplies: raw.HasReplies,
+ RootPostID: parseMediaRefID(raw.RootPost),
+ ParentID: strings.TrimSpace(raw.ParentID),
+ }
+}
+
+// UserMentions lists the first page of posts mentioning the user (threads_manage_mentions).
+func (c *Client) UserMentions(ctx context.Context, userID string, limit int) ([]MentionItem, error) {
+ items, _, err := c.UserMentionsPage(ctx, userID, limit, "")
+ return items, err
+}
+
+// UserMentionsPage lists one cursor page of mentions. nextAfter is empty when there is no next page.
+func (c *Client) UserMentionsPage(ctx context.Context, userID string, limit int, after string) ([]MentionItem, string, error) {
+ if !c.Enabled() {
+ return nil, "", fmt.Errorf("threads api access token is required")
+ }
+ userID = strings.TrimSpace(userID)
+ if userID == "" {
+ return nil, "", fmt.Errorf("threads user id is required")
+ }
+ if limit <= 0 {
+ limit = 25
+ }
+ if limit > 100 {
+ limit = 100
+ }
+ params := url.Values{}
+ params.Set("access_token", c.accessToken)
+ params.Set("fields", "id,text,username,permalink,timestamp,media_type,is_reply,is_quote_post,has_replies,root_post,parent_id")
+ params.Set("limit", fmt.Sprintf("%d", limit))
+ if after = strings.TrimSpace(after); after != "" {
+ params.Set("after", after)
+ }
+ endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "/mentions?" + params.Encode()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ body, status, err := doRequest(req)
+ if err != nil {
+ return nil, "", err
+ }
+ if status != http.StatusOK {
+ return nil, "", parseAPIError(body, status)
+ }
+ var payload struct {
+ Data []mentionItemRaw `json:"data"`
+ Paging struct {
+ Cursors struct {
+ After string `json:"after"`
+ } `json:"cursors"`
+ } `json:"paging"`
+ }
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return nil, "", fmt.Errorf("parse mentions: %w", err)
+ }
+ out := make([]MentionItem, 0, len(payload.Data))
+ for _, raw := range payload.Data {
+ out = append(out, normalizeMentionItem(raw))
+ }
+ return out, strings.TrimSpace(payload.Paging.Cursors.After), nil
+}
+
+// LocationSearch searches public locations (threads_location_tagging).
+func (c *Client) LocationSearch(ctx context.Context, query string, limit int) ([]LocationItem, error) {
+ if !c.Enabled() {
+ return nil, fmt.Errorf("threads api access token is required")
+ }
+ query = strings.TrimSpace(query)
+ if query == "" {
+ return nil, fmt.Errorf("location query is required")
+ }
+ if limit <= 0 {
+ limit = 10
+ }
+ if limit > 50 {
+ limit = 50
+ }
+ params := url.Values{}
+ params.Set("access_token", c.accessToken)
+ params.Set("q", query)
+ params.Set("fields", "id,name")
+ params.Set("limit", fmt.Sprintf("%d", limit))
+ endpoint := graphBaseURL + "/location_search?" + params.Encode()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ body, status, err := doRequest(req)
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, parseAPIError(body, status)
+ }
+ var payload struct {
+ Data []LocationItem `json:"data"`
+ }
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return nil, fmt.Errorf("parse location_search: %w", err)
+ }
+ return payload.Data, nil
+}
+
+// ShareToInstagram cross-posts a Threads media to linked Instagram (threads_share_to_instagram).
+func (c *Client) ShareToInstagram(ctx context.Context, mediaID string) error {
+ if !c.Enabled() {
+ return fmt.Errorf("threads api access token is required")
+ }
+ mediaID = strings.TrimSpace(mediaID)
+ if mediaID == "" {
+ return fmt.Errorf("threads media id is required")
+ }
+ params := url.Values{}
+ params.Set("access_token", c.accessToken)
+ endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "/crosspost?" + params.Encode()
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
+ if err != nil {
+ return err
+ }
+ body, status, err := doRequest(req)
+ if err != nil {
+ return err
+ }
+ if status != http.StatusOK {
+ return parseAPIError(body, status)
+ }
+ return nil
+}
+
+// HideReply hides a reply on the user's thread (threads_manage_replies).
+func (c *Client) HideReply(ctx context.Context, replyID string, hide bool) error {
+ if !c.Enabled() {
+ return fmt.Errorf("threads api access token is required")
+ }
+ replyID = strings.TrimSpace(replyID)
+ if replyID == "" {
+ return fmt.Errorf("reply id is required")
+ }
+ params := url.Values{}
+ params.Set("access_token", c.accessToken)
+ params.Set("hide", fmt.Sprintf("%t", hide))
+ endpoint := graphBaseURL + "/" + url.PathEscape(replyID) + "?" + params.Encode()
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
+ if err != nil {
+ return err
+ }
+ body, status, err := doRequest(req)
+ if err != nil {
+ return err
+ }
+ if status != http.StatusOK {
+ return parseAPIError(body, status)
+ }
+ return nil
+}
+
+// MediaInsights fetches per-post lifetime metrics (threads_manage_insights).
+func (c *Client) MediaInsights(ctx context.Context, mediaID string, metrics []string) (map[string]int, error) {
+ if !c.Enabled() {
+ return nil, fmt.Errorf("threads api access token is required")
+ }
+ mediaID = strings.TrimSpace(mediaID)
+ if mediaID == "" {
+ return nil, fmt.Errorf("threads media id is required")
+ }
+ if len(metrics) == 0 {
+ metrics = []string{"views", "likes", "replies", "reposts", "quotes", "shares"}
+ }
+ params := url.Values{}
+ params.Set("access_token", c.accessToken)
+ params.Set("metric", strings.Join(metrics, ","))
+ endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "/insights?" + params.Encode()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ body, status, err := doRequest(req)
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, parseAPIError(body, status)
+ }
+ var payload struct {
+ Data []InsightValue `json:"data"`
+ }
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return nil, fmt.Errorf("parse media insights: %w", err)
+ }
+ out := make(map[string]int, len(payload.Data))
+ for _, item := range payload.Data {
+ name := strings.TrimSpace(strings.ToLower(item.Name))
+ if name == "" || len(item.Values) == 0 {
+ continue
+ }
+ out[name] = item.Values[0].Value
+ }
+ return out, nil
+}
+
+// UserInsights fetches profile-level insights (threads_manage_insights).
+func (c *Client) UserInsights(ctx context.Context, userID string, metrics []string) ([]InsightValue, error) {
+ if !c.Enabled() {
+ return nil, fmt.Errorf("threads api access token is required")
+ }
+ userID = strings.TrimSpace(userID)
+ if userID == "" {
+ return nil, fmt.Errorf("threads user id is required")
+ }
+ if len(metrics) == 0 {
+ metrics = []string{"views", "likes", "replies", "reposts", "quotes", "followers_count"}
+ }
+ params := url.Values{}
+ params.Set("access_token", c.accessToken)
+ params.Set("metric", strings.Join(metrics, ","))
+ endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "/threads_insights?" + params.Encode()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ body, status, err := doRequest(req)
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, parseAPIError(body, status)
+ }
+ var payload struct {
+ Data []InsightValue `json:"data"`
+ }
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return nil, fmt.Errorf("parse threads_insights: %w", err)
+ }
+ return payload.Data, nil
+}
+
+func (c *Client) getMediaList(ctx context.Context, endpoint string) ([]MediaItem, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ res, err := c.http.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
+ if err != nil {
+ return nil, err
+ }
+ if res.StatusCode != http.StatusOK {
+ return nil, parseAPIError(body, res.StatusCode)
+ }
+ var payload struct {
+ Data []MediaItem `json:"data"`
+ }
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return nil, fmt.Errorf("parse threads media list: %w", err)
+ }
+ return payload.Data, nil
+}
diff --git a/backend/internal/library/threadsapi/fields_test.go b/backend/internal/library/threadsapi/fields_test.go
new file mode 100644
index 0000000..ae3314f
--- /dev/null
+++ b/backend/internal/library/threadsapi/fields_test.go
@@ -0,0 +1,27 @@
+package threadsapi
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestUserThreadsListFieldsExcludeEngagement(t *testing.T) {
+ if fieldInList(userThreadsListFields, "like_count") || fieldInList(userThreadsListFields, "reply_count") {
+ t.Fatalf("userThreadsListFields must not request engagement fields: %q", userThreadsListFields)
+ }
+}
+
+func TestMediaConversationFieldsExcludeEngagement(t *testing.T) {
+ if fieldInList(mediaConversationFields, "like_count") {
+ t.Fatalf("mediaConversationFields must not request like_count: %q", mediaConversationFields)
+ }
+}
+
+func fieldInList(fields, name string) bool {
+ for _, part := range strings.Split(fields, ",") {
+ if strings.TrimSpace(part) == name {
+ return true
+ }
+ }
+ return false
+}
diff --git a/backend/internal/library/threadsapi/media.go b/backend/internal/library/threadsapi/media.go
index eb62234..b0e203d 100644
--- a/backend/internal/library/threadsapi/media.go
+++ b/backend/internal/library/threadsapi/media.go
@@ -6,53 +6,99 @@ import (
"fmt"
"net/http"
"net/url"
+ "strings"
"time"
)
+type MediaDetail struct {
+ ID string `json:"id"`
+ Text string `json:"text"`
+ Username string `json:"username"`
+ Permalink string `json:"permalink"`
+ Timestamp string `json:"timestamp"`
+ MediaType string `json:"media_type"`
+ IsReply bool `json:"is_reply"`
+}
+
+func (c *Client) GetMediaDetail(ctx context.Context, mediaID string) (*MediaDetail, error) {
+ if !c.Enabled() {
+ return nil, fmt.Errorf("threads api access token is required")
+ }
+ mediaID = strings.TrimSpace(mediaID)
+ if mediaID == "" {
+ return nil, fmt.Errorf("threads media id is required")
+ }
+ params := url.Values{}
+ params.Set("access_token", c.accessToken)
+ params.Set("fields", "id,text,username,permalink,timestamp,media_type,is_reply")
+ endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "?" + params.Encode()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ body, status, err := doRequest(req)
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, parseAPIError(body, status)
+ }
+ var out MediaDetail
+ if err := json.Unmarshal(body, &out); err != nil {
+ return nil, fmt.Errorf("parse media detail: %w", err)
+ }
+ out.ID = strings.TrimSpace(out.ID)
+ if out.ID == "" {
+ return nil, fmt.Errorf("threads media detail missing id")
+ }
+ return &out, nil
+}
+
type MediaStats struct {
LikeCount int `json:"like_count"`
ReplyCount int `json:"reply_count"`
RepostCount int `json:"repost_count"`
QuoteCount int `json:"quote_count"`
+ Views int `json:"views,omitempty"`
+ Shares int `json:"shares,omitempty"`
}
+// GetMediaStats loads engagement counts via GET /{media-id}/insights.
+// Meta does not expose like_count on the media object itself (requesting those fields returns API errors).
func GetMediaStats(ctx context.Context, accessToken, mediaID string) (*MediaStats, error) {
- u := fmt.Sprintf("%s/%s", graphBaseURL, mediaID)
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
+ return NewClient(accessToken).GetMediaStats(ctx, mediaID)
+}
+
+func (c *Client) GetMediaStats(ctx context.Context, mediaID string) (*MediaStats, error) {
+ if !c.Enabled() {
+ return nil, fmt.Errorf("threads api access token is required")
+ }
+ mediaID = strings.TrimSpace(mediaID)
+ if mediaID == "" {
+ return nil, fmt.Errorf("threads media id is required")
+ }
+ metrics, err := c.MediaInsights(ctx, mediaID, []string{"likes", "replies", "reposts", "quotes", "views", "shares"})
if err != nil {
+ if IsPermissionError(err) {
+ return nil, fmt.Errorf("%w(貼文互動數需 threads_manage_insights,請確認 OAuth 已授權)", err)
+ }
return nil, err
}
- q := url.Values{}
- q.Set("fields", "like_count,reply_count,repost_count,quote_count")
- q.Set("access_token", accessToken)
- req.URL.RawQuery = q.Encode()
+ return mapInsightMetrics(metrics), nil
+}
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("threads api media stats error: %s", resp.Status)
- }
-
- var raw struct {
- ID string `json:"id"`
- LikeCount int `json:"like_count"`
- ReplyCount int `json:"reply_count"`
- RepostCount int `json:"repost_count"`
- QuoteCount int `json:"quote_count"`
- }
- if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
- return nil, err
+func mapInsightMetrics(metrics map[string]int) *MediaStats {
+ if metrics == nil {
+ metrics = map[string]int{}
}
return &MediaStats{
- LikeCount: raw.LikeCount,
- ReplyCount: raw.ReplyCount,
- RepostCount: raw.RepostCount,
- QuoteCount: raw.QuoteCount,
- }, nil
+ LikeCount: metrics["likes"],
+ ReplyCount: metrics["replies"],
+ RepostCount: metrics["reposts"],
+ QuoteCount: metrics["quotes"],
+ Views: metrics["views"],
+ Shares: metrics["shares"],
+ }
}
type TokenRefreshResult struct {
diff --git a/backend/internal/library/threadsapi/media_test.go b/backend/internal/library/threadsapi/media_test.go
new file mode 100644
index 0000000..5558c61
--- /dev/null
+++ b/backend/internal/library/threadsapi/media_test.go
@@ -0,0 +1,27 @@
+package threadsapi
+
+import "testing"
+
+func TestMapInsightMetrics(t *testing.T) {
+ stats := mapInsightMetrics(map[string]int{
+ "likes": 10,
+ "replies": 3,
+ "reposts": 2,
+ "quotes": 1,
+ "views": 99,
+ "shares": 4,
+ })
+ if stats.LikeCount != 10 || stats.ReplyCount != 3 || stats.RepostCount != 2 || stats.QuoteCount != 1 {
+ t.Fatalf("unexpected counts: %+v", stats)
+ }
+ if stats.Views != 99 || stats.Shares != 4 {
+ t.Fatalf("unexpected views/shares: %+v", stats)
+ }
+}
+
+func TestMapInsightMetricsEmpty(t *testing.T) {
+ stats := mapInsightMetrics(nil)
+ if stats.LikeCount != 0 || stats.ReplyCount != 0 {
+ t.Fatalf("expected zero stats, got %+v", stats)
+ }
+}
diff --git a/backend/internal/library/threadsapi/oauth.go b/backend/internal/library/threadsapi/oauth.go
new file mode 100644
index 0000000..f3b38e2
--- /dev/null
+++ b/backend/internal/library/threadsapi/oauth.go
@@ -0,0 +1,257 @@
+package threadsapi
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+const (
+ threadsAuthorizeURL = "https://threads.net/oauth/authorize"
+ threadsShortTokenURL = "https://graph.threads.net/oauth/access_token"
+ threadsLongLivedTokenURL = "https://graph.threads.net/access_token"
+)
+
+// flexString unmarshals JSON string or number (Meta often returns user_id as a number).
+type flexString string
+
+func (s *flexString) UnmarshalJSON(data []byte) error {
+ if len(data) == 0 || string(data) == "null" {
+ *s = ""
+ return nil
+ }
+ if data[0] == '"' {
+ var str string
+ if err := json.Unmarshal(data, &str); err != nil {
+ return err
+ }
+ *s = flexString(str)
+ return nil
+ }
+ var num json.Number
+ if err := json.Unmarshal(data, &num); err != nil {
+ return err
+ }
+ *s = flexString(num.String())
+ return nil
+}
+
+func (s flexString) String() string {
+ return strings.TrimSpace(string(s))
+}
+
+type OAuthConfig struct {
+ AppID string
+ AppSecret string
+ RedirectURI string
+ SuccessRedirect string
+}
+
+func (c OAuthConfig) Enabled() bool {
+ return strings.TrimSpace(c.AppID) != "" &&
+ strings.TrimSpace(c.AppSecret) != "" &&
+ strings.TrimSpace(c.RedirectURI) != ""
+}
+
+type TokenExchangeResult struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int `json:"expires_in"`
+ UserID flexString `json:"user_id"`
+}
+
+type UserProfile struct {
+ ID flexString `json:"id"`
+ Username string `json:"username"`
+ Name string `json:"name"`
+}
+
+func BuildAuthorizeURL(cfg OAuthConfig, state string) (string, error) {
+ if !cfg.Enabled() {
+ return "", fmt.Errorf("threads oauth is not configured")
+ }
+ state = strings.TrimSpace(state)
+ if state == "" {
+ return "", fmt.Errorf("oauth state is required")
+ }
+ params := url.Values{}
+ params.Set("client_id", strings.TrimSpace(cfg.AppID))
+ params.Set("redirect_uri", strings.TrimSpace(cfg.RedirectURI))
+ params.Set("scope", OAuthScopeString())
+ params.Set("response_type", "code")
+ params.Set("state", state)
+ return threadsAuthorizeURL + "?" + params.Encode(), nil
+}
+
+func ExchangeCodeForToken(ctx context.Context, cfg OAuthConfig, code string) (*TokenExchangeResult, error) {
+ if !cfg.Enabled() {
+ return nil, fmt.Errorf("threads oauth is not configured")
+ }
+ code = strings.TrimSpace(code)
+ if code == "" {
+ return nil, fmt.Errorf("authorization code is required")
+ }
+ // Meta may append #_ to the redirect code; strip suffix if present.
+ if idx := strings.Index(code, "#_"); idx >= 0 {
+ code = code[:idx]
+ }
+
+ params := url.Values{}
+ params.Set("client_id", strings.TrimSpace(cfg.AppID))
+ params.Set("client_secret", strings.TrimSpace(cfg.AppSecret))
+ params.Set("grant_type", "authorization_code")
+ params.Set("redirect_uri", strings.TrimSpace(cfg.RedirectURI))
+ params.Set("code", code)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, threadsShortTokenURL+"?"+params.Encode(), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ body, status, err := oauthDoRequest(req)
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, parseAPIError(body, status)
+ }
+ var result TokenExchangeResult
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parse threads token response: %w", err)
+ }
+ if strings.TrimSpace(result.AccessToken) == "" {
+ return nil, fmt.Errorf("threads token exchange returned empty access_token")
+ }
+ return &result, nil
+}
+
+func ExchangeLongLivedToken(ctx context.Context, cfg OAuthConfig, shortLivedToken string) (*TokenExchangeResult, error) {
+ if !cfg.Enabled() {
+ return nil, fmt.Errorf("threads oauth is not configured")
+ }
+ shortLivedToken = strings.TrimSpace(shortLivedToken)
+ if shortLivedToken == "" {
+ return nil, fmt.Errorf("short-lived access token is required")
+ }
+
+ params := url.Values{}
+ params.Set("grant_type", "th_exchange_token")
+ params.Set("client_secret", strings.TrimSpace(cfg.AppSecret))
+ params.Set("access_token", shortLivedToken)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, threadsLongLivedTokenURL+"?"+params.Encode(), nil)
+ if err != nil {
+ return nil, err
+ }
+ body, status, err := oauthDoRequest(req)
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, parseAPIError(body, status)
+ }
+ var result TokenExchangeResult
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parse long-lived token response: %w", err)
+ }
+ if strings.TrimSpace(result.AccessToken) == "" {
+ return nil, fmt.Errorf("long-lived token exchange returned empty access_token")
+ }
+ return &result, nil
+}
+
+func GetUserProfile(ctx context.Context, accessToken, userID string) (*UserProfile, error) {
+ accessToken = strings.TrimSpace(accessToken)
+ userID = strings.TrimSpace(userID)
+ if accessToken == "" {
+ return nil, fmt.Errorf("access token is required")
+ }
+ if userID == "" {
+ return GetMeProfile(ctx, accessToken)
+ }
+
+ params := url.Values{}
+ params.Set("access_token", accessToken)
+ params.Set("fields", "id,username,name")
+
+ endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "?" + params.Encode()
+ return fetchUserProfile(ctx, endpoint)
+}
+
+// GetMeProfile loads the authenticated Threads user via /me (preferred after OAuth).
+func GetMeProfile(ctx context.Context, accessToken string) (*UserProfile, error) {
+ accessToken = strings.TrimSpace(accessToken)
+ if accessToken == "" {
+ return nil, fmt.Errorf("access token is required")
+ }
+ params := url.Values{}
+ params.Set("access_token", accessToken)
+ params.Set("fields", "id,username,name")
+ endpoint := graphBaseURL + "/me?" + params.Encode()
+ return fetchUserProfile(ctx, endpoint)
+}
+
+// ThreadsUserIDFromToken picks user_id returned by the OAuth token exchange.
+func ThreadsUserIDFromToken(short, long *TokenExchangeResult) string {
+ if short != nil {
+ if id := short.UserID.String(); id != "" {
+ return id
+ }
+ }
+ if long != nil {
+ return long.UserID.String()
+ }
+ return ""
+}
+
+// IsPermissionError reports Meta permission / app-review / tester-list errors.
+func IsPermissionError(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := strings.ToLower(err.Error())
+ return strings.Contains(msg, "threads_basic") ||
+ strings.Contains(msg, "app review") ||
+ strings.Contains(msg, "testers")
+}
+
+func fetchUserProfile(ctx context.Context, endpoint string) (*UserProfile, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ body, status, err := oauthDoRequest(req)
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, parseAPIError(body, status)
+ }
+ var profile UserProfile
+ if err := json.Unmarshal(body, &profile); err != nil {
+ return nil, fmt.Errorf("parse threads user profile: %w", err)
+ }
+ if profile.ID.String() == "" {
+ return nil, fmt.Errorf("threads user profile missing id")
+ }
+ return &profile, nil
+}
+
+func oauthDoRequest(req *http.Request) ([]byte, int, error) {
+ client := &http.Client{Timeout: 25 * time.Second}
+ res, err := client.Do(req)
+ if err != nil {
+ return nil, 0, err
+ }
+ defer res.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
+ if err != nil {
+ return nil, res.StatusCode, err
+ }
+ return body, res.StatusCode, nil
+}
diff --git a/backend/internal/library/threadsapi/oauth_permission_test.go b/backend/internal/library/threadsapi/oauth_permission_test.go
new file mode 100644
index 0000000..4b3f7b4
--- /dev/null
+++ b/backend/internal/library/threadsapi/oauth_permission_test.go
@@ -0,0 +1,28 @@
+package threadsapi
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestIsPermissionError(t *testing.T) {
+ err := errors.New(`threads api 400: This action requires the threads_basic permission`)
+ if !IsPermissionError(err) {
+ t.Fatal("expected permission error")
+ }
+ if IsPermissionError(errors.New("network timeout")) {
+ t.Fatal("unexpected permission match")
+ }
+}
+
+func TestThreadsUserIDFromToken(t *testing.T) {
+ short := &TokenExchangeResult{UserID: flexString("111")}
+ long := &TokenExchangeResult{UserID: flexString("222")}
+ if got := ThreadsUserIDFromToken(short, long); got != "111" {
+ t.Fatalf("got %q", got)
+ }
+ short.UserID = ""
+ if got := ThreadsUserIDFromToken(short, long); got != "222" {
+ t.Fatalf("got %q", got)
+ }
+}
diff --git a/backend/internal/library/threadsapi/oauth_test.go b/backend/internal/library/threadsapi/oauth_test.go
new file mode 100644
index 0000000..163b864
--- /dev/null
+++ b/backend/internal/library/threadsapi/oauth_test.go
@@ -0,0 +1,28 @@
+package threadsapi
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestTokenExchangeResultUnmarshalNumericUserID(t *testing.T) {
+ raw := []byte(`{"access_token":"tok","token_type":"bearer","expires_in":3600,"user_id":123456789}`)
+ var result TokenExchangeResult
+ if err := json.Unmarshal(raw, &result); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if result.UserID.String() != "123456789" {
+ t.Fatalf("user_id = %q, want 123456789", result.UserID.String())
+ }
+}
+
+func TestUserProfileUnmarshalNumericID(t *testing.T) {
+ raw := []byte(`{"id":987654321,"username":"demo","name":"Demo"}`)
+ var profile UserProfile
+ if err := json.Unmarshal(raw, &profile); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if profile.ID.String() != "987654321" {
+ t.Fatalf("id = %q, want 987654321", profile.ID.String())
+ }
+}
diff --git a/backend/internal/library/threadsapi/publish.go b/backend/internal/library/threadsapi/publish.go
index d793dc3..1cbac06 100644
--- a/backend/internal/library/threadsapi/publish.go
+++ b/backend/internal/library/threadsapi/publish.go
@@ -14,14 +14,15 @@ import (
)
type PublishResult struct {
- MediaID string
- Permalink string
+ MediaID string `json:"media_id"`
+ Permalink string `json:"permalink"`
}
type PublishTextInput struct {
ThreadsUserID string
AccessToken string
Text string
+ TopicTag string
}
type PublishReplyInput struct {
@@ -46,7 +47,7 @@ func PublishText(ctx context.Context, in PublishTextInput) (*PublishResult, erro
return nil, err
}
- containerID, err := createTextContainer(ctx, userID, token, text)
+ containerID, err := createTextContainer(ctx, userID, token, text, in.TopicTag)
if err != nil {
return nil, err
}
@@ -95,11 +96,14 @@ func PublishReply(ctx context.Context, in PublishReplyInput) (*PublishResult, er
return &PublishResult{MediaID: mediaID, Permalink: permalink}, nil
}
-func createTextContainer(ctx context.Context, userID, token, text string) (string, error) {
+func createTextContainer(ctx context.Context, userID, token, text, topicTag string) (string, error) {
params := url.Values{}
params.Set("access_token", token)
params.Set("media_type", "TEXT")
params.Set("text", text)
+ if tag := normalizeTopicTag(topicTag); tag != "" {
+ params.Set("topic_tag", tag)
+ }
endpoint := graphBaseURL + "/" + userID + "/threads?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
if err != nil {
@@ -124,6 +128,17 @@ func createTextContainer(ctx context.Context, userID, token, text string) (strin
return payload.ID, nil
}
+func normalizeTopicTag(value string) string {
+ tag := strings.TrimSpace(value)
+ tag = strings.TrimLeft(tag, "#")
+ tag = strings.TrimSpace(tag)
+ if len([]rune(tag)) > 40 {
+ runes := []rune(tag)
+ tag = string(runes[:40])
+ }
+ return tag
+}
+
func createReplyContainer(ctx context.Context, userID, token, replyTo, text string) (string, error) {
params := url.Values{}
params.Set("access_token", token)
@@ -184,12 +199,12 @@ func publishContainer(ctx context.Context, userID, token, containerID string) (s
}
func waitForContainerReady(ctx context.Context, containerID, token string) error {
- for attempt := 0; attempt < 20; attempt++ {
+ for attempt := 0; attempt < 12; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return ctx.Err()
- case <-time.After(3 * time.Second):
+ case <-time.After(2 * time.Second):
}
}
params := url.Values{}
diff --git a/backend/internal/library/threadsapi/replies.go b/backend/internal/library/threadsapi/replies.go
index 8864feb..d058294 100644
--- a/backend/internal/library/threadsapi/replies.go
+++ b/backend/internal/library/threadsapi/replies.go
@@ -10,17 +10,61 @@ import (
"strings"
)
+const mediaConversationFields = "id,text,username,permalink,timestamp,is_reply,is_reply_owned_by_me,has_replies,replied_to,root_post,parent_id,media_type,media_url,thumbnail_url,gif_url,children{id,media_type,media_url,thumbnail_url}"
+
+type ReplyMediaChild struct {
+ ID string `json:"id"`
+ MediaType string `json:"media_type"`
+ MediaURL string `json:"media_url"`
+ ThumbnailURL string `json:"thumbnail_url"`
+}
+
type ReplyItem struct {
- ID string `json:"id"`
- Text string `json:"text"`
- Username string `json:"username"`
- Permalink string `json:"permalink"`
- Timestamp string `json:"timestamp"`
- LikeCount int `json:"like_count"`
- ParentID string `json:"parent_id"`
+ ID string `json:"id"`
+ Text string `json:"text"`
+ Username string `json:"username"`
+ Permalink string `json:"permalink"`
+ Timestamp string `json:"timestamp"`
+ LikeCount int `json:"like_count"`
+ ParentID string `json:"parent_id"`
+ RepliedToID string `json:"replied_to_id"`
+ RootPostID string `json:"root_post_id"`
+ IsReply bool `json:"is_reply"`
+ IsReplyOwnedByMe bool `json:"is_reply_owned_by_me"`
+ HasReplies bool `json:"has_replies"`
+ MediaType string `json:"media_type"`
+ MediaURL string `json:"media_url"`
+ ThumbnailURL string `json:"thumbnail_url"`
+ GifURL string `json:"gif_url"`
+ MediaChildren []ReplyMediaChild `json:"media_children,omitempty"`
+}
+
+type replyItemRaw struct {
+ ID string `json:"id"`
+ Text string `json:"text"`
+ Username string `json:"username"`
+ Permalink string `json:"permalink"`
+ Timestamp string `json:"timestamp"`
+ LikeCount int `json:"like_count"`
+ ParentID string `json:"parent_id"`
+ RepliedTo json.RawMessage `json:"replied_to"`
+ RootPost json.RawMessage `json:"root_post"`
+ IsReply bool `json:"is_reply"`
+ IsReplyOwnedByMe bool `json:"is_reply_owned_by_me"`
+ HasReplies bool `json:"has_replies"`
+ MediaType string `json:"media_type"`
+ MediaURL string `json:"media_url"`
+ ThumbnailURL string `json:"thumbnail_url"`
+ GifURL string `json:"gif_url"`
+ Children json.RawMessage `json:"children"`
}
func (c *Client) MediaReplies(ctx context.Context, mediaID string, limit int) ([]ReplyItem, error) {
+ return c.MediaConversation(ctx, mediaID, limit)
+}
+
+// MediaConversation returns flattened top-level and nested replies for a thread root.
+func (c *Client) MediaConversation(ctx context.Context, mediaID string, limit int) ([]ReplyItem, error) {
if !c.Enabled() {
return nil, fmt.Errorf("threads api access token is required")
}
@@ -31,39 +75,146 @@ func (c *Client) MediaReplies(ctx context.Context, mediaID string, limit int) ([
if limit <= 0 {
limit = 10
}
- if limit > 50 {
- limit = 50
+ if limit > 100 {
+ limit = 100
}
- params := url.Values{}
- params.Set("access_token", c.accessToken)
- params.Set("fields", "id,text,username,permalink,timestamp,like_count,parent_id")
- params.Set("limit", fmt.Sprintf("%d", limit))
+ out := make([]ReplyItem, 0, limit)
+ nextURL := ""
+ for len(out) < limit {
+ pageLimit := limit - len(out)
+ if pageLimit > 50 {
+ pageLimit = 50
+ }
+ endpoint := nextURL
+ if endpoint == "" {
+ params := url.Values{}
+ params.Set("access_token", c.accessToken)
+ params.Set("fields", mediaConversationFields)
+ params.Set("limit", fmt.Sprintf("%d", pageLimit))
+ params.Set("reverse", "false")
+ endpoint = graphBaseURL + "/" + url.PathEscape(mediaID) + "/conversation?" + params.Encode()
+ }
- endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "/replies?" + params.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return nil, err
- }
- res, err := c.http.Do(req)
- if err != nil {
- return nil, err
- }
- defer res.Body.Close()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ res, err := c.http.Do(req)
+ if err != nil {
+ return nil, err
+ }
- body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
- if err != nil {
- return nil, err
- }
- if res.StatusCode != http.StatusOK {
- return nil, parseAPIError(body, res.StatusCode)
- }
+ body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
+ res.Body.Close()
+ if err != nil {
+ return nil, err
+ }
+ if res.StatusCode != http.StatusOK {
+ return nil, parseAPIError(body, res.StatusCode)
+ }
- var payload struct {
- Data []ReplyItem `json:"data"`
+ var payload struct {
+ Data []replyItemRaw `json:"data"`
+ Paging struct {
+ Next string `json:"next"`
+ } `json:"paging"`
+ }
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return nil, fmt.Errorf("parse threads conversation: %w", err)
+ }
+ for _, raw := range payload.Data {
+ out = append(out, normalizeReplyItem(raw))
+ if len(out) >= limit {
+ break
+ }
+ }
+ nextURL = strings.TrimSpace(payload.Paging.Next)
+ if nextURL == "" || len(payload.Data) == 0 {
+ break
+ }
}
- if err := json.Unmarshal(body, &payload); err != nil {
- return nil, fmt.Errorf("parse threads replies: %w", err)
- }
- return payload.Data, nil
+ return out, nil
+}
+
+func normalizeReplyItem(raw replyItemRaw) ReplyItem {
+ repliedToID := parseMediaRefID(raw.RepliedTo)
+ if repliedToID == "" {
+ repliedToID = strings.TrimSpace(raw.ParentID)
+ }
+ return ReplyItem{
+ ID: strings.TrimSpace(raw.ID),
+ Text: strings.TrimSpace(raw.Text),
+ Username: strings.TrimSpace(raw.Username),
+ Permalink: strings.TrimSpace(raw.Permalink),
+ Timestamp: strings.TrimSpace(raw.Timestamp),
+ LikeCount: raw.LikeCount,
+ ParentID: strings.TrimSpace(raw.ParentID),
+ RepliedToID: repliedToID,
+ RootPostID: parseMediaRefID(raw.RootPost),
+ IsReply: raw.IsReply,
+ IsReplyOwnedByMe: raw.IsReplyOwnedByMe,
+ HasReplies: raw.HasReplies,
+ MediaType: strings.TrimSpace(raw.MediaType),
+ MediaURL: strings.TrimSpace(raw.MediaURL),
+ ThumbnailURL: strings.TrimSpace(raw.ThumbnailURL),
+ GifURL: strings.TrimSpace(raw.GifURL),
+ MediaChildren: parseMediaChildren(raw.Children),
+ }
+}
+
+func parseMediaChildren(raw json.RawMessage) []ReplyMediaChild {
+ if len(raw) == 0 || string(raw) == "null" {
+ return nil
+ }
+
+ var asList []ReplyMediaChild
+ if err := json.Unmarshal(raw, &asList); err == nil {
+ return normalizeMediaChildren(asList)
+ }
+
+ var wrapped struct {
+ Data []ReplyMediaChild `json:"data"`
+ }
+ if err := json.Unmarshal(raw, &wrapped); err == nil && len(wrapped.Data) > 0 {
+ return normalizeMediaChildren(wrapped.Data)
+ }
+ return nil
+}
+
+func normalizeMediaChildren(items []ReplyMediaChild) []ReplyMediaChild {
+ out := make([]ReplyMediaChild, 0, len(items))
+ for _, item := range items {
+ child := ReplyMediaChild{
+ ID: strings.TrimSpace(item.ID),
+ MediaType: strings.TrimSpace(item.MediaType),
+ MediaURL: strings.TrimSpace(item.MediaURL),
+ ThumbnailURL: strings.TrimSpace(item.ThumbnailURL),
+ }
+ if child.ID == "" && child.MediaURL == "" && child.ThumbnailURL == "" {
+ continue
+ }
+ out = append(out, child)
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
+func parseMediaRefID(raw json.RawMessage) string {
+ if len(raw) == 0 {
+ return ""
+ }
+ var asString string
+ if err := json.Unmarshal(raw, &asString); err == nil {
+ return strings.TrimSpace(asString)
+ }
+ var asObject struct {
+ ID string `json:"id"`
+ }
+ if err := json.Unmarshal(raw, &asObject); err == nil {
+ return strings.TrimSpace(asObject.ID)
+ }
+ return ""
}
diff --git a/backend/internal/library/threadsapi/replies_test.go b/backend/internal/library/threadsapi/replies_test.go
new file mode 100644
index 0000000..d123180
--- /dev/null
+++ b/backend/internal/library/threadsapi/replies_test.go
@@ -0,0 +1,69 @@
+package threadsapi
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestParseMediaRefIDObjectAndString(t *testing.T) {
+ obj := json.RawMessage(`{"id":"123"}`)
+ if got := parseMediaRefID(obj); got != "123" {
+ t.Fatalf("object ref: got %q", got)
+ }
+ str := json.RawMessage(`"456"`)
+ if got := parseMediaRefID(str); got != "456" {
+ t.Fatalf("string ref: got %q", got)
+ }
+}
+
+func TestNormalizeReplyItemPrefersRepliedTo(t *testing.T) {
+ item := normalizeReplyItem(replyItemRaw{
+ ID: "child",
+ ParentID: "legacy-parent",
+ RepliedTo: json.RawMessage(`{"id":"real-parent"}`),
+ Text: "hello",
+ })
+ if item.RepliedToID != "real-parent" {
+ t.Fatalf("expected replied_to id, got %q", item.RepliedToID)
+ }
+}
+
+func TestNormalizeReplyItemKeepsMediaURLs(t *testing.T) {
+ item := normalizeReplyItem(replyItemRaw{
+ ID: "img-reply",
+ MediaType: "IMAGE",
+ MediaURL: "https://example.com/photo.jpg",
+ ThumbnailURL: "https://example.com/thumb.jpg",
+ GifURL: "https://example.com/cat.gif",
+ })
+ if item.MediaURL != "https://example.com/photo.jpg" {
+ t.Fatalf("media_url: got %q", item.MediaURL)
+ }
+ if item.GifURL != "https://example.com/cat.gif" {
+ t.Fatalf("gif_url: got %q", item.GifURL)
+ }
+}
+
+func TestNormalizeReplyItemParsesCarouselChildren(t *testing.T) {
+ item := normalizeReplyItem(replyItemRaw{
+ ID: "carousel-reply",
+ MediaType: "CAROUSEL_ALBUM",
+ Children: json.RawMessage(`{"data":[{"id":"c1","media_type":"IMAGE","media_url":"https://example.com/1.jpg"},{"id":"c2","media_type":"VIDEO","media_url":"https://example.com/2.mp4","thumbnail_url":"https://example.com/2.jpg"}]}`),
+ })
+ if len(item.MediaChildren) != 2 {
+ t.Fatalf("expected 2 media children, got %d", len(item.MediaChildren))
+ }
+ if item.MediaChildren[0].MediaURL != "https://example.com/1.jpg" {
+ t.Fatalf("first child media_url: got %q", item.MediaChildren[0].MediaURL)
+ }
+ if item.MediaChildren[1].ThumbnailURL != "https://example.com/2.jpg" {
+ t.Fatalf("second child thumbnail_url: got %q", item.MediaChildren[1].ThumbnailURL)
+ }
+}
+
+func TestParseMediaChildrenAcceptsArray(t *testing.T) {
+ children := parseMediaChildren(json.RawMessage(`[{"id":"x","media_type":"IMAGE","media_url":"https://example.com/x.jpg"}]`))
+ if len(children) != 1 || children[0].ID != "x" {
+ t.Fatalf("unexpected children: %#v", children)
+ }
+}
diff --git a/backend/internal/library/threadsapi/resolve_media.go b/backend/internal/library/threadsapi/resolve_media.go
new file mode 100644
index 0000000..ab8e38a
--- /dev/null
+++ b/backend/internal/library/threadsapi/resolve_media.go
@@ -0,0 +1,298 @@
+package threadsapi
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "unicode/utf8"
+)
+
+var threadsPermalinkRe = regexp.MustCompile(`(?i)threads\.(?:com|net)/@([^/]+)/post/([^/?#]+)`)
+
+// IsNumericMediaID reports whether the value looks like a Threads Graph media id.
+func IsNumericMediaID(raw string) bool {
+ raw = strings.TrimSpace(raw)
+ if len(raw) < 8 {
+ return false
+ }
+ for _, r := range raw {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return true
+}
+
+func parsePermalinkParts(permalink string) (username, shortcode string) {
+ permalink = strings.TrimSpace(permalink)
+ if permalink == "" {
+ return "", ""
+ }
+ permalink = strings.Split(permalink, "?")[0]
+ permalink = strings.Split(permalink, "#")[0]
+ match := threadsPermalinkRe.FindStringSubmatch(permalink)
+ if len(match) < 3 {
+ return "", ""
+ }
+ return strings.TrimSpace(match[1]), strings.TrimSpace(match[2])
+}
+
+type ResolveReplyMediaInput struct {
+ ExternalID string
+ Permalink string
+ HintText string
+}
+
+// ResolveReplyMediaID returns a Graph API media id suitable for reply_to_id.
+func (c *Client) ResolveReplyMediaID(ctx context.Context, externalID, permalink string) (string, error) {
+ return c.ResolveReplyMedia(ctx, ResolveReplyMediaInput{
+ ExternalID: externalID,
+ Permalink: permalink,
+ })
+}
+
+// ResolveReplyMedia resolves shortcode/permalink targets into numeric media ids for reply_to_id.
+func (c *Client) ResolveReplyMedia(ctx context.Context, in ResolveReplyMediaInput) (string, error) {
+ if !c.Enabled() {
+ return "", fmt.Errorf("threads api access token is required")
+ }
+ candidate := strings.TrimSpace(in.ExternalID)
+ if IsNumericMediaID(candidate) {
+ return candidate, nil
+ }
+ username, shortcode := parsePermalinkParts(in.Permalink)
+ if !IsNumericMediaID(candidate) && candidate != "" {
+ shortcode = candidate
+ }
+ if IsNumericMediaID(shortcode) {
+ return shortcode, nil
+ }
+ if shortcode == "" {
+ return "", fmt.Errorf("缺少有效的 Threads 貼文 ID")
+ }
+ if username == "" {
+ return "", fmt.Errorf("permalink 缺少作者資訊,無法解析貼文 ID")
+ }
+
+ for _, query := range keywordQueriesFromHint(in.HintText) {
+ if id, err := c.lookupMediaIDByKeywordText(ctx, username, shortcode, query); err == nil && id != "" {
+ return id, nil
+ }
+ }
+ if id, err := c.lookupMediaIDViaProfilePosts(ctx, username, shortcode); err == nil && id != "" {
+ return id, nil
+ }
+ if id, err := c.lookupMediaIDByAuthorAndShortcode(ctx, username, shortcode); err == nil && id != "" {
+ return id, nil
+ }
+ return "", fmt.Errorf(
+ "無法將貼文 shortcode 轉成 Threads media id(%s/@%s/post/%s)。請確認 OAuth 具備 threads_profile_discovery 與 threads_keyword_search,或重新以 API 海巡抓取貼文",
+ shortcode, username, shortcode,
+ )
+}
+
+func keywordQueriesFromHint(hint string) []string {
+ hint = strings.TrimSpace(hint)
+ if hint == "" {
+ return nil
+ }
+ hint = strings.Join(strings.Fields(hint), " ")
+ seen := map[string]struct{}{}
+ out := make([]string, 0, 3)
+ add := func(query string) {
+ query = strings.TrimSpace(query)
+ if query == "" || utf8.RuneCountInString(query) < 4 {
+ return
+ }
+ if _, ok := seen[query]; ok {
+ return
+ }
+ seen[query] = struct{}{}
+ out = append(out, query)
+ }
+ add(trimRunes(hint, 80))
+ if idx := strings.IndexAny(hint, "。!?!?.\n"); idx > 0 {
+ add(trimRunes(hint[:idx], 60))
+ }
+ add(trimRunes(hint, 32))
+ return out
+}
+
+func trimRunes(raw string, max int) string {
+ raw = strings.TrimSpace(raw)
+ if max <= 0 || utf8.RuneCountInString(raw) <= max {
+ return raw
+ }
+ runes := []rune(raw)
+ return strings.TrimSpace(string(runes[:max]))
+}
+
+func (c *Client) lookupMediaIDViaProfilePosts(ctx context.Context, username, shortcode string) (string, error) {
+ return c.FindMediaIDByAuthorShortcode(ctx, username, shortcode)
+}
+
+func (c *Client) lookupMediaIDByKeywordText(ctx context.Context, username, shortcode, query string) (string, error) {
+ query = strings.TrimSpace(query)
+ if query == "" {
+ return "", fmt.Errorf("keyword query is required")
+ }
+ for _, searchType := range []string{"TOP", "RECENT"} {
+ id, err := c.keywordSearchMediaByShortcode(ctx, username, query, searchType, true, shortcode)
+ if err == nil && id != "" {
+ return id, nil
+ }
+ }
+ return "", fmt.Errorf("threads keyword_search did not return media for @%s post %s", username, shortcode)
+}
+
+func (c *Client) lookupMediaIDByAuthorAndShortcode(ctx context.Context, username, shortcode string) (string, error) {
+ username = strings.TrimPrefix(strings.TrimSpace(username), "@")
+ shortcode = strings.TrimSpace(shortcode)
+ if username == "" || shortcode == "" {
+ return "", fmt.Errorf("username and shortcode are required")
+ }
+ type lookupStrategy struct {
+ searchType string
+ withAuthor bool
+ }
+ strategies := []lookupStrategy{
+ {searchType: "TOP", withAuthor: true},
+ {searchType: "RECENT", withAuthor: true},
+ {searchType: "TOP", withAuthor: false},
+ {searchType: "RECENT", withAuthor: false},
+ }
+ var lastErr error
+ for _, strategy := range strategies {
+ id, err := c.keywordSearchMediaByShortcode(ctx, username, shortcode, strategy.searchType, strategy.withAuthor, shortcode)
+ if err == nil && id != "" {
+ return id, nil
+ }
+ if err != nil {
+ lastErr = err
+ }
+ }
+ if lastErr != nil {
+ return "", lastErr
+ }
+ return "", fmt.Errorf("threads keyword_search did not return media for @%s post %s", username, shortcode)
+}
+
+func (c *Client) keywordSearchMediaByShortcode(
+ ctx context.Context,
+ username, query, searchType string,
+ withAuthor bool,
+ wantShortcode string,
+) (string, error) {
+ query = strings.TrimSpace(query)
+ wantShortcode = strings.TrimSpace(wantShortcode)
+ if query == "" || wantShortcode == "" {
+ return "", fmt.Errorf("query and shortcode are required")
+ }
+ params := url.Values{}
+ params.Set("access_token", c.accessToken)
+ params.Set("q", query)
+ params.Set("search_type", searchType)
+ if withAuthor {
+ params.Set("author_username", username)
+ }
+ params.Set("fields", "id,permalink,shortcode")
+ params.Set("limit", "25")
+ endpoint := graphBaseURL + "/keyword_search?" + params.Encode()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return "", err
+ }
+ res, err := c.http.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer res.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
+ if err != nil {
+ return "", err
+ }
+ if res.StatusCode != http.StatusOK {
+ return "", parseAPIError(body, res.StatusCode)
+ }
+ var payload struct {
+ Data []struct {
+ ID string `json:"id"`
+ Permalink string `json:"permalink"`
+ Shortcode string `json:"shortcode"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return "", err
+ }
+ wantSuffix := "/post/" + wantShortcode
+ for _, item := range payload.Data {
+ id := strings.TrimSpace(item.ID)
+ if !IsNumericMediaID(id) {
+ continue
+ }
+ if withAuthor {
+ link := strings.TrimSpace(item.Permalink)
+ if link != "" && !strings.Contains(strings.ToLower(link), "/@"+strings.ToLower(username)+"/") {
+ continue
+ }
+ }
+ if strings.EqualFold(strings.TrimSpace(item.Shortcode), wantShortcode) {
+ return id, nil
+ }
+ link := strings.TrimSpace(item.Permalink)
+ if link != "" && strings.HasSuffix(strings.Split(link, "?")[0], wantSuffix) {
+ return id, nil
+ }
+ }
+ return "", fmt.Errorf("threads keyword_search did not return media for @%s post %s", username, wantShortcode)
+}
+
+// PrimeReplyTargetForPublish registers a public post via Threads API search before reply_to_id publish.
+// Meta only allows replying to public posts that were recently returned by keyword_search.
+func (c *Client) PrimeReplyTargetForPublish(ctx context.Context, in ResolveReplyMediaInput, mediaID string) error {
+ if !c.Enabled() {
+ return fmt.Errorf("threads api access token is required")
+ }
+ username, shortcode := parsePermalinkParts(in.Permalink)
+ if candidate := strings.TrimSpace(in.ExternalID); !IsNumericMediaID(candidate) && candidate != "" {
+ shortcode = candidate
+ }
+ mediaID = strings.TrimSpace(mediaID)
+ if username == "" || shortcode == "" || mediaID == "" {
+ return fmt.Errorf("缺少作者、shortcode 或 media id,無法預先搜尋貼文")
+ }
+
+ // Fast path: shortcode / author queries with author_username filter (fewer API round-trips).
+ for _, query := range []string{shortcode, username, "@" + username} {
+ for _, searchType := range []string{"TOP", "RECENT"} {
+ id, err := c.keywordSearchMediaByShortcode(ctx, username, query, searchType, true, shortcode)
+ if err == nil && strings.TrimSpace(id) == mediaID {
+ return nil
+ }
+ }
+ }
+ for _, query := range keywordQueriesFromHint(in.HintText) {
+ for _, searchType := range []string{"TOP", "RECENT"} {
+ id, err := c.keywordSearchMediaByShortcode(ctx, username, query, searchType, true, shortcode)
+ if err == nil && strings.TrimSpace(id) == mediaID {
+ return nil
+ }
+ }
+ }
+ if id, err := c.FindMediaIDByAuthorShortcode(ctx, username, shortcode); err == nil && strings.TrimSpace(id) == mediaID {
+ for _, searchType := range []string{"TOP", "RECENT"} {
+ if _, err := c.keywordSearchMediaByShortcode(ctx, username, shortcode, searchType, true, shortcode); err == nil {
+ return nil
+ }
+ }
+ }
+ return fmt.Errorf(
+ "Threads API 搜尋不到 @%s 的這篇貼文(%s)。回覆他人貼文前必須先用 keyword_search 找到它,請確認 threads_keyword_search 權限已核准,或改用「開始海巡(API)」重新抓取",
+ username, shortcode,
+ )
+}
diff --git a/backend/internal/library/threadsapi/resolve_media_hint_test.go b/backend/internal/library/threadsapi/resolve_media_hint_test.go
new file mode 100644
index 0000000..4673002
--- /dev/null
+++ b/backend/internal/library/threadsapi/resolve_media_hint_test.go
@@ -0,0 +1,13 @@
+package threadsapi
+
+import "testing"
+
+func TestKeywordQueriesFromHint(t *testing.T) {
+ queries := keywordQueriesFromHint("最近換季皮膚好乾,大家有推薦的保濕產品嗎?")
+ if len(queries) == 0 {
+ t.Fatal("expected keyword queries")
+ }
+ if queries[0] == "" {
+ t.Fatal("first query empty")
+ }
+}
diff --git a/backend/internal/library/threadsapi/resolve_media_test.go b/backend/internal/library/threadsapi/resolve_media_test.go
new file mode 100644
index 0000000..4a16e3d
--- /dev/null
+++ b/backend/internal/library/threadsapi/resolve_media_test.go
@@ -0,0 +1,19 @@
+package threadsapi
+
+import "testing"
+
+func TestIsNumericMediaID(t *testing.T) {
+ if !IsNumericMediaID("18123456789012345") {
+ t.Fatal("expected numeric media id")
+ }
+ if IsNumericMediaID("AbCdEf123") {
+ t.Fatal("shortcode should not be numeric media id")
+ }
+}
+
+func TestParsePermalinkParts(t *testing.T) {
+ user, code := parsePermalinkParts("https://www.threads.net/@alice/post/AbCdEf123")
+ if user != "alice" || code != "AbCdEf123" {
+ t.Fatalf("got %q %q", user, code)
+ }
+}
diff --git a/backend/internal/library/threadsapi/scopes.go b/backend/internal/library/threadsapi/scopes.go
new file mode 100644
index 0000000..5e76c48
--- /dev/null
+++ b/backend/internal/library/threadsapi/scopes.go
@@ -0,0 +1,23 @@
+package threadsapi
+
+import "strings"
+
+// DefaultOAuthScopes lists all Threads API permissions used by this product.
+// threads_basic is required for all API usage.
+var DefaultOAuthScopes = []string{
+ "threads_basic",
+ "threads_content_publish",
+ "threads_delete",
+ "threads_keyword_search",
+ "threads_location_tagging",
+ "threads_manage_insights",
+ "threads_manage_mentions",
+ "threads_manage_replies",
+ "threads_profile_discovery",
+ "threads_read_replies",
+ "threads_share_to_instagram",
+}
+
+func OAuthScopeString() string {
+ return strings.Join(DefaultOAuthScopes, ",")
+}
diff --git a/backend/internal/library/threadspost/format.go b/backend/internal/library/threadspost/format.go
new file mode 100644
index 0000000..84debcd
--- /dev/null
+++ b/backend/internal/library/threadspost/format.go
@@ -0,0 +1,35 @@
+package threadspost
+
+import (
+ "strings"
+)
+
+// FormatDraftText normalizes generated post bodies without destroying the author's voice.
+func FormatDraftText(text string) string {
+ text = strings.ReplaceAll(text, "\r\n", "\n")
+ text = strings.ReplaceAll(text, "\r", "\n")
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return ""
+ }
+
+ lines := strings.Split(text, "\n")
+ out := make([]string, 0, len(lines))
+ blankCount := 0
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ blankCount++
+ if blankCount <= 1 && len(out) > 0 {
+ out = append(out, "")
+ }
+ continue
+ }
+ blankCount = 0
+ out = append(out, line)
+ }
+ for len(out) > 0 && out[len(out)-1] == "" {
+ out = out[:len(out)-1]
+ }
+ return ClampPublish(strings.Join(out, "\n"))
+}
diff --git a/backend/internal/library/threadspost/format_test.go b/backend/internal/library/threadspost/format_test.go
new file mode 100644
index 0000000..6485dd9
--- /dev/null
+++ b/backend/internal/library/threadspost/format_test.go
@@ -0,0 +1,48 @@
+package threadspost
+
+import "testing"
+
+func TestFormatDraftTextPreservesPunctuationAndLineBreaks(t *testing.T) {
+ raw := "我最近迷上自己做早餐耶!\n以前都隨便買個麵包、三明治,趕著出門。\n有沒有人也跟我一樣?"
+ want := "我最近迷上自己做早餐耶!\n以前都隨便買個麵包、三明治,趕著出門。\n有沒有人也跟我一樣?"
+ got := FormatDraftText(raw)
+ if got != want {
+ t.Fatalf("FormatDraftText() = %q, want %q", got, want)
+ }
+}
+
+func TestFormatDraftTextKeepsSingleParagraph(t *testing.T) {
+ raw := "第一句很好。第二句也不錯!第三句收尾?"
+ want := "第一句很好。第二句也不錯!第三句收尾?"
+ got := FormatDraftText(raw)
+ if got != want {
+ t.Fatalf("FormatDraftText() = %q, want %q", got, want)
+ }
+}
+
+func TestFormatDraftTextCollapsesExcessBlankLines(t *testing.T) {
+ raw := "第一段\n\n\n第二段"
+ want := "第一段\n\n第二段"
+ got := FormatDraftText(raw)
+ if got != want {
+ t.Fatalf("FormatDraftText() = %q, want %q", got, want)
+ }
+}
+
+func TestFormatDraftTextPreservesEmoji(t *testing.T) {
+ raw := "廚房像打完仗一樣 😂\n但看到成果就值得了"
+ want := "廚房像打完仗一樣 😂\n但看到成果就值得了"
+ got := FormatDraftText(raw)
+ if got != want {
+ t.Fatalf("FormatDraftText() = %q, want %q", got, want)
+ }
+}
+
+func TestFormatDraftTextPreservesHashtag(t *testing.T) {
+ raw := "今天心情不錯\n#早餐日記"
+ want := "今天心情不錯\n#早餐日記"
+ got := FormatDraftText(raw)
+ if got != want {
+ t.Fatalf("FormatDraftText() = %q, want %q", got, want)
+ }
+}
diff --git a/backend/internal/library/threadspost/mimic.go b/backend/internal/library/threadspost/mimic.go
new file mode 100644
index 0000000..b78bd9d
--- /dev/null
+++ b/backend/internal/library/threadspost/mimic.go
@@ -0,0 +1,65 @@
+package threadspost
+
+import (
+ "fmt"
+ "strings"
+)
+
+// LineCount returns non-empty lines after normalizing newlines.
+func LineCount(text string) int {
+ text = strings.ReplaceAll(text, "\r\n", "\n")
+ text = strings.ReplaceAll(text, "\r", "\n")
+ n := 0
+ for _, line := range strings.Split(text, "\n") {
+ if strings.TrimSpace(line) != "" {
+ n++
+ }
+ }
+ return n
+}
+
+// MimicLengthRange returns a suggested min/max rune count when imitating referenceText.
+func MimicLengthRange(referenceText string) (min, max int) {
+ ref := RuneLen(referenceText)
+ if ref <= 0 {
+ return ViralSweetMin, ViralSweetMax
+ }
+ min = int(float64(ref) * 0.75)
+ max = int(float64(ref) * 1.25)
+ if min < 20 {
+ min = 20
+ }
+ if max < min+10 {
+ max = min + 10
+ }
+ if max > MaxPublishRunes {
+ max = MaxPublishRunes
+ }
+ if min > max {
+ min = max
+ }
+ return min, max
+}
+
+// MimicLengthGuidance builds prompt text for rewrite/mimic flows.
+func MimicLengthGuidance(referenceText string) string {
+ referenceText = strings.TrimSpace(referenceText)
+ if referenceText == "" {
+ return ""
+ }
+ refRunes := RuneLen(referenceText)
+ refLines := LineCount(referenceText)
+ min, max := MimicLengthRange(referenceText)
+ lineMin, lineMax := refLines, refLines
+ if refLines > 1 {
+ lineMin = refLines - 1
+ if lineMin < 1 {
+ lineMin = 1
+ }
+ lineMax = refLines + 1
+ }
+ return fmt.Sprintf(
+ "【篇幅(仿寫)】參考原文約 %d 字、%d 行。產出正文請控制在 %d~%d 字、約 %d~%d 行,不要明顯比參考長很多或短很多。精簡優先:刪掉重複鋪陳、無關細節、雞湯總結與多餘轉折;每一句都要推進故事或情緒,不然就砍掉。",
+ refRunes, refLines, min, max, lineMin, lineMax,
+ )
+}
diff --git a/backend/internal/library/threadspost/mimic_test.go b/backend/internal/library/threadspost/mimic_test.go
new file mode 100644
index 0000000..08fcffc
--- /dev/null
+++ b/backend/internal/library/threadspost/mimic_test.go
@@ -0,0 +1,26 @@
+package threadspost
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestMimicLengthRange(t *testing.T) {
+ min, max := MimicLengthRange("今天早餐吃了蛋餅\n加辣真的爽")
+ if min <= 0 || max < min {
+ t.Fatalf("MimicLengthRange() = %d, %d", min, max)
+ }
+}
+
+func TestMimicLengthGuidanceIncludesReferenceStats(t *testing.T) {
+ ref := "第一句\n第二句\n第三句"
+ got := MimicLengthGuidance(ref)
+ if got == "" {
+ t.Fatal("MimicLengthGuidance() empty")
+ }
+ for _, part := range []string{"3 行", "精簡優先"} {
+ if !strings.Contains(got, part) {
+ t.Fatalf("MimicLengthGuidance() missing %q: %q", part, got)
+ }
+ }
+}
diff --git a/backend/internal/library/viral/analyze_viral.go b/backend/internal/library/viral/analyze_viral.go
index a7ef92e..45f1cdf 100644
--- a/backend/internal/library/viral/analyze_viral.go
+++ b/backend/internal/library/viral/analyze_viral.go
@@ -7,6 +7,14 @@ import (
)
type ViralAnalysis struct {
+ StoryHook string `json:"storyHook"`
+ NarrativeBeat string `json:"narrativeBeat"`
+ EmotionalCore string `json:"emotionalCore"`
+ SceneSeed string `json:"sceneSeed"`
+ AuthenticityCues []string `json:"authenticityCues"`
+ WhatToAvoid string `json:"whatToAvoid"`
+
+ // Legacy fields kept for backward-compatible parsing.
HookPattern string `json:"hookPattern"`
StructurePattern string `json:"structurePattern"`
EmotionalTrigger string `json:"emotionalTrigger"`
@@ -26,9 +34,15 @@ type AnalyzeViralInput struct {
}
func BuildAnalyzeViralSystemPrompt() string {
- return strings.TrimSpace(`你是 Threads 爆款結構分析師。拆解貼文為什麼會紅、怎麼仿寫結構,不要建議抄襲原文。
+ return strings.TrimSpace(`你是 Threads 敘事分析師。拆解這篇貼文為什麼讓人想看完、怎麼寫出同樣的故事感(不是抄句型)。
-只回傳 JSON:hookPattern, structurePattern, emotionalTrigger, replicationStrategy, keyTakeaways(字串陣列 3~5 項)。繁體中文。`)
+只回傳 JSON(繁體中文):
+- storyHook:讓人停下滑動的開場力量(描述手法,不要給可照抄的句子)
+- narrativeBeat:故事怎麼推進(經歷/轉折/感受,不是段落模板)
+- emotionalCore:核心情緒是什麼
+- sceneSeed:一個值得借鑑的「具體場景種子」(時間/地點/動作,供啟發勿照搬)
+- authenticityCues:3~5 個讓它像真人的小細節(口語、坦白、不完美、具體細節等)
+- whatToAvoid:若照搬哪些寫法會立刻變成 AI 複製文`)
}
func BuildAnalyzeViralUserPrompt(in AnalyzeViralInput) string {
@@ -65,7 +79,10 @@ func ParseAnalyzeViralOutput(raw string) (ViralAnalysis, error) {
if err := json.Unmarshal(payload, &out); err != nil {
return ViralAnalysis{}, fmt.Errorf("parse viral analysis: %w", err)
}
- if strings.TrimSpace(out.HookPattern) == "" && strings.TrimSpace(out.StructurePattern) == "" {
+ if strings.TrimSpace(out.StoryHook) == "" &&
+ strings.TrimSpace(out.NarrativeBeat) == "" &&
+ strings.TrimSpace(out.HookPattern) == "" &&
+ strings.TrimSpace(out.StructurePattern) == "" {
return ViralAnalysis{}, fmt.Errorf("viral analysis missing content")
}
return out, nil
@@ -73,29 +90,47 @@ func ParseAnalyzeViralOutput(raw string) (ViralAnalysis, error) {
func FormatAnalysisForReplicate(analysis ViralAnalysis) string {
var b strings.Builder
- if hook := strings.TrimSpace(analysis.HookPattern); hook != "" {
- b.WriteString("Hook 模式:")
+ if hook := firstNonEmpty(analysis.StoryHook, analysis.HookPattern); hook != "" {
+ b.WriteString("故事開場力量:")
b.WriteString(hook)
b.WriteString("\n")
}
- if structure := strings.TrimSpace(analysis.StructurePattern); structure != "" {
- b.WriteString("結構節奏:")
- b.WriteString(structure)
+ if beat := firstNonEmpty(analysis.NarrativeBeat, analysis.StructurePattern); beat != "" {
+ b.WriteString("敘事推進:")
+ b.WriteString(beat)
b.WriteString("\n")
}
- if emotion := strings.TrimSpace(analysis.EmotionalTrigger); emotion != "" {
- b.WriteString("情緒觸發:")
+ if emotion := firstNonEmpty(analysis.EmotionalCore, analysis.EmotionalTrigger); emotion != "" {
+ b.WriteString("核心情緒:")
b.WriteString(emotion)
b.WriteString("\n")
}
- if strategy := strings.TrimSpace(analysis.ReplicationStrategy); strategy != "" {
- b.WriteString("仿寫策略:")
- b.WriteString(strategy)
+ if scene := strings.TrimSpace(analysis.SceneSeed); scene != "" {
+ b.WriteString("場景種子(啟發用,勿照搬):")
+ b.WriteString(scene)
b.WriteString("\n")
}
- if len(analysis.KeyTakeaways) > 0 {
- b.WriteString("重點:")
- b.WriteString(strings.Join(analysis.KeyTakeaways, ";"))
+ cues := analysis.AuthenticityCues
+ if len(cues) == 0 {
+ cues = analysis.KeyTakeaways
+ }
+ if len(cues) > 0 {
+ b.WriteString("真人質感線索:")
+ b.WriteString(strings.Join(cues, ";"))
+ b.WriteString("\n")
+ }
+ if avoid := firstNonEmpty(analysis.WhatToAvoid, analysis.ReplicationStrategy); avoid != "" {
+ b.WriteString("避免變成複製文:")
+ b.WriteString(avoid)
}
return strings.TrimSpace(b.String())
}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if trimmed := strings.TrimSpace(value); trimmed != "" {
+ return trimmed
+ }
+ }
+ return ""
+}
diff --git a/backend/internal/library/viral/analyze_viral_test.go b/backend/internal/library/viral/analyze_viral_test.go
new file mode 100644
index 0000000..8a5af82
--- /dev/null
+++ b/backend/internal/library/viral/analyze_viral_test.go
@@ -0,0 +1,43 @@
+package viral
+
+import "testing"
+
+func TestFormatAnalysisForReplicateUsesNarrativeFields(t *testing.T) {
+ got := FormatAnalysisForReplicate(ViralAnalysis{
+ StoryHook: "先丟一個具體窘況",
+ NarrativeBeat: "從失敗經驗拐到頓悟",
+ EmotionalCore: "鬆了一口氣",
+ SceneSeed: "週日早晨的廚房",
+ AuthenticityCues: []string{"坦白搞砸", "口語碎碎念"},
+ WhatToAvoid: "不要照抄三段式",
+ })
+ for _, part := range []string{"故事開場力量", "敘事推進", "核心情緒", "場景種子", "真人質感線索", "避免變成複製文", "週日早晨的廚房"} {
+ if got == "" || !contains(got, part) {
+ t.Fatalf("FormatAnalysisForReplicate() missing %q: %q", part, got)
+ }
+ }
+}
+
+func TestFormatAnalysisForReplicateFallsBackToLegacyFields(t *testing.T) {
+ got := FormatAnalysisForReplicate(ViralAnalysis{
+ HookPattern: "痛點開場",
+ StructurePattern: "三段式",
+ KeyTakeaways: []string{"具體細節"},
+ })
+ if !contains(got, "故事開場力量") || !contains(got, "痛點開場") {
+ t.Fatalf("legacy fallback missing: %q", got)
+ }
+}
+
+func contains(s, part string) bool {
+ return len(s) >= len(part) && (s == part || len(part) == 0 || indexOf(s, part) >= 0)
+}
+
+func indexOf(s, part string) int {
+ for i := 0; i+len(part) <= len(s); i++ {
+ if s[i:i+len(part)] == part {
+ return i
+ }
+ }
+ return -1
+}
diff --git a/backend/internal/library/viral/free_trends.go b/backend/internal/library/viral/free_trends.go
new file mode 100644
index 0000000..4771ea0
--- /dev/null
+++ b/backend/internal/library/viral/free_trends.go
@@ -0,0 +1,121 @@
+package viral
+
+import (
+ "context"
+ "encoding/xml"
+ "html"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "time"
+)
+
+var rssTagRE = regexp.MustCompile(`<[^>]+>`)
+
+type freeRSS struct {
+ Channel struct {
+ Items []freeRSSItem `xml:"item"`
+ } `xml:"channel"`
+}
+
+type freeRSSItem struct {
+ Title string `xml:"title"`
+ Description string `xml:"description"`
+ Link string `xml:"link"`
+}
+
+func CollectFreeTopicTrends(ctx context.Context, keyword, personaBrief string) []MissionInspireTrendSnippet {
+ queries := freeTrendFeedQueries(keyword, personaBrief)
+ client := &http.Client{Timeout: 7 * time.Second}
+ out := make([]MissionInspireTrendSnippet, 0, 12)
+ seen := map[string]struct{}{}
+
+ add := func(query, title, snippet, link string) {
+ title = cleanRSSField(title)
+ snippet = cleanRSSField(snippet)
+ link = strings.TrimSpace(link)
+ if title == "" && snippet == "" {
+ return
+ }
+ key := strings.ToLower(title + "|" + snippet)
+ if _, ok := seen[key]; ok {
+ return
+ }
+ seen[key] = struct{}{}
+ out = append(out, MissionInspireTrendSnippet{Query: query, Title: title, Snippet: snippet, URL: link})
+ }
+
+ for _, feed := range queries {
+ if len(out) >= 12 {
+ break
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, feed.url, nil)
+ if err != nil {
+ continue
+ }
+ req.Header.Set("User-Agent", "HaixunTopicBot/1.0")
+ resp, err := client.Do(req)
+ if err != nil || resp == nil {
+ continue
+ }
+ func() {
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return
+ }
+ var parsed freeRSS
+ if err := xml.NewDecoder(resp.Body).Decode(&parsed); err != nil {
+ return
+ }
+ for _, item := range parsed.Channel.Items {
+ if len(out) >= 12 {
+ break
+ }
+ add(feed.label, item.Title, item.Description, item.Link)
+ }
+ }()
+ }
+ return out
+}
+
+type freeTrendFeed struct {
+ label string
+ url string
+}
+
+func freeTrendFeedQueries(keyword, personaBrief string) []freeTrendFeed {
+ feeds := []freeTrendFeed{
+ {label: "Google Trends 台灣每日趨勢", url: "https://trends.google.com/trends/trendingsearches/daily/rss?geo=TW"},
+ {label: "Google Trends 台灣即時趨勢", url: "https://trends.google.com/trends/trendingsearches/realtime/rss?geo=TW&category=all"},
+ }
+ terms := []string{strings.TrimSpace(keyword)}
+ brief := strings.TrimSpace(personaBrief)
+ if brief != "" {
+ if len([]rune(brief)) > 18 {
+ brief = string([]rune(brief)[:18])
+ }
+ terms = append(terms, brief)
+ }
+ for _, term := range terms {
+ if term == "" {
+ continue
+ }
+ q := url.QueryEscape(term + " 共鳴 OR 痛點 OR 趨勢")
+ feeds = append(feeds, freeTrendFeed{
+ label: "Google News 搜尋:" + term,
+ url: "https://news.google.com/rss/search?q=" + q + "&hl=zh-TW&gl=TW&ceid=TW:zh-Hant",
+ })
+ }
+ return feeds
+}
+
+func cleanRSSField(raw string) string {
+ raw = html.UnescapeString(raw)
+ raw = rssTagRE.ReplaceAllString(raw, " ")
+ raw = strings.Join(strings.Fields(raw), " ")
+ if len([]rune(raw)) > 220 {
+ return string([]rune(raw)[:220]) + "…"
+ }
+ return raw
+}
diff --git a/backend/internal/library/viral/mission_inspire.go b/backend/internal/library/viral/mission_inspire.go
index 32f4718..17d3884 100644
--- a/backend/internal/library/viral/mission_inspire.go
+++ b/backend/internal/library/viral/mission_inspire.go
@@ -21,6 +21,8 @@ type MissionInspireInput struct {
PersonaPillars []string
RecentMissionLabels []string
RecentSeedQueries []string
+ AvoidTopics []string
+ ContentDirection string
UserKeyword string
TrendSnippets []MissionInspireTrendSnippet
WebSearchProvider string
@@ -45,27 +47,50 @@ type MissionInspireThreadsInput struct {
}
type MissionInspireOutput struct {
- Label string
- SeedQuery string
- Brief string
- TrendReason string
- TrendKeywords []string
+ Label string
+ SeedQuery string
+ Brief string
+ TrendReason string
+ TrendKeywords []string
+ Angles []string
+ Mission string
+ TargetAudience string
+ OpeningType string
+ BodyType string
+ Emotion string
+ CtaType string
+ RiskLevel string
+ Avoid []string
}
func BuildMissionInspireSystemPrompt() string {
- return strings.TrimSpace(`你是 Threads 拷貝忍者的「靈感骰子」顧問。根據近期 Threads 熱門貼文、網路搜尋訊號與創作者人設,產出一組**全新**拷貝任務草稿。
+ return strings.TrimSpace(`你是 Threads 帳號的內容編輯與 Topic Radar。根據近期 Threads / 社群討論訊號、網路搜尋素材與創作者人設,先產出「可執行的內容計畫」,不要直接寫貼文正文。
規則:
-1. 若【近期趨勢訊號】有內容,從中挑一個適合在 Threads 海巡的方向;不要編造不存在的時事或熱門貼文
+1. 若【近期趨勢訊號】有內容,從中挑一個適合在 Threads 海巡的方向;優先使用社群熱門關鍵字、受眾求助、心得討論、留言常見問題,不要把新聞事件本身當主題
2. 若趨勢訊號為空(未連線網路搜尋),**必須**改依人設、受眾痛點與常見 Threads 討論型態推測「近期可能被搜尋」的話題,並在 trendReason 說明推測理由(不要假裝有外部熱搜來源)
3. label:任務名稱,6~18 字,像企劃案標題,不要標點堆疊
4. seedQuery:種子關鍵字/近期熱詞,2~6 個詞用頓號或空格分隔,適合當 Threads 搜尋起點
-5. brief:這次想找什麼,2~4 句,說明要海巡哪類爆款、語氣或格式偏好
-6. trendReason:1~2 句,為何選這個趨勢(可引用訊號來源大意,不要貼 URL)
-7. trendKeywords:3~6 個相關熱詞(字串陣列)
-8. 避開【近期已做過的任務】相同題材
-9. 繁體中文;只回傳 JSON:
-{"label":"","seedQuery":"","brief":"","trendReason":"","trendKeywords":[]}`)
+5. brief:這次內容計畫摘要,2~4 句,說明話題、受眾痛點、為何適合這個帳號
+6. trendReason:1~2 句,為何選這個趨勢(說明它和受眾討論、搜尋意圖或痛點的關係,不要貼 URL)
+7. trendKeywords:3~6 個相關熱詞(字串陣列),必須像 Threads 會搜尋/討論的關鍵字,不要像新聞標題
+8. angles:3~5 個可勾選切角(字串陣列),每個切角都要說明「這篇要怎麼切」,不要直接寫成完整貼文
+9. mission:內容任務,只能是「日常陪伴」「專業知識」「情緒共鳴」「工具清單」「熱門觀點」「互動提問」之一,除非使用者方向要求更精準
+10. targetAudience:這篇真正要對誰說,越具體越好
+11. openingType:開頭策略,例如「問題開場」「場景開場」「反直覺開場」「經驗開場」「清單開場」
+12. bodyType:中段結構,例如「先共鳴再整理」「問題拆解」「步驟清單」「案例對照」「觀點辯證」
+13. emotion:情緒槓桿,例如「焦慮被接住」「想被理解」「怕踩雷」「想準備好」「想被鼓勵」
+14. ctaType:互動/收藏/私訊等 CTA 型態,不要硬業配
+15. riskLevel:low / medium / high;醫療、財務、法律、政策、名人爭議等至少 medium
+16. avoid:3~6 個禁忌或不要寫偏的方向(字串陣列)
+17. 避開【近期已做過的任務】相同題材
+18. 起號優先:題目要能連續延伸 5~10 篇,不要只是一則熱搜心得
+19. 除非使用者關鍵字、人設或趨勢訊號明確要求,不要產出早餐、早安、日常飲食、純生活流水帳這類泛題
+20. 人設介紹只用來判斷語氣與受眾,不是題材庫。不可因人設寫 Y2K、咖啡、穿搭、音樂,就把靈感主題漂移成咖啡、穿搭或復古日常
+21. 若使用者有指定關鍵字,label、seedQuery、brief 都必須保留該方向,不可漂移成無關日常題
+22. 繁體中文;只回傳 JSON:
+23. 避免新聞導向:不要產出「某事件最新進展」「政策新聞懶人包」「名人/品牌新聞」這類題目;除非它已經轉化成受眾正在問的生活問題或經驗討論
+{"label":"","seedQuery":"","brief":"","trendReason":"","trendKeywords":[],"angles":[],"mission":"","targetAudience":"","openingType":"","bodyType":"","emotion":"","ctaType":"","riskLevel":"low","avoid":[]}`)
}
func BuildMissionInspireUserPrompt(in MissionInspireInput) string {
@@ -76,9 +101,9 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string {
b.WriteString("\n")
}
if p := strings.TrimSpace(in.PersonaBlock); p != "" {
- b.WriteString("【人設摘要】\n")
+ b.WriteString("【人設摘要(只用來校準語氣與受眾,不是題材庫)】\n")
b.WriteString(p)
- b.WriteString("\n")
+ b.WriteString("\n注意:不要把人設裡的名詞、興趣或裝飾符號直接變成話題。\n")
}
if bench := strings.TrimSpace(in.StyleBenchmark); bench != "" {
b.WriteString("【對標帳號】@")
@@ -105,8 +130,20 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string {
b.WriteString(keyword)
b.WriteString("\n")
}
- if len(in.RecentMissionLabels) > 0 || len(in.RecentSeedQueries) > 0 {
+ if direction := strings.TrimSpace(in.ContentDirection); direction != "" {
+ b.WriteString("【使用者選擇內容方向】")
+ b.WriteString(direction)
+ b.WriteString("\n請讓 label、brief、angles 都符合這個方向;如果方向是 auto,請依熱度與人設自動選。\n")
+ }
+ if len(in.AvoidTopics) > 0 || len(in.RecentMissionLabels) > 0 || len(in.RecentSeedQueries) > 0 {
b.WriteString("【近期已做過的任務(請避開)】\n")
+ for _, topic := range in.AvoidTopics {
+ if topic = strings.TrimSpace(topic); topic != "" {
+ b.WriteString("- ")
+ b.WriteString(topic)
+ b.WriteString("\n")
+ }
+ }
for _, label := range in.RecentMissionLabels {
if label = strings.TrimSpace(label); label != "" {
b.WriteString("- ")
@@ -147,15 +184,15 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string {
b.WriteString("\n")
}
}
- b.WriteString("請產出拷貝任務靈感 JSON。")
+ b.WriteString("請產出 Topic Radar 與 Content Plan JSON。")
return b.String()
}
func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string {
queries := []string{
- "Google Trends 台灣 今日 熱門搜尋 關鍵字",
- "Threads 台灣 熱門 話題 最近 一週",
- "近期 網路 熱搜 關鍵字 台灣",
+ "Threads 台灣 熱門 關鍵字 討論 最近 一週",
+ "Threads 台灣 請問 推薦 心得 最近",
+ "社群 熱門 關鍵字 台灣 討論 Dcard PTT Threads",
}
context := strings.TrimSpace(personaBrief + " " + styleBenchmark)
if context != "" {
@@ -163,7 +200,10 @@ func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string {
if len([]rune(trimmed)) > 24 {
trimmed = string([]rune(trimmed)[:24])
}
- queries = append(queries, trimmed+" 熱門 話題 最近")
+ queries = append(queries,
+ trimmed+" Threads 熱門 關鍵字 討論",
+ trimmed+" 請問 心得 推薦 Threads",
+ )
}
return queries
}
@@ -186,9 +226,11 @@ func InspireThreadsSearchQueries(in MissionInspireThreadsInput) []string {
keyword := cleanInspireQuery(in.Keyword)
if keyword != "" {
add(keyword)
- add(keyword + " 心情")
- add(keyword + " 經驗")
+ add(keyword + " 熱門")
+ add(keyword + " 心得")
add(keyword + " 請問")
+ add(keyword + " 推薦")
+ add(keyword + " 怎麼辦")
}
for _, pillar := range in.Pillars {
if len(out) >= 6 {
@@ -324,10 +366,17 @@ func ParseMissionInspireOutput(raw string) (MissionInspireOutput, error) {
return MissionInspireOutput{}, err
}
out := MissionInspireOutput{
- Label: firstJSONString(root, "label", "title", "mission_label"),
- SeedQuery: firstJSONString(root, "seedQuery", "seed_query", "seed", "keywords"),
- Brief: firstJSONString(root, "brief", "description", "goal"),
- TrendReason: firstJSONString(root, "trendReason", "trend_reason", "reason"),
+ Label: firstJSONString(root, "label", "title", "mission_label"),
+ SeedQuery: firstJSONString(root, "seedQuery", "seed_query", "seed", "keywords"),
+ Brief: firstJSONString(root, "brief", "description", "goal"),
+ TrendReason: firstJSONString(root, "trendReason", "trend_reason", "reason"),
+ Mission: firstJSONString(root, "mission", "contentMission", "content_mission"),
+ TargetAudience: firstJSONString(root, "targetAudience", "target_audience", "audience"),
+ OpeningType: firstJSONString(root, "openingType", "opening_type", "hookType", "hook_type"),
+ BodyType: firstJSONString(root, "bodyType", "body_type", "structure", "bodyStructure"),
+ Emotion: firstJSONString(root, "emotion", "emotionalLever", "emotional_lever"),
+ CtaType: firstJSONString(root, "ctaType", "cta_type", "cta"),
+ RiskLevel: firstJSONString(root, "riskLevel", "risk_level", "risk"),
}
if out.Label == "" || out.SeedQuery == "" || out.Brief == "" {
return MissionInspireOutput{}, fmt.Errorf("missing label, seedQuery, or brief")
@@ -340,5 +389,21 @@ func ParseMissionInspireOutput(raw string) (MissionInspireOutput, error) {
out.TrendKeywords = parseFlexibleStringList(rawKW)
}
}
+ if rawAngles, ok := root["angles"]; ok {
+ out.Angles = parseFlexibleStringList(rawAngles)
+ }
+ if len(out.Angles) == 0 {
+ if rawAngles, ok := root["contentAngles"]; ok {
+ out.Angles = parseFlexibleStringList(rawAngles)
+ }
+ }
+ if rawAvoid, ok := root["avoid"]; ok {
+ out.Avoid = parseFlexibleStringList(rawAvoid)
+ }
+ if len(out.Avoid) == 0 {
+ if rawAvoid, ok := root["avoidTopics"]; ok {
+ out.Avoid = parseFlexibleStringList(rawAvoid)
+ }
+ }
return out, nil
}
diff --git a/backend/internal/library/viral/mission_inspire_collect.go b/backend/internal/library/viral/mission_inspire_collect.go
index c22fdc1..febd496 100644
--- a/backend/internal/library/viral/mission_inspire_collect.go
+++ b/backend/internal/library/viral/mission_inspire_collect.go
@@ -49,6 +49,9 @@ func CollectMissionInspireTrends(
if title == "" && snippet == "" {
continue
}
+ if looksLikeNewsResult(title, snippet, item.URL) {
+ continue
+ }
key := strings.ToLower(title + "|" + snippet)
if _, ok := seen[key]; ok {
continue
@@ -64,3 +67,24 @@ func CollectMissionInspireTrends(
}
return out
}
+
+func looksLikeNewsResult(title, snippet, url string) bool {
+ text := strings.ToLower(title + " " + snippet + " " + url)
+ newsSignals := []string{"新聞", "快訊", "中央社", "聯合新聞", "ettoday", "tvbs", "setn", "ctwant", "chinatimes", "ltn.com", "news"}
+ socialSignals := []string{"threads", "dcard", "ptt", "請問", "心得", "推薦", "有人", "怎麼辦", "討論", "留言"}
+ newsScore := 0
+ for _, signal := range newsSignals {
+ if strings.Contains(text, strings.ToLower(signal)) {
+ newsScore++
+ }
+ }
+ if newsScore == 0 {
+ return false
+ }
+ for _, signal := range socialSignals {
+ if strings.Contains(text, strings.ToLower(signal)) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/backend/internal/library/viral/replicate.go b/backend/internal/library/viral/replicate.go
index 6be7051..ef01504 100644
--- a/backend/internal/library/viral/replicate.go
+++ b/backend/internal/library/viral/replicate.go
@@ -6,6 +6,7 @@ import (
"regexp"
"strings"
+ "haixun-backend/internal/library/copyvoice"
"haixun-backend/internal/library/threadspost"
)
@@ -19,6 +20,7 @@ type ReplicateInput struct {
OriginalText string
AuthorName string
StructureAnalysis string
+ NarrativeSeed string
}
type ReplicateResult struct {
@@ -30,13 +32,15 @@ type ReplicateResult struct {
}
func BuildSystemPrompt() string {
- return strings.TrimSpace(`你是 Threads 爆款複製策略師。根據參考爆文,為使用者撰寫「同結構、同節奏、但完全原創」的貼文。
+ return strings.TrimSpace(`你是 Threads / Instagram 創作者。讀完參考爆文後,用指定人設的語言指紋寫一篇全新貼文。
-規則:
-- 複製的是爆款公式(hook 手法、情緒節奏),不是抄襲原文
-- 文筆必須像創作者本人,套用其 8D 風格策略
+` + copyvoice.SystemRules() + `
+
+輸出:
- text 主文 ≤ 500 字(Threads API 硬上限,含 #話題標籤)
-- 爆款互動最佳 80~220 字:前 1~2 行強 hook,口語精簡;超過 300 字互動通常下降
+- 參考原文只學情緒力度、資訊密度與節奏;最後必須換成人設會講出口的話
+- 篇幅、行數、標點與換行優先遵守人設語言指紋;沒有指紋時才貼近參考原文
+- text 排版要像真人手機發文:可使用自然標點、空行、列點與少量 emoji;不要完全無標點,也不要每句硬換行,除非人設樣本就是這樣
- 只回傳一個 JSON 物件,欄位:angle, hook, text, rationale, structureNotes`)
}
@@ -51,29 +55,39 @@ func BuildUserPrompt(input ReplicateInput) string {
b.WriteString("\n")
}
if persona := strings.TrimSpace(input.Persona); persona != "" {
- b.WriteString("\n人設與語氣:\n")
+ b.WriteString("\n人設定位、語言指紋與禁忌(最高優先):\n")
b.WriteString(persona)
b.WriteString("\n")
+ b.WriteString(copyvoice.PersonaCalibrationNote())
+ b.WriteString("\n")
}
if style := strings.TrimSpace(input.StyleProfile); style != "" {
b.WriteString("\n8D 風格策略:\n")
b.WriteString(style)
b.WriteString("\n")
}
+ if analysis := strings.TrimSpace(input.StructureAnalysis); analysis != "" {
+ b.WriteString("\n敘事分析(只學故事感,不要套句型或段落公式):\n")
+ b.WriteString(analysis)
+ b.WriteString("\n")
+ }
author := strings.TrimSpace(input.AuthorName)
if author == "" {
author = "匿名"
}
- if analysis := strings.TrimSpace(input.StructureAnalysis); analysis != "" {
- b.WriteString("\n爆款結構分析(仿寫時套用):\n")
- b.WriteString(analysis)
+ original := strings.TrimSpace(input.OriginalText)
+ if note := threadspost.MimicLengthGuidance(original); note != "" {
+ b.WriteString("\n")
+ b.WriteString(note)
b.WriteString("\n")
}
b.WriteString("\n原文參考(@")
b.WriteString(author)
- b.WriteString(",只學結構不抄內容):\n")
- b.WriteString(strings.TrimSpace(input.OriginalText))
- b.WriteString("\n\n請產出一篇可發布的複製版貼文 JSON。")
+ b.WriteString(",只感受情緒、敘事與篇幅,禁止抄句型):\n")
+ b.WriteString(original)
+ b.WriteString("\n\n本次敘事切入:")
+ b.WriteString(copyvoice.NarrativeLens(input.NarrativeSeed))
+ b.WriteString("\n\n請產出一篇可發布的全新貼文 JSON。hook 欄位只放內部分析,不要把模板句型寫進 text。")
return b.String()
}
@@ -86,7 +100,7 @@ func ParseReplicateOutput(raw string) (ReplicateResult, error) {
if err := json.Unmarshal(payload, &out); err != nil {
return ReplicateResult{}, fmt.Errorf("parse viral replica json: %w", err)
}
- out.Text = trimText(out.Text)
+ out.Text = threadspost.FormatDraftText(out.Text)
if out.Text == "" {
return ReplicateResult{}, fmt.Errorf("replica text missing")
}
@@ -97,15 +111,6 @@ func ParseReplicateOutput(raw string) (ReplicateResult, error) {
return out, nil
}
-func trimText(text string) string {
- text = strings.TrimSpace(text)
- runes := []rune(text)
- if len(runes) > threadspost.MaxPublishRunes {
- return string(runes[:threadspost.MaxPublishRunes])
- }
- return text
-}
-
func extractJSONObject(raw string) ([]byte, error) {
raw = strings.TrimSpace(raw)
if m := codeFenceRE.FindStringSubmatch(raw); len(m) == 2 {
diff --git a/backend/internal/library/webpage/digest.go b/backend/internal/library/webpage/digest.go
index 4f61628..a36f6ce 100644
--- a/backend/internal/library/webpage/digest.go
+++ b/backend/internal/library/webpage/digest.go
@@ -13,11 +13,11 @@ import (
)
const (
- DefaultMaxPages = 6
- DefaultMaxMarkdown = 6000
- DefaultPerPageChars = 2500
- DefaultFetchTimeout = 18 * time.Second
- DefaultMaxBodyBytes = 2 << 20
+ DefaultMaxPages = 6
+ DefaultMaxMarkdown = 6000
+ DefaultPerPageChars = 2500
+ DefaultFetchTimeout = 18 * time.Second
+ DefaultMaxBodyBytes = 2 << 20
)
type FetchOptions struct {
@@ -179,4 +179,4 @@ func min(a, b int) int {
return a
}
return b
-}
\ No newline at end of file
+}
diff --git a/backend/internal/library/webpage/digest_test.go b/backend/internal/library/webpage/digest_test.go
index b0cc541..e66b279 100644
--- a/backend/internal/library/webpage/digest_test.go
+++ b/backend/internal/library/webpage/digest_test.go
@@ -40,4 +40,4 @@ func TestFetchDigests_ExtractsMarkdown(t *testing.T) {
if !strings.Contains(digests[0].Markdown, "備孕指南") || !strings.Contains(digests[0].Markdown, "葉酸") {
t.Fatalf("markdown = %q", digests[0].Markdown)
}
-}
\ No newline at end of file
+}
diff --git a/backend/internal/library/webpage/format.go b/backend/internal/library/webpage/format.go
index 4362d0a..2f7126c 100644
--- a/backend/internal/library/webpage/format.go
+++ b/backend/internal/library/webpage/format.go
@@ -49,4 +49,4 @@ func formatDigestSection(item Digest) string {
return ""
}
return "來源:" + item.URL + "\n(抓取失敗:" + item.Error + ")"
-}
\ No newline at end of file
+}
diff --git a/backend/internal/logic/brand/create_brand_product_logic.go b/backend/internal/logic/brand/create_brand_product_logic.go
index 85e631e..76c5184 100644
--- a/backend/internal/logic/brand/create_brand_product_logic.go
+++ b/backend/internal/logic/brand/create_brand_product_logic.go
@@ -38,6 +38,7 @@ func (l *CreateBrandProductLogic) CreateBrandProduct(req *types.CreateBrandProdu
BrandID: req.ID,
Label: req.Label,
ProductContext: req.ProductContext,
+ PlacementURL: req.PlacementURL,
MatchTags: req.MatchTags,
})
if err != nil {
diff --git a/backend/internal/logic/brand/generate_outreach_drafts_logic.go b/backend/internal/logic/brand/generate_outreach_drafts_logic.go
index 40dd871..ba3de8b 100644
--- a/backend/internal/logic/brand/generate_outreach_drafts_logic.go
+++ b/backend/internal/logic/brand/generate_outreach_drafts_logic.go
@@ -5,21 +5,12 @@ package brand
import (
"context"
- "fmt"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
- libkg "haixun-backend/internal/library/knowledge"
liboutreach "haixun-backend/internal/library/outreach"
- libplacement "haixun-backend/internal/library/placement"
- libprompt "haixun-backend/internal/library/prompt"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- branddomain "haixun-backend/internal/model/brand/domain/usecase"
outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase"
- scanpostentity "haixun-backend/internal/model/scan_post/domain/entity"
- scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@@ -46,225 +37,40 @@ func (l *GenerateOutreachDraftsLogic) GenerateOutreachDrafts(req *types.Generate
return nil, err
}
scanPostID := strings.TrimSpace(req.ScanPostID)
- count := 2
- if req.Count > 0 {
- count = req.Count
- }
if scanPostID == "" {
return nil, app.For(code.Brand).InputMissingRequired("scan_post_id is required")
}
- if count > 4 {
- count = 4
+ if strings.TrimSpace(req.VoicePersonaID) == "" {
+ return nil, app.For(code.Brand).InputMissingRequired("voice_persona_id is required")
}
- brand, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- voicePersona := ""
- voiceID := strings.TrimSpace(req.VoicePersonaID)
- if voiceID != "" {
- vp, vpErr := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, voiceID)
- if vpErr != nil {
- return nil, vpErr
- }
- voicePersona = vp.Persona
- }
- productID := strings.TrimSpace(req.ProductID)
- if productID == "" {
- productID = strings.TrimSpace(brand.ProductID)
- }
- post, err := l.svcCtx.ScanPost.Get(l.ctx, tenantID, uid, req.ID, scanPostID)
- if err != nil {
- return nil, err
- }
-
- topicLabel := strings.TrimSpace(post.SearchTag)
- placementReason := buildPlacementReason(post)
- if graph, graphErr := l.svcCtx.KnowledgeGraph.Get(l.ctx, tenantID, uid, req.ID); graphErr == nil && graph != nil {
- if node := findGraphNode(graph.Nodes, post.GraphNodeID); node != nil {
- if strings.TrimSpace(node.Label) != "" {
- topicLabel = strings.TrimSpace(node.Label)
- }
- placementReason = mergePlacementReason(placementReason, node, post)
- }
- }
-
- userPrompt, err := liboutreach.BuildUserPrompt(liboutreach.GenerateInput{
- Persona: voicePersona,
- TopicLabel: topicLabel,
- AudienceBrief: brand.TargetAudience,
- ProductBrief: productBriefForBrand(brand, productID, post.SearchTag),
- PlacementReason: placementReason,
- TargetText: post.Text,
- AuthorName: post.Author,
- Count: count,
- })
- if err != nil {
- return nil, app.For(code.AI).SysInternal("outreach user prompt load failed")
- }
-
- systemPrompt, err := libprompt.OutreachPlacementSystem()
- if err != nil {
- return nil, app.For(code.AI).SysInternal("outreach system prompt load failed")
- }
- if strings.TrimSpace(voicePersona) != "" {
- systemPrompt = strings.TrimSpace(systemPrompt) + "\n\n人設與語氣:\n" + strings.TrimSpace(voicePersona)
- }
-
- credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return nil, err
- }
-
- result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: systemPrompt,
- Messages: []domai.Message{
- {Role: "user", Content: userPrompt},
- },
+ saved, err := liboutreach.GenerateDraft(l.ctx, outreachDepsFrom(l.svcCtx), liboutreach.GenerateDraftRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ BrandID: req.ID,
+ TopicID: strings.TrimSpace(req.TopicID),
+ ScanPostID: scanPostID,
+ Count: req.Count,
+ VoicePersonaID: req.VoicePersonaID,
+ ProductID: req.ProductID,
+ Regenerate: req.Regenerate,
})
if err != nil {
return nil, err
}
-
- parsed, err := liboutreach.ParseGenerateOutput(result.Text)
- if err != nil {
- return nil, app.For(code.AI).SvcThirdParty("獲客留言 LLM 回傳無法解析:" + err.Error())
- }
-
- draftItems := make([]outreachusecase.DraftItem, 0, len(parsed.Drafts))
- for _, item := range parsed.Drafts {
- draftItems = append(draftItems, outreachusecase.DraftItem{
- Text: item.Text,
- Angle: item.Angle,
- Rationale: item.Rationale,
- })
- }
- saved, err := l.svcCtx.OutreachDraft.Create(l.ctx, outreachusecase.CreateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- BrandID: req.ID,
- TopicID: strings.TrimSpace(req.TopicID),
- ScanPostID: scanPostID,
- Relevance: parsed.Relevance,
- Reason: parsed.Reason,
- Drafts: draftItems,
- })
- if err != nil {
- return nil, err
- }
- _, _ = l.svcCtx.ScanPost.UpdateOutreach(l.ctx, scanpostusecase.UpdateOutreachRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- BrandID: req.ID,
- PostID: scanPostID,
- Status: scanpostentity.OutreachStatusDrafted,
- })
-
return toOutreachDraftData(saved), nil
}
-func findGraphNode(nodes []libkg.Node, nodeID string) *libkg.Node {
- nodeID = strings.TrimSpace(nodeID)
- if nodeID == "" {
- return nil
+func outreachDepsFrom(svcCtx *svc.ServiceContext) liboutreach.GenerateDraftDeps {
+ return liboutreach.GenerateDraftDeps{
+ Brand: svcCtx.Brand,
+ Persona: svcCtx.Persona,
+ ScanPost: svcCtx.ScanPost,
+ KnowledgeGraph: svcCtx.KnowledgeGraph,
+ ThreadsAccount: svcCtx.ThreadsAccount,
+ AI: svcCtx.AI,
+ OutreachDraft: svcCtx.OutreachDraft,
}
- for i := range nodes {
- if nodes[i].ID == nodeID {
- return &nodes[i]
- }
- }
- return nil
-}
-
-func buildPlacementReason(post *scanpostusecase.ScanPostSummary) string {
- if post == nil {
- return ""
- }
- parts := []string{}
- if post.Priority == "gold" {
- parts = append(parts, "雙軌命中(相關+近期)")
- } else if post.Priority == "recent" {
- parts = append(parts, "近期軌命中")
- }
- if post.SolvedByProduct {
- parts = append(parts, "產品可解此需求")
- }
- if post.ProductFitScore > 0 {
- parts = append(parts, fmt.Sprintf("產品匹配 %d", post.ProductFitScore))
- }
- return strings.Join(parts, ";")
-}
-
-func mergePlacementReason(base string, node *libkg.Node, post *scanpostusecase.ScanPostSummary) string {
- parts := []string{}
- if strings.TrimSpace(base) != "" {
- parts = append(parts, strings.TrimSpace(base))
- }
- if node != nil {
- if strings.TrimSpace(node.PlacementValue) != "" {
- parts = append(parts, "置入價值 "+strings.TrimSpace(node.PlacementValue))
- }
- if node.ProductFitScore > 0 && (post == nil || post.ProductFitScore == 0) {
- parts = append(parts, fmt.Sprintf("節點產品匹配 %d", node.ProductFitScore))
- }
- }
- return strings.Join(parts, ";")
-}
-
-func productBriefForBrand(brand *branddomain.BrandSummary, productID, searchTag string) string {
- if brand == nil {
- return ""
- }
- if product := pickProductForOutreach(brand, productID, searchTag); product != nil {
- merged := libplacement.BuildMergedProductContext(brand.DisplayName, product.ProductContext, product.Label)
- if pb := libplacement.ProductBriefFromContext(merged); pb != "" {
- return pb
- }
- }
- if pb := libplacement.ProductBriefFromContext(brand.ProductContext); pb != "" {
- return pb
- }
- return strings.TrimSpace(brand.ProductBrief)
-}
-
-func pickProductForOutreach(brand *branddomain.BrandSummary, productID, searchTag string) *branddomain.ProductSummary {
- if brand == nil || len(brand.Products) == 0 {
- return nil
- }
- if id := strings.TrimSpace(productID); id != "" {
- for i := range brand.Products {
- if brand.Products[i].ID == id {
- return &brand.Products[i]
- }
- }
- }
- tag := strings.TrimSpace(searchTag)
- if tag == "" {
- return &brand.Products[0]
- }
- bestScore := -1
- var best *branddomain.ProductSummary
- for i := range brand.Products {
- score := libplacement.ScoreProductForTag(tag, brand.Products[i].MatchTags)
- if score > bestScore {
- bestScore = score
- best = &brand.Products[i]
- }
- }
- if best != nil && bestScore > 0 {
- return best
- }
- return &brand.Products[0]
}
func toOutreachDraftData(saved *outreachusecase.DraftSummary) *types.GenerateOutreachDraftsData {
diff --git a/backend/internal/logic/brand/mapper.go b/backend/internal/logic/brand/mapper.go
index 5e6bf1e..bd18f9c 100644
--- a/backend/internal/logic/brand/mapper.go
+++ b/backend/internal/logic/brand/mapper.go
@@ -34,6 +34,7 @@ func toBrandProductData(item domusecase.ProductSummary) types.BrandProductData {
ID: item.ID,
Label: item.Label,
ProductContext: item.ProductContext,
+ PlacementURL: item.PlacementURL,
MatchTags: append([]string(nil), item.MatchTags...),
CreateAt: item.CreateAt,
UpdateAt: item.UpdateAt,
diff --git a/backend/internal/logic/brand/patch_scan_post_outreach_logic.go b/backend/internal/logic/brand/patch_scan_post_outreach_logic.go
index 34cb05d..cc9e067 100644
--- a/backend/internal/logic/brand/patch_scan_post_outreach_logic.go
+++ b/backend/internal/logic/brand/patch_scan_post_outreach_logic.go
@@ -73,6 +73,7 @@ func toScanPostData(post *scanpostusecase.ScanPostSummary) *types.ScanPostData {
GraphNodeID: post.GraphNodeID,
SearchTag: post.SearchTag,
QueryDimension: post.QueryDimension,
+ RecencyDays: post.RecencyDays,
ExternalID: post.ExternalID,
Permalink: post.Permalink,
Author: post.Author,
diff --git a/backend/internal/logic/brand/publish_outreach_draft_logic.go b/backend/internal/logic/brand/publish_outreach_draft_logic.go
index f28428f..2a2d965 100644
--- a/backend/internal/logic/brand/publish_outreach_draft_logic.go
+++ b/backend/internal/logic/brand/publish_outreach_draft_logic.go
@@ -9,7 +9,10 @@ import (
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
+ liboutreach "haixun-backend/internal/library/outreach"
+ libplacement "haixun-backend/internal/library/placement"
libthreads "haixun-backend/internal/library/threadsapi"
+ "haixun-backend/internal/library/threadspost"
scanpostentity "haixun-backend/internal/model/scan_post/domain/entity"
scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
"haixun-backend/internal/svc"
@@ -38,7 +41,7 @@ func (l *PublishOutreachDraftLogic) PublishOutreachDraft(req *types.PublishOutre
return nil, err
}
scanPostID := strings.TrimSpace(req.ScanPostID)
- text := strings.TrimSpace(req.Text)
+ text := liboutreach.NormalizeDraftText(req.Text)
confirm := req.Confirm
if scanPostID == "" {
return nil, app.For(code.Brand).InputMissingRequired("scan_post_id is required")
@@ -49,6 +52,9 @@ func (l *PublishOutreachDraftLogic) PublishOutreachDraft(req *types.PublishOutre
if text == "" {
return nil, app.For(code.Brand).InputMissingRequired("text is required")
}
+ if err := threadspost.ValidateReply(text); err != nil {
+ return nil, app.For(code.Brand).InputInvalidFormat(err.Error())
+ }
if _, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID); err != nil {
return nil, err
@@ -57,26 +63,49 @@ func (l *PublishOutreachDraftLogic) PublishOutreachDraft(req *types.PublishOutre
if err != nil {
return nil, err
}
- externalID := strings.TrimSpace(post.ExternalID)
- if externalID == "" {
- return nil, app.For(code.Brand).InputMissingRequired("此貼文缺少 external_id,無法透過 API 回覆")
+ rawTargetID := libplacement.ResolveReplyTargetID(post.ExternalID, post.Permalink)
+ if rawTargetID == "" {
+ return nil, app.For(code.Brand).InputMissingRequired("此貼文缺少 Threads 貼文 ID(external_id 或有效 permalink),無法透過 API 回覆")
}
- cred, err := l.svcCtx.ThreadsAccount.ResolveMemberThreadsPublishCredential(l.ctx, tenantID, uid)
+ cred, err := l.svcCtx.ThreadsAccount.ResolveMemberThreadsReplyCredential(l.ctx, tenantID, uid)
if err != nil {
return nil, err
}
+ storageState := ""
+ if session, sessErr := l.svcCtx.ThreadsAccount.GetBrowserSession(l.ctx, tenantID, uid, cred.AccountID); sessErr == nil && session != nil {
+ storageState = strings.TrimSpace(session.StorageState)
+ }
+ resolveInput := libplacement.ReplyMediaResolveInput{
+ AccessToken: cred.AccessToken,
+ StorageState: storageState,
+ ExternalID: rawTargetID,
+ Permalink: post.Permalink,
+ HintText: post.Text,
+ }
+ replyToID, err := libplacement.ResolveReplyMediaForPublish(l.ctx, resolveInput)
+ if err != nil {
+ return nil, app.For(code.Brand).InputMissingRequired(err.Error())
+ }
+ apiClient := libthreads.NewClient(cred.AccessToken)
+ if err := apiClient.PrimeReplyTargetForPublish(l.ctx, libthreads.ResolveReplyMediaInput{
+ ExternalID: rawTargetID,
+ Permalink: post.Permalink,
+ HintText: post.Text,
+ }, replyToID); err != nil {
+ return nil, app.For(code.ThreadsAccount).SvcThirdParty("Threads API 無法回覆此貼文:" + err.Error())
+ }
result, err := libthreads.PublishReply(l.ctx, libthreads.PublishReplyInput{
ThreadsUserID: cred.ThreadsUserID,
AccessToken: cred.AccessToken,
- ReplyToID: externalID,
+ ReplyToID: replyToID,
Text: text,
})
if err != nil {
return nil, app.For(code.ThreadsAccount).SvcThirdParty("Threads API 發送留言失敗:" + err.Error())
}
- updated, err := l.svcCtx.ScanPost.UpdateOutreach(l.ctx, scanpostusecase.UpdateOutreachRequest{
+ patch := scanpostusecase.UpdateOutreachRequest{
TenantID: tenantID,
OwnerUID: uid,
BrandID: req.ID,
@@ -84,7 +113,11 @@ func (l *PublishOutreachDraftLogic) PublishOutreachDraft(req *types.PublishOutre
Status: scanpostentity.OutreachStatusPublished,
PublishedReplyID: result.MediaID,
PublishedPermalink: result.Permalink,
- })
+ }
+ if libthreads.IsNumericMediaID(replyToID) && !libthreads.IsNumericMediaID(post.ExternalID) {
+ patch.ExternalID = replyToID
+ }
+ updated, err := l.svcCtx.ScanPost.UpdateOutreach(l.ctx, patch)
if err != nil {
return nil, err
}
diff --git a/backend/internal/logic/brand/start_brand_scan_job_logic.go b/backend/internal/logic/brand/start_brand_scan_job_logic.go
index b6c8a81..21ba896 100644
--- a/backend/internal/logic/brand/start_brand_scan_job_logic.go
+++ b/backend/internal/logic/brand/start_brand_scan_job_logic.go
@@ -123,11 +123,8 @@ func (l *StartBrandScanJobLogic) StartBrandScanJob(req *types.StartBrandScanJobH
if placement.MemberNeedsWebSearchKey(memberCtx) && placement.MissingWebSearchKey(research) {
return nil, app.For(code.Setting).InputMissingRequired(placement.WebSearchKeyRequiredMessage(research))
}
- if memberCtx.DevMode && !memberCtx.BrowserConnected {
- return nil, app.For(code.Setting).InputMissingRequired("開發模式需先同步 Chrome Session")
- }
- if !memberCtx.DevMode && memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected {
- return nil, app.For(code.Setting).InputMissingRequired("正式模式需先完成 Threads API 連線")
+ if memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected {
+ return nil, app.For(code.Setting).InputMissingRequired("請先完成 Threads API 連線後再開始海巡")
}
payload := map[string]any{
diff --git a/backend/internal/logic/brand/update_brand_product_logic.go b/backend/internal/logic/brand/update_brand_product_logic.go
index 34e8b5d..185518e 100644
--- a/backend/internal/logic/brand/update_brand_product_logic.go
+++ b/backend/internal/logic/brand/update_brand_product_logic.go
@@ -5,6 +5,7 @@ package brand
import (
"context"
+ "strings"
domusecase "haixun-backend/internal/model/brand/domain/usecase"
"haixun-backend/internal/svc"
@@ -32,15 +33,18 @@ func (l *UpdateBrandProductLogic) UpdateBrandProduct(req *types.UpdateBrandProdu
if err != nil {
return nil, err
}
+ placementURL := strings.TrimSpace(req.PlacementURL)
item, err := l.svcCtx.Brand.UpdateProduct(l.ctx, domusecase.UpdateProductRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- BrandID: req.ID,
- ProductID: req.ProductID,
- Label: req.Label,
- ProductContext: req.ProductContext,
- MatchTags: req.MatchTags,
- MatchTagsSet: req.MatchTags != nil,
+ TenantID: tenantID,
+ OwnerUID: uid,
+ BrandID: req.ID,
+ ProductID: req.ProductID,
+ Label: req.Label,
+ ProductContext: req.ProductContext,
+ PlacementURL: &placementURL,
+ PlacementURLSet: true,
+ MatchTags: req.MatchTags,
+ MatchTagsSet: req.MatchTags != nil,
})
if err != nil {
return nil, err
diff --git a/backend/internal/logic/brand/update_outreach_draft_logic.go b/backend/internal/logic/brand/update_outreach_draft_logic.go
new file mode 100644
index 0000000..dc95ca5
--- /dev/null
+++ b/backend/internal/logic/brand/update_outreach_draft_logic.go
@@ -0,0 +1,68 @@
+package brand
+
+import (
+ "context"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ liboutreach "haixun-backend/internal/library/outreach"
+ "haixun-backend/internal/library/threadspost"
+ outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type UpdateOutreachDraftLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewUpdateOutreachDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateOutreachDraftLogic {
+ return &UpdateOutreachDraftLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *UpdateOutreachDraftLogic) UpdateOutreachDraft(req *types.UpdateOutreachDraftHandlerReq) (*types.GenerateOutreachDraftsData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ draftID := strings.TrimSpace(req.DraftID)
+ if draftID == "" {
+ return nil, app.For(code.Brand).InputMissingRequired("draft_id is required")
+ }
+ text := liboutreach.NormalizeDraftText(req.Text)
+ if text == "" {
+ return nil, app.For(code.Brand).InputMissingRequired("text is required")
+ }
+ if err := threadspost.ValidateReply(text); err != nil {
+ return nil, app.For(code.Brand).InputInvalidFormat(err.Error())
+ }
+ if req.DraftIndex < 0 {
+ return nil, app.For(code.Brand).InputInvalidFormat("draft_index must be >= 0")
+ }
+
+ if _, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID); err != nil {
+ return nil, err
+ }
+
+ updated, err := l.svcCtx.OutreachDraft.UpdateDraftItem(l.ctx, outreachusecase.UpdateDraftItemRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ BrandID: req.ID,
+ DraftID: draftID,
+ DraftIndex: req.DraftIndex,
+ Text: text,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return toOutreachDraftData(updated), nil
+}
diff --git a/backend/internal/logic/copy_mission/capabilities.go b/backend/internal/logic/copy_mission/capabilities.go
deleted file mode 100644
index 98e8c57..0000000
--- a/backend/internal/logic/copy_mission/capabilities.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package copy_mission
-
-import (
- "context"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/svc"
-)
-
-func requireMemberAiReady(ctx context.Context, svcCtx *svc.ServiceContext, tenantID, uid string) error {
- if _, err := svcCtx.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, uid); err != nil {
- return app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 AI API key")
- }
- return nil
-}
diff --git a/backend/internal/logic/copy_mission/create_copy_mission_logic.go b/backend/internal/logic/copy_mission/create_copy_mission_logic.go
index cc652ca..86aea3b 100644
--- a/backend/internal/logic/copy_mission/create_copy_mission_logic.go
+++ b/backend/internal/logic/copy_mission/create_copy_mission_logic.go
@@ -1,10 +1,11 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
- "strings"
- missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@@ -25,33 +26,8 @@ func NewCreateCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext)
}
}
-func (l *CreateCopyMissionLogic) CreateCopyMission(req *types.CreateCopyMissionHandlerReq) (*types.CopyMissionData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.PersonaID)
- persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
- if err != nil {
- return nil, err
- }
- initialMap, initialTags := seedMissionResearchFromPersona(*persona)
- createReq := missiondomain.CreateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: personaID,
- Label: req.Label,
- SeedQuery: req.SeedQuery,
- Brief: req.Brief,
- }
- if initialMap != nil {
- createReq.InitialResearchMap = initialMap
- createReq.InitialSelectedTags = initialTags
- }
- item, err := l.svcCtx.CopyMission.Create(l.ctx, createReq)
- if err != nil {
- return nil, err
- }
- data := toCopyMissionData(*item)
- return &data, nil
+func (l *CreateCopyMissionLogic) CreateCopyMission(req *types.CreateCopyMissionHandlerReq) (resp *types.CopyMissionData, err error) {
+ // todo: add your logic here and delete this line
+
+ return
}
diff --git a/backend/internal/logic/copy_mission/delete_copy_mission_logic.go b/backend/internal/logic/copy_mission/delete_copy_mission_logic.go
index 6c64daf..65d8414 100644
--- a/backend/internal/logic/copy_mission/delete_copy_mission_logic.go
+++ b/backend/internal/logic/copy_mission/delete_copy_mission_logic.go
@@ -1,8 +1,10 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
- "strings"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@@ -25,9 +27,7 @@ func NewDeleteCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext)
}
func (l *DeleteCopyMissionLogic) DeleteCopyMission(req *types.CopyMissionPath) error {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return err
- }
- return l.svcCtx.CopyMission.Delete(l.ctx, tenantID, uid, strings.TrimSpace(req.PersonaID), strings.TrimSpace(req.ID))
+ // todo: add your logic here and delete this line
+
+ return nil
}
diff --git a/backend/internal/logic/copy_mission/delete_copy_mission_matrix_drafts_logic.go b/backend/internal/logic/copy_mission/delete_copy_mission_matrix_drafts_logic.go
index 6a96cef..960b831 100644
--- a/backend/internal/logic/copy_mission/delete_copy_mission_matrix_drafts_logic.go
+++ b/backend/internal/logic/copy_mission/delete_copy_mission_matrix_drafts_logic.go
@@ -1,52 +1,33 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
- "fmt"
- "strings"
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
)
type DeleteCopyMissionMatrixDraftsLogic struct {
+ logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewDeleteCopyMissionMatrixDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteCopyMissionMatrixDraftsLogic {
- return &DeleteCopyMissionMatrixDraftsLogic{ctx: ctx, svcCtx: svcCtx}
+ return &DeleteCopyMissionMatrixDraftsLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
}
-func (l *DeleteCopyMissionMatrixDraftsLogic) DeleteCopyMissionMatrixDrafts(
- req *types.DeleteCopyMissionMatrixDraftsHandlerReq,
-) (*types.DeleteCopyMissionMatrixDraftsData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.PersonaID)
- missionID := strings.TrimSpace(req.ID)
- if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil {
- return nil, err
- }
- deleted, err := l.svcCtx.CopyDraft.DeleteMissionMatrixDrafts(l.ctx, copydraftdomain.DeleteMissionMatrixDraftsRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: personaID,
- MissionID: missionID,
- DraftIDs: req.DraftIDs,
- })
- if err != nil {
- return nil, err
- }
- message := fmt.Sprintf("已刪除 %d 篇矩陣草稿", deleted)
- if len(req.DraftIDs) == 0 {
- message = fmt.Sprintf("已刪除全部 %d 篇矩陣草稿", deleted)
- }
- return &types.DeleteCopyMissionMatrixDraftsData{
- Deleted: deleted,
- Message: message,
- }, nil
-}
\ No newline at end of file
+func (l *DeleteCopyMissionMatrixDraftsLogic) DeleteCopyMissionMatrixDrafts(req *types.DeleteCopyMissionMatrixDraftsHandlerReq) (resp *types.DeleteCopyMissionMatrixDraftsData, err error) {
+ // todo: add your logic here and delete this line
+
+ return
+}
diff --git a/backend/internal/logic/copy_mission/expand_copy_mission_graph_logic.go b/backend/internal/logic/copy_mission/expand_copy_mission_graph_logic.go
index 0e7a121..e621f52 100644
--- a/backend/internal/logic/copy_mission/expand_copy_mission_graph_logic.go
+++ b/backend/internal/logic/copy_mission/expand_copy_mission_graph_logic.go
@@ -1,14 +1,11 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
- "strings"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libkg "haixun-backend/internal/library/knowledge"
- "haixun-backend/internal/library/placement"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@@ -29,68 +26,8 @@ func NewExpandCopyMissionGraphLogic(ctx context.Context, svcCtx *svc.ServiceCont
}
}
-func (l *ExpandCopyMissionGraphLogic) ExpandCopyMissionGraph(req *types.ExpandCopyMissionGraphHandlerReq) (*types.ExpandKnowledgeGraphData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.PersonaID)
- missionID := strings.TrimSpace(req.ID)
- seed := strings.TrimSpace(req.SeedQuery)
- if seed == "" {
- return nil, app.For(code.Persona).InputMissingRequired("seed_query is required")
- }
- mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID)
- if err != nil {
- return nil, err
- }
- if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" {
- return nil, app.For(code.Persona).InputMissingRequired("請先產生研究地圖再擴展延伸知識")
- }
- if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil {
- return nil, err
- }
+func (l *ExpandCopyMissionGraphLogic) ExpandCopyMissionGraph(req *types.ExpandCopyMissionGraphHandlerReq) (resp *types.ExpandKnowledgeGraphData, err error) {
+ // todo: add your logic here and delete this line
- research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- expandStrategy := placement.EffectiveExpandStrategy(research)
- if req.Supplemental && placement.WebSearchAvailable(research) {
- expandStrategy = libkg.ExpandStrategyBrave
- }
- memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
- if err != nil {
- return nil, err
- }
-
- payload := map[string]any{
- "tenant_id": tenantID,
- "owner_uid": uid,
- "persona_id": personaID,
- "copy_mission_id": missionID,
- "seed_query": seed,
- "supplemental": req.Supplemental,
- "expand_strategy": expandStrategy.String(),
- }
- for key, value := range memberCtx.PayloadFields() {
- payload[key] = value
- }
-
- run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
- TemplateType: "expand-copy-mission-graph",
- Scope: "copy_mission",
- ScopeID: missionID,
- TenantID: tenantID,
- OwnerUID: uid,
- Payload: payload,
- })
- if err != nil {
- return nil, err
- }
- return &types.ExpandKnowledgeGraphData{
- JobID: run.ID.Hex(),
- Status: string(run.Status),
- Message: "延伸知識擴展中,完成後可勾選節點用於產文",
- }, nil
-}
\ No newline at end of file
+ return
+}
diff --git a/backend/internal/logic/copy_mission/generate_copy_mission_matrix_logic.go b/backend/internal/logic/copy_mission/generate_copy_mission_matrix_logic.go
index 0971772..f949569 100644
--- a/backend/internal/logic/copy_mission/generate_copy_mission_matrix_logic.go
+++ b/backend/internal/logic/copy_mission/generate_copy_mission_matrix_logic.go
@@ -1,226 +1,33 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
- "fmt"
- "strings"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libcopy "haixun-backend/internal/library/copymission"
- libkg "haixun-backend/internal/library/knowledge"
- libmatrix "haixun-backend/internal/library/matrix"
- libweb "haixun-backend/internal/library/webpage"
- libprompt "haixun-backend/internal/library/prompt"
- "haixun-backend/internal/library/style8d"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity"
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
- missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
- scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
)
type GenerateCopyMissionMatrixLogic struct {
+ logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGenerateCopyMissionMatrixLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateCopyMissionMatrixLogic {
- return &GenerateCopyMissionMatrixLogic{ctx: ctx, svcCtx: svcCtx}
+ return &GenerateCopyMissionMatrixLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
}
-func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(
- req *types.GenerateCopyMissionMatrixHandlerReq,
-) (*types.GenerateCopyMissionMatrixData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.PersonaID)
- missionID := strings.TrimSpace(req.ID)
- mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID)
- if err != nil {
- return nil, err
- }
- if mission.Status != string(missionentity.StatusMapped) &&
- mission.Status != string(missionentity.StatusScanned) &&
- mission.Status != string(missionentity.StatusDrafted) {
- return nil, app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣")
- }
- if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" {
- return nil, app.For(code.Persona).InputMissingRequired("請先完成研究地圖")
- }
+func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(req *types.GenerateCopyMissionMatrixHandlerReq) (resp *types.GenerateCopyMissionMatrixData, err error) {
+ // todo: add your logic here and delete this line
- persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
- if err != nil {
- return nil, err
- }
- if err := ensureNoActiveMatrixJob(l.ctx, l.svcCtx, missionID); err != nil {
- return nil, err
- }
- if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
- return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析")
- }
- personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
-
- count := req.Count
- if count <= 0 {
- count = 5
- }
- if count > 12 {
- count = 12
- }
-
- posts, err := l.svcCtx.ScanPost.ListForPersona(l.ctx, scanpostdomain.PersonaListRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: personaID,
- CopyMissionID: missionID,
- Limit: 12,
- })
- if err != nil {
- return nil, err
- }
- samples := matrixSamplesFromScanPosts(posts)
- graphNodes, graphSources := loadMatrixGraph(l.ctx, l.svcCtx, tenantID, uid, missionID)
- knowledgeItems := libcopy.MatrixKnowledgeItems(
- mission.ResearchMap.SelectedKnowledgeItems,
- mission.ResearchMap.KnowledgeItems,
- graphNodes,
- mission.SelectedTags,
- )
- researchSources := libcopy.ResearchSourcesFromSummaries(mission.ResearchMap.ResearchItems)
- refURLs := libcopy.MergeReferenceURLs(
- libcopy.ReferenceURLsFromSelection(graphNodes, graphSources),
- researchSources,
- )
- researchItemsBlock := libcopy.FormatResearchItemsForMatrix(researchSources)
- referenceMarkdown := ""
- if len(refURLs) > 0 {
- digests := libweb.FetchDigests(l.ctx, refURLs, libweb.FetchOptions{})
- referenceMarkdown = libmatrix.BuildReferenceMarkdown(digests)
- }
- researchBlock := libmatrix.FormatCopyResearchMapBlockWithReferences(
- mission.ResearchMap.AudienceSummary,
- mission.ResearchMap.ContentGoal,
- mission.ResearchMap.Questions,
- mission.ResearchMap.Pillars,
- mission.ResearchMap.Exclusions,
- knowledgeItems,
- researchItemsBlock,
- referenceMarkdown,
- )
- userPrompt, err := libmatrix.BuildCopyUserPrompt(libmatrix.CopyGenerateInput{
- Count: count,
- TopicLabel: mission.Label,
- TopicBrief: mission.Brief,
- ResearchMap: researchBlock,
- SelectedTags: mission.SelectedTags,
- ViralSamples: samples,
- PersonaBlock: personaBlock,
- })
- if err != nil {
- return nil, app.For(code.AI).SysInternal("matrix user prompt load failed")
- }
- systemPrompt, err := libprompt.MatrixCopySystem()
- if err != nil {
- return nil, app.For(code.AI).SysInternal("matrix system prompt load failed")
- }
- credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return nil, err
- }
- parsed, err := libmatrix.GenerateCopyOutput(l.ctx, l.svcCtx.AI, domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: systemPrompt,
- Messages: []domai.Message{
- {Role: "user", Content: userPrompt},
- },
- }, count)
- if err != nil {
- return nil, app.For(code.AI).SvcThirdParty("內容矩陣 LLM 回傳無法解析:" + err.Error())
- }
-
- createReqs := make([]copydraftdomain.CreateRequest, 0, len(parsed.Rows))
- for _, row := range parsed.Rows {
- createReqs = append(createReqs, copydraftdomain.CreateRequest{
- CopyMissionID: missionID,
- DraftType: copydraftentity.DraftTypeMatrix,
- SortOrder: row.SortOrder,
- Text: row.Text,
- Angle: row.Angle,
- Hook: row.Hook,
- Rationale: row.Rationale,
- ReferenceNotes: row.ReferenceNotes,
- Sources: row.SourcePermalinks,
- })
- }
- saved, err := l.svcCtx.CopyDraft.ReplaceMissionMatrix(l.ctx, tenantID, uid, personaID, missionID, createReqs)
- if err != nil {
- return nil, err
- }
-
- drafted := missionentity.StatusDrafted
- _, _ = l.svcCtx.CopyMission.Update(l.ctx, missiondomain.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: personaID,
- MissionID: missionID,
- Patch: missiondomain.MissionPatch{
- Status: &drafted,
- },
- })
-
- drafts := make([]types.CopyDraftData, 0, len(saved))
- for _, item := range saved {
- drafts = append(drafts, toCopyDraftData(item))
- }
- return &types.GenerateCopyMissionMatrixData{
- Drafts: drafts,
- Message: fmt.Sprintf("已產出 %d 篇矩陣草稿", len(drafts)),
- }, nil
-}
-
-func loadMatrixGraph(ctx context.Context, svcCtx *svc.ServiceContext, tenantID, uid, missionID string) ([]libkg.Node, []libkg.BraveSource) {
- if svcCtx == nil || svcCtx.KnowledgeGraph == nil {
- return nil, nil
- }
- graph, err := svcCtx.KnowledgeGraph.GetByCopyMission(ctx, tenantID, uid, missionID)
- if err != nil || graph == nil {
- return nil, nil
- }
- return graph.Nodes, graph.BraveSources
-}
-
-func matrixSamplesFromScanPosts(posts []scanpostdomain.ScanPostSummary) string {
- samples := make([]libmatrix.ViralPostSample, 0, len(posts))
- for _, post := range posts {
- replies := make([]libmatrix.ViralReplySample, 0, len(post.Replies))
- for _, reply := range post.Replies {
- replies = append(replies, libmatrix.ViralReplySample{
- Author: reply.Author,
- Text: reply.Text,
- })
- }
- samples = append(samples, libmatrix.ViralPostSample{
- Author: post.Author,
- LikeCount: post.LikeCount,
- SearchTag: post.SearchTag,
- Text: post.Text,
- Replies: replies,
- })
- }
- return libmatrix.FormatViralSamples(samples)
+ return
}
diff --git a/backend/internal/logic/copy_mission/get_copy_mission_graph_logic.go b/backend/internal/logic/copy_mission/get_copy_mission_graph_logic.go
index c58d7eb..f833033 100644
--- a/backend/internal/logic/copy_mission/get_copy_mission_graph_logic.go
+++ b/backend/internal/logic/copy_mission/get_copy_mission_graph_logic.go
@@ -1,8 +1,10 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
- "strings"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@@ -24,20 +26,8 @@ func NewGetCopyMissionGraphLogic(ctx context.Context, svcCtx *svc.ServiceContext
}
}
-func (l *GetCopyMissionGraphLogic) GetCopyMissionGraph(req *types.CopyMissionPath) (*types.KnowledgeGraphData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.PersonaID)
- missionID := strings.TrimSpace(req.ID)
- if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil {
- return nil, err
- }
- graph, err := l.svcCtx.KnowledgeGraph.GetByCopyMission(l.ctx, tenantID, uid, missionID)
- if err != nil {
- return nil, err
- }
- data := toKnowledgeGraphData(graph)
- return &data, nil
-}
\ No newline at end of file
+func (l *GetCopyMissionGraphLogic) GetCopyMissionGraph(req *types.CopyMissionPath) (resp *types.KnowledgeGraphData, err error) {
+ // todo: add your logic here and delete this line
+
+ return
+}
diff --git a/backend/internal/logic/copy_mission/get_copy_mission_logic.go b/backend/internal/logic/copy_mission/get_copy_mission_logic.go
index 2bb8467..c0963a9 100644
--- a/backend/internal/logic/copy_mission/get_copy_mission_logic.go
+++ b/backend/internal/logic/copy_mission/get_copy_mission_logic.go
@@ -1,8 +1,10 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
- "strings"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@@ -24,15 +26,8 @@ func NewGetCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Ge
}
}
-func (l *GetCopyMissionLogic) GetCopyMission(req *types.CopyMissionPath) (*types.CopyMissionData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, strings.TrimSpace(req.PersonaID), strings.TrimSpace(req.ID))
- if err != nil {
- return nil, err
- }
- data := toCopyMissionData(*item)
- return &data, nil
+func (l *GetCopyMissionLogic) GetCopyMission(req *types.CopyMissionPath) (resp *types.CopyMissionData, err error) {
+ // todo: add your logic here and delete this line
+
+ return
}
diff --git a/backend/internal/logic/copy_mission/get_copy_mission_scan_schedule_logic.go b/backend/internal/logic/copy_mission/get_copy_mission_scan_schedule_logic.go
index 4b23184..42d3c9e 100644
--- a/backend/internal/logic/copy_mission/get_copy_mission_scan_schedule_logic.go
+++ b/backend/internal/logic/copy_mission/get_copy_mission_scan_schedule_logic.go
@@ -1,40 +1,33 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
- "strings"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
)
type GetCopyMissionScanScheduleLogic struct {
+ logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetCopyMissionScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCopyMissionScanScheduleLogic {
- return &GetCopyMissionScanScheduleLogic{ctx: ctx, svcCtx: svcCtx}
+ return &GetCopyMissionScanScheduleLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
}
-func (l *GetCopyMissionScanScheduleLogic) GetCopyMissionScanSchedule(
- req *types.CopyMissionPath,
-) (*types.CopyMissionScanScheduleData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.PersonaID)
- missionID := strings.TrimSpace(req.ID)
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
- return nil, err
- }
- if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil {
- return nil, err
- }
- schedule, err := findCopyMissionScanSchedule(l.ctx, l.svcCtx, missionID)
- if err != nil {
- return nil, err
- }
- return toCopyMissionScanScheduleData(schedule, personaID, missionID), nil
+func (l *GetCopyMissionScanScheduleLogic) GetCopyMissionScanSchedule(req *types.CopyMissionPath) (resp *types.CopyMissionScanScheduleData, err error) {
+ // todo: add your logic here and delete this line
+
+ return
}
diff --git a/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go b/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go
index c3d5fef..98fe6ea 100644
--- a/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go
+++ b/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go
@@ -1,8 +1,12 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
"strings"
+ "time"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
@@ -12,91 +16,41 @@ import (
"haixun-backend/internal/library/websearch"
domai "haixun-backend/internal/model/ai/domain/usecase"
aiusecase "haixun-backend/internal/model/ai/usecase"
+ contentops "haixun-backend/internal/model/content_ops/domain/usecase"
+ personadomain "haixun-backend/internal/model/persona/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
)
type InspireCopyMissionLogic struct {
+ logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewInspireCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *InspireCopyMissionLogic {
- return &InspireCopyMissionLogic{ctx: ctx, svcCtx: svcCtx}
+ return &InspireCopyMissionLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
}
-func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspirationHandlerReq) (*types.CopyMissionInspirationData, error) {
+func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspirationHandlerReq) (resp *types.CopyMissionInspirationData, err error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
personaID := strings.TrimSpace(req.PersonaID)
- userKeyword := strings.TrimSpace(req.Keyword)
+ if personaID == "" {
+ return nil, app.For(code.Persona).InputMissingRequired("persona_id is required")
+ }
persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
if err != nil {
return nil, err
}
-
- missionList, err := l.svcCtx.CopyMission.List(l.ctx, tenantID, uid, personaID)
- if err != nil {
- return nil, err
- }
- recentLabels := make([]string, 0, 8)
- recentSeeds := make([]string, 0, 8)
- for _, mission := range missionList.List {
- if mission.Status == "archived" {
- continue
- }
- if label := strings.TrimSpace(mission.Label); label != "" {
- recentLabels = append(recentLabels, label)
- }
- if seed := strings.TrimSpace(mission.SeedQuery); seed != "" {
- recentSeeds = append(recentSeeds, seed)
- }
- if len(recentLabels) >= 8 && len(recentSeeds) >= 8 {
- break
- }
- }
-
- trendSnippets := []libviral.MissionInspireTrendSnippet{}
- webSearchProvider := ""
- trendSourceLabel := ""
- llmOnly := true
-
- research, researchErr := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
- if researchErr == nil {
- memberCtx, memberErr := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
- if memberErr == nil {
- trendSnippets = libviral.CollectMissionInspireThreadsTrends(l.ctx, libviral.MissionInspireThreadsInput{
- Keyword: userKeyword,
- PersonaBrief: persona.Brief,
- StyleBenchmark: persona.StyleBenchmark,
- Pillars: append([]string(nil), persona.CopyResearchMap.Pillars...),
- Questions: append([]string(nil), persona.CopyResearchMap.Questions...),
- Member: memberCtx,
- Crawler: l.makeCrawlerSearchFn(tenantID, uid),
- })
- if len(trendSnippets) > 0 {
- trendSourceLabel = memberCtx.DiscoverPathLabel()
- llmOnly = false
- }
- if len(trendSnippets) == 0 && placement.WebSearchAvailable(research) {
- webClient := websearch.New(memberCtx.WebSearchConfig())
- trendSnippets = libviral.CollectMissionInspireTrends(
- l.ctx,
- webClient,
- memberCtx,
- persona.Brief+" "+userKeyword,
- persona.StyleBenchmark,
- )
- webSearchProvider = memberCtx.WebSearchProviderLabel()
- trendSourceLabel = webSearchProvider
- llmOnly = len(trendSnippets) == 0
- }
- }
- }
- webSearchUsed := len(trendSnippets) > 0 && webSearchProvider != ""
-
credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
if err != nil {
return nil, err
@@ -106,47 +60,280 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspi
return nil, err
}
+ keyword := strings.TrimSpace(req.Keyword)
+ trends, sources, searchUsed := []libviral.MissionInspireTrendSnippet(nil), []types.CopyMissionInspirationSourceData(nil), false
+ if req.UseWebSearch {
+ trends, sources, searchUsed = l.collectTrendSignals(tenantID, uid, persona, keyword)
+ }
+ llmOnly := len(trends) == 0
+ if keyword != "" && llmOnly {
+ trends = append(trends, libviral.MissionInspireTrendSnippet{
+ Query: keyword,
+ Title: "使用者指定方向",
+ Snippet: keyword,
+ })
+ }
+
personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
+ temp := 0.72
result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: libviral.BuildMissionInspireSystemPrompt(),
- Messages: []domai.Message{
- {
- Role: "user",
- Content: libviral.BuildMissionInspireUserPrompt(libviral.MissionInspireInput{
- PersonaDisplayName: persona.DisplayName,
- PersonaBrief: persona.Brief,
- PersonaBlock: personaBlock,
- StyleBenchmark: persona.StyleBenchmark,
- PersonaAudience: persona.CopyResearchMap.AudienceSummary,
- PersonaContentGoal: persona.CopyResearchMap.ContentGoal,
- PersonaQuestions: append([]string(nil), persona.CopyResearchMap.Questions...),
- PersonaPillars: append([]string(nil), persona.CopyResearchMap.Pillars...),
- RecentMissionLabels: recentLabels,
- RecentSeedQueries: recentSeeds,
- UserKeyword: userKeyword,
- TrendSnippets: trendSnippets,
- WebSearchProvider: trendSourceLabel,
- LLMOnly: llmOnly,
- }),
- },
- },
+ Provider: providerID,
+ Model: credential.Model,
+ Credential: domai.Credential{APIKey: credential.APIKey},
+ System: libviral.BuildMissionInspireSystemPrompt(),
+ Temperature: &temp,
+ Messages: []domai.Message{{Role: "user", Content: libviral.BuildMissionInspireUserPrompt(libviral.MissionInspireInput{
+ PersonaDisplayName: persona.DisplayName,
+ PersonaBrief: persona.Brief,
+ PersonaBlock: personaBlock,
+ StyleBenchmark: persona.StyleBenchmark,
+ AvoidTopics: cleanAvoidTopics(req.AvoidTopics),
+ ContentDirection: req.ContentDirection,
+ UserKeyword: keyword,
+ TrendSnippets: trends,
+ WebSearchProvider: sourceLabel(searchUsed, sources),
+ LLMOnly: llmOnly,
+ })}},
})
if err != nil {
- return nil, err
+ return nil, app.For(code.AI).SvcThirdParty("起號靈感生成失敗:" + err.Error())
}
-
parsed, err := libviral.ParseMissionInspireOutput(result.Text)
if err != nil {
- return nil, app.For(code.AI).SvcThirdParty("靈感骰子 LLM 回傳無法解析:" + err.Error())
+ return nil, app.For(code.AI).SvcThirdParty("起號靈感回傳無法解析:" + err.Error())
}
- sources := make([]types.CopyMissionInspirationSourceData, 0, len(trendSnippets))
- for _, item := range trendSnippets {
+ topicCandidate, plan := l.persistInspiration(tenantID, uid, personaID, parsed, searchUsed)
+ return &types.CopyMissionInspirationData{
+ TopicCandidateID: topicCandidate,
+ ContentPlanID: plan,
+ Label: parsed.Label,
+ SeedQuery: parsed.SeedQuery,
+ Brief: parsed.Brief,
+ TrendReason: parsed.TrendReason,
+ TrendKeywords: parsed.TrendKeywords,
+ Angles: parsed.Angles,
+ Mission: parsed.Mission,
+ TargetAudience: parsed.TargetAudience,
+ OpeningType: parsed.OpeningType,
+ BodyType: parsed.BodyType,
+ Emotion: parsed.Emotion,
+ CtaType: parsed.CtaType,
+ RiskLevel: parsed.RiskLevel,
+ Avoid: parsed.Avoid,
+ Sources: sources,
+ WebSearchUsed: searchUsed,
+ Message: "已找到一個可延伸的話題與內容計畫",
+ }, nil
+}
+
+func (l *InspireCopyMissionLogic) persistInspiration(tenantID, uid, personaID string, parsed libviral.MissionInspireOutput, searchUsed bool) (string, string) {
+ if l.svcCtx.ContentOps == nil {
+ return "", ""
+ }
+ riskScore := riskScoreFromLevel(parsed.RiskLevel)
+ category := parsed.Mission
+ if category == "" {
+ category = "候選話題"
+ }
+ source := "llm"
+ if searchUsed {
+ source = "topic_radar"
+ }
+ topic, err := l.svcCtx.ContentOps.CreateTopicCandidate(l.ctx, contentops.TopicCandidateInput{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ PersonaID: personaID,
+ Name: parsed.Label,
+ Source: source,
+ Category: category,
+ SeedQuery: parsed.SeedQuery,
+ TrendReason: parsed.TrendReason,
+ TrendKeywords: parsed.TrendKeywords,
+ HeatScore: scoreBySearch(searchUsed),
+ FitScore: 82,
+ InteractionScore: 70,
+ ExtendScore: 78,
+ RiskScore: riskScore,
+ RecommendedMissions: []string{parsed.Mission},
+ })
+ if err != nil {
+ l.Errorf("persist topic candidate: %v", err)
+ return "", ""
+ }
+ angle := ""
+ if len(parsed.Angles) > 0 {
+ angle = parsed.Angles[0]
+ }
+ plan, err := l.svcCtx.ContentOps.CreateContentPlan(l.ctx, contentops.ContentPlanInput{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ PersonaID: personaID,
+ TopicCandidateID: topic.ID,
+ Topic: parsed.Label,
+ Mission: fallbackString(parsed.Mission, "互動文"),
+ TargetAudience: parsed.TargetAudience,
+ Angle: angle,
+ OpeningType: parsed.OpeningType,
+ BodyType: parsed.BodyType,
+ Emotion: parsed.Emotion,
+ CtaType: parsed.CtaType,
+ RiskLevel: parsed.RiskLevel,
+ RequiresHumanReview: parsed.RiskLevel != "low",
+ Avoid: parsed.Avoid,
+ })
+ if err != nil {
+ l.Errorf("persist content plan: %v", err)
+ return topic.ID, ""
+ }
+ return topic.ID, plan.ID
+}
+
+func scoreBySearch(searchUsed bool) int {
+ if searchUsed {
+ return 76
+ }
+ return 55
+}
+
+func riskScoreFromLevel(level string) int {
+ switch strings.ToLower(strings.TrimSpace(level)) {
+ case "high":
+ return 75
+ case "low":
+ return 20
+ default:
+ return 45
+ }
+}
+
+func fallbackString(value, fallback string) string {
+ if strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ return fallback
+}
+
+func (l *InspireCopyMissionLogic) collectTrendSignals(tenantID, uid string, persona *personadomain.PersonaSummary, keyword string) ([]libviral.MissionInspireTrendSnippet, []types.CopyMissionInspirationSourceData, bool) {
+ if persona == nil {
+ return nil, nil, false
+ }
+ trends := make([]libviral.MissionInspireTrendSnippet, 0, 12)
+ seen := map[string]struct{}{}
+ addTrend := func(item libviral.MissionInspireTrendSnippet) {
+ item.Query = strings.TrimSpace(item.Query)
+ item.Title = strings.TrimSpace(item.Title)
+ item.Snippet = strings.TrimSpace(item.Snippet)
+ item.URL = strings.TrimSpace(item.URL)
+ if item.Title == "" && item.Snippet == "" {
+ return
+ }
+ if isNewsLikeInspiration(item.Title, item.Snippet, item.URL) {
+ return
+ }
+ key := strings.ToLower(item.Query + "|" + item.Title + "|" + item.Snippet)
+ if _, ok := seen[key]; ok {
+ return
+ }
+ seen[key] = struct{}{}
+ trends = append(trends, item)
+ }
+
+ for _, item := range libviral.CollectFreeTopicTrends(l.ctx, keyword, persona.Brief) {
+ addTrend(item)
+ }
+
+ research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
+ if err != nil {
+ sources := inspirationSourcesFromTrends(trends)
+ return trends, sources, len(sources) > 0
+ }
+ memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
+ if err != nil {
+ sources := inspirationSourcesFromTrends(trends)
+ return trends, sources, len(sources) > 0
+ }
+
+ if placement.WebSearchAvailable(research) {
+ client := websearch.New(memberCtx.WebSearchConfig())
+ for _, item := range libviral.CollectMissionInspireTrends(l.ctx, client, memberCtx, strings.TrimSpace(persona.Brief+" "+keyword), persona.StyleBenchmark) {
+ addTrend(item)
+ }
+ if keyword != "" && client.Enabled() {
+ for _, query := range []string{
+ "Threads 台灣 " + keyword + " 熱門 關鍵字 討論",
+ keyword + " 請問 心得 推薦 Threads",
+ keyword + " 痛點 經驗 怎麼辦 社群",
+ keyword + " Dcard PTT Threads 熱門 討論",
+ } {
+ res, searchErr := client.Search(l.ctx, websearch.SearchOptions{
+ Query: query,
+ Limit: 4,
+ Mode: websearch.ModeKnowledgeExpand,
+ Country: memberCtx.BraveCountry,
+ SearchLang: memberCtx.BraveSearchLang,
+ UserLocation: memberCtx.ExaUserLocation,
+ StartPublishedDate: placement.FormatPublishedAfterISO(14, time.Now().UTC()),
+ })
+ if searchErr != nil || res.Status != "success" {
+ continue
+ }
+ for _, result := range res.Results {
+ addTrend(libviral.MissionInspireTrendSnippet{
+ Query: query,
+ Title: result.Title,
+ Snippet: result.Snippet,
+ URL: result.URL,
+ })
+ }
+ }
+ }
+ }
+
+ if keyword != "" && len(trends) < 8 {
+ for _, item := range libviral.CollectMissionInspireThreadsTrends(l.ctx, libviral.MissionInspireThreadsInput{
+ Keyword: keyword,
+ PersonaBrief: persona.Brief,
+ StyleBenchmark: persona.StyleBenchmark,
+ Pillars: persona.CopyResearchMap.Pillars,
+ Questions: persona.CopyResearchMap.Questions,
+ Member: memberCtx,
+ }) {
+ addTrend(item)
+ }
+ }
+
+ if len(trends) > 10 {
+ trends = trends[:10]
+ }
+ sources := inspirationSourcesFromTrends(trends)
+ return trends, sources, len(sources) > 0
+}
+
+func isNewsLikeInspiration(title, snippet, url string) bool {
+ text := strings.ToLower(title + " " + snippet + " " + url)
+ newsSignals := []string{"新聞", "快訊", "中央社", "聯合新聞", "ettoday", "tvbs", "setn", "ctwant", "chinatimes", "ltn.com", "news"}
+ socialSignals := []string{"threads", "dcard", "ptt", "請問", "心得", "推薦", "有人", "怎麼辦", "討論", "留言"}
+ newsScore := 0
+ for _, signal := range newsSignals {
+ if strings.Contains(text, strings.ToLower(signal)) {
+ newsScore++
+ }
+ }
+ if newsScore == 0 {
+ return false
+ }
+ for _, signal := range socialSignals {
+ if strings.Contains(text, strings.ToLower(signal)) {
+ return false
+ }
+ }
+ return true
+}
+
+func inspirationSourcesFromTrends(trends []libviral.MissionInspireTrendSnippet) []types.CopyMissionInspirationSourceData {
+ sources := make([]types.CopyMissionInspirationSourceData, 0, len(trends))
+ for _, item := range trends {
sources = append(sources, types.CopyMissionInspirationSourceData{
Query: item.Query,
Title: item.Title,
@@ -154,37 +341,33 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspi
URL: item.URL,
})
}
-
- message := "已依近期 Threads 熱門素材產出任務靈感,可微調後建立任務"
- if webSearchUsed {
- message = "已依近期網路趨勢產出任務靈感,可微調後建立任務"
- }
- if len(trendSnippets) == 0 {
- message = "未取得外部趨勢素材,已改由 LLM 依人設與關鍵字推測靈感;同步 Chrome Session、完成 Threads API 或補上 Brave/Exa key 可更貼近熱搜"
- }
-
- return &types.CopyMissionInspirationData{
- Label: parsed.Label,
- SeedQuery: parsed.SeedQuery,
- Brief: parsed.Brief,
- TrendReason: parsed.TrendReason,
- TrendKeywords: parsed.TrendKeywords,
- Sources: sources,
- WebSearchUsed: webSearchUsed,
- Message: message,
- }, nil
+ return sources
}
-func (l *InspireCopyMissionLogic) makeCrawlerSearchFn(tenantID, ownerUID string) placement.CrawlerSearchFn {
- return func(ctx context.Context, member placement.MemberContext, keyword string, limit int) ([]placement.DiscoverPost, error) {
- accountID := strings.TrimSpace(member.ActiveAccountID)
- if accountID == "" {
- return nil, app.For(code.Setting).InputMissingRequired("請先選定經營帳號並同步 Chrome Session")
- }
- session, err := l.svcCtx.ThreadsAccount.GetBrowserSession(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- return placement.RunExecCrawlerSearch(ctx, session.StorageState, keyword, limit)
+func sourceLabel(used bool, sources []types.CopyMissionInspirationSourceData) string {
+ if !used || len(sources) == 0 {
+ return ""
}
+ return "Free Trend / Web / Threads Search"
+}
+
+func cleanAvoidTopics(items []string) []string {
+ out := make([]string, 0, len(items))
+ seen := map[string]struct{}{}
+ for _, item := range items {
+ item = strings.TrimSpace(item)
+ if item == "" {
+ continue
+ }
+ key := strings.ToLower(item)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ out = append(out, item)
+ if len(out) >= 8 {
+ break
+ }
+ }
+ return out
}
diff --git a/backend/internal/logic/copy_mission/knowledge_graph_mapper.go b/backend/internal/logic/copy_mission/knowledge_graph_mapper.go
deleted file mode 100644
index dfb98c3..0000000
--- a/backend/internal/logic/copy_mission/knowledge_graph_mapper.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package copy_mission
-
-import (
- kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase"
- "haixun-backend/internal/types"
-)
-
-func toKnowledgeGraphData(graph *kgusecase.GraphSummary) types.KnowledgeGraphData {
- if graph == nil {
- return types.KnowledgeGraphData{}
- }
- nodes := make([]types.KnowledgeGraphNodeData, 0, len(graph.Nodes))
- for _, node := range graph.Nodes {
- evidence := make([]types.KnowledgeGraphEvidenceData, 0, len(node.Evidence))
- for _, item := range node.Evidence {
- evidence = append(evidence, types.KnowledgeGraphEvidenceData{
- URL: item.URL,
- Snippet: item.Snippet,
- Query: item.Query,
- })
- }
- nodes = append(nodes, types.KnowledgeGraphNodeData{
- ID: node.ID,
- Label: node.Label,
- NodeKind: node.NodeKind,
- Type: node.Type,
- Layer: node.Layer,
- Relation: node.Relation,
- PlacementValue: node.PlacementValue,
- ProductFitScore: node.ProductFitScore,
- SelectedForScan: node.SelectedForScan,
- RelevanceTags: append([]string{}, node.DerivedTags.Relevance...),
- RecencyTags: append([]string{}, node.DerivedTags.Recency...),
- Evidence: evidence,
- })
- }
- edges := make([]types.KnowledgeGraphEdgeData, 0, len(graph.Edges))
- for _, edge := range graph.Edges {
- edges = append(edges, types.KnowledgeGraphEdgeData{
- From: edge.From,
- To: edge.To,
- Relation: edge.Relation,
- })
- }
- sources := make([]types.BraveSourceData, 0, len(graph.BraveSources))
- for _, src := range graph.BraveSources {
- sources = append(sources, types.BraveSourceData{
- Query: src.Query,
- Title: src.Title,
- URL: src.URL,
- Snippet: src.Snippet,
- })
- }
- return types.KnowledgeGraphData{
- ID: graph.ID,
- BrandID: graph.BrandID,
- Seed: graph.Seed,
- Nodes: nodes,
- Edges: edges,
- BraveSources: sources,
- ExpandStrategy: graph.ExpandStrategy,
- PainTagCount: graph.PainTagCount,
- GeneratedAt: graph.GeneratedAt,
- CreateAt: graph.CreateAt,
- UpdateAt: graph.UpdateAt,
- }
-}
\ No newline at end of file
diff --git a/backend/internal/logic/copy_mission/list_copy_mission_copy_drafts_logic.go b/backend/internal/logic/copy_mission/list_copy_mission_copy_drafts_logic.go
index 7acba5e..2057700 100644
--- a/backend/internal/logic/copy_mission/list_copy_mission_copy_drafts_logic.go
+++ b/backend/internal/logic/copy_mission/list_copy_mission_copy_drafts_logic.go
@@ -1,40 +1,33 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
- "strings"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
)
type ListCopyMissionCopyDraftsLogic struct {
+ logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListCopyMissionCopyDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCopyMissionCopyDraftsLogic {
- return &ListCopyMissionCopyDraftsLogic{ctx: ctx, svcCtx: svcCtx}
+ return &ListCopyMissionCopyDraftsLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
}
-func (l *ListCopyMissionCopyDraftsLogic) ListCopyMissionCopyDrafts(req *types.CopyMissionPath) (*types.ListCopyMissionCopyDraftsData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.PersonaID)
- missionID := strings.TrimSpace(req.ID)
- if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil {
- return nil, err
- }
+func (l *ListCopyMissionCopyDraftsLogic) ListCopyMissionCopyDrafts(req *types.CopyMissionPath) (resp *types.ListCopyMissionCopyDraftsData, err error) {
+ // todo: add your logic here and delete this line
- drafts, err := l.svcCtx.CopyDraft.ListByMission(l.ctx, tenantID, uid, personaID, missionID, 50)
- if err != nil {
- return nil, err
- }
- list := make([]types.CopyDraftData, 0, len(drafts))
- for _, item := range drafts {
- list = append(list, toCopyDraftData(item))
- }
- return &types.ListCopyMissionCopyDraftsData{List: list, Total: len(list)}, nil
+ return
}
diff --git a/backend/internal/logic/copy_mission/list_copy_mission_scan_posts_logic.go b/backend/internal/logic/copy_mission/list_copy_mission_scan_posts_logic.go
index 6281c70..b21ffcb 100644
--- a/backend/internal/logic/copy_mission/list_copy_mission_scan_posts_logic.go
+++ b/backend/internal/logic/copy_mission/list_copy_mission_scan_posts_logic.go
@@ -1,49 +1,33 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
- "strings"
- scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
)
type ListCopyMissionScanPostsLogic struct {
+ logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListCopyMissionScanPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCopyMissionScanPostsLogic {
- return &ListCopyMissionScanPostsLogic{ctx: ctx, svcCtx: svcCtx}
+ return &ListCopyMissionScanPostsLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
}
-func (l *ListCopyMissionScanPostsLogic) ListCopyMissionScanPosts(
- req *types.ListCopyMissionScanPostsHandlerReq,
-) (*types.ListPersonaViralScanPostsData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.PersonaID)
- missionID := strings.TrimSpace(req.ID)
- if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil {
- return nil, err
- }
+func (l *ListCopyMissionScanPostsLogic) ListCopyMissionScanPosts(req *types.ListCopyMissionScanPostsHandlerReq) (resp *types.ListPersonaViralScanPostsData, err error) {
+ // todo: add your logic here and delete this line
- limit := 100
- if req.Limit > 0 {
- limit = req.Limit
- }
- posts, err := l.svcCtx.ScanPost.ListForPersona(l.ctx, scanpostdomain.PersonaListRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: personaID,
- CopyMissionID: missionID,
- Limit: limit,
- })
- if err != nil {
- return nil, err
- }
- return toViralScanPostsData(posts), nil
+ return
}
diff --git a/backend/internal/logic/copy_mission/list_copy_missions_logic.go b/backend/internal/logic/copy_mission/list_copy_missions_logic.go
index f99b9f0..661225f 100644
--- a/backend/internal/logic/copy_mission/list_copy_missions_logic.go
+++ b/backend/internal/logic/copy_mission/list_copy_missions_logic.go
@@ -1,8 +1,10 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
- "strings"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@@ -24,18 +26,8 @@ func NewListCopyMissionsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *
}
}
-func (l *ListCopyMissionsLogic) ListCopyMissions(req *types.PersonaCopyMissionsPath) (*types.ListCopyMissionsData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.PersonaID)
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
- return nil, err
- }
- result, err := l.svcCtx.CopyMission.List(l.ctx, tenantID, uid, personaID)
- if err != nil {
- return nil, err
- }
- return toListData(result), nil
+func (l *ListCopyMissionsLogic) ListCopyMissions(req *types.PersonaCopyMissionsPath) (resp *types.ListCopyMissionsData, err error) {
+ // todo: add your logic here and delete this line
+
+ return
}
diff --git a/backend/internal/logic/copy_mission/mapper.go b/backend/internal/logic/copy_mission/mapper.go
deleted file mode 100644
index 8812738..0000000
--- a/backend/internal/logic/copy_mission/mapper.go
+++ /dev/null
@@ -1,299 +0,0 @@
-package copy_mission
-
-import (
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
- missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
- scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase"
- "haixun-backend/internal/types"
-)
-
-func seedMissionResearchFromPersona(persona personadomain.PersonaSummary) (*missionentity.ResearchMap, []string) {
- src := persona.CopyResearchMap
- if src.AudienceSummary == "" && src.ContentGoal == "" && len(src.SuggestedTags) == 0 {
- return nil, nil
- }
- tags := make([]missionentity.SuggestedTag, 0, len(src.SuggestedTags))
- for _, tag := range src.SuggestedTags {
- if tag == "" {
- continue
- }
- tags = append(tags, missionentity.SuggestedTag{Tag: tag})
- }
- selected := append([]string(nil), src.SuggestedTags...)
- if len(selected) > 6 {
- selected = selected[:6]
- }
- rm := &missionentity.ResearchMap{
- AudienceSummary: src.AudienceSummary,
- ContentGoal: src.ContentGoal,
- Questions: append([]string(nil), src.Questions...),
- Pillars: append([]string(nil), src.Pillars...),
- Exclusions: append([]string(nil), src.Exclusions...),
- SuggestedTags: tags,
- BenchmarkNotes: src.BenchmarkNotes,
- }
- return rm, selected
-}
-
-func toMissionPatch(req *types.UpdateCopyMissionReq) missiondomain.MissionPatch {
- if req == nil {
- return missiondomain.MissionPatch{}
- }
- patch := missiondomain.MissionPatch{
- Label: req.Label,
- SeedQuery: req.SeedQuery,
- Brief: req.Brief,
- }
- if req.AudienceSummary != nil {
- patch.AudienceSummary = req.AudienceSummary
- }
- if req.ContentGoal != nil {
- patch.ContentGoal = req.ContentGoal
- }
- if req.Questions != nil {
- patch.QuestionsSet = true
- patch.Questions = append([]string(nil), req.Questions...)
- }
- if req.Pillars != nil {
- patch.PillarsSet = true
- patch.Pillars = append([]string(nil), req.Pillars...)
- }
- if req.Exclusions != nil {
- patch.ExclusionsSet = true
- patch.Exclusions = append([]string(nil), req.Exclusions...)
- }
- if req.KnowledgeItems != nil {
- patch.KnowledgeItemsSet = true
- patch.KnowledgeItems = append([]string(nil), req.KnowledgeItems...)
- }
- if req.SelectedKnowledgeItems != nil {
- patch.SelectedKnowledgeItemsSet = true
- patch.SelectedKnowledgeItems = append([]string(nil), req.SelectedKnowledgeItems...)
- }
- if req.BenchmarkNotes != nil {
- patch.BenchmarkNotes = req.BenchmarkNotes
- }
- if req.SelectedTags != nil {
- patch.SelectedTagsSet = true
- patch.SelectedTags = append([]string(nil), req.SelectedTags...)
- }
- return patch
-}
-
-func toResearchItemData(items []missiondomain.ResearchItemSummary) []types.CopyMissionResearchItemData {
- out := make([]types.CopyMissionResearchItemData, 0, len(items))
- for _, item := range items {
- out = append(out, types.CopyMissionResearchItemData{
- Title: item.Title,
- URL: item.URL,
- Snippet: item.Snippet,
- Query: item.Query,
- })
- }
- return out
-}
-
-func toCopyMissionData(item missiondomain.MissionSummary) types.CopyMissionData {
- tags := make([]types.CopySuggestedTagData, 0, len(item.ResearchMap.SuggestedTags))
- for _, tag := range item.ResearchMap.SuggestedTags {
- tags = append(tags, types.CopySuggestedTagData{
- Tag: tag.Tag,
- Reason: tag.Reason,
- SearchIntent: tag.SearchIntent,
- SearchType: tag.SearchType,
- })
- }
- accounts := make([]types.CopySimilarAccountData, 0, len(item.ResearchMap.SimilarAccounts))
- for _, acc := range item.ResearchMap.SimilarAccounts {
- accounts = append(accounts, types.CopySimilarAccountData{
- Username: acc.Username,
- Reason: acc.Reason,
- Source: acc.Source,
- MatchedSource: append([]string(nil), acc.MatchedSource...),
- Confidence: acc.Confidence,
- Status: acc.Status,
- TopicRelevance: acc.TopicRelevance,
- LastSeenAt: acc.LastSeenAt,
- ProfileUrl: acc.ProfileURL,
- AuthorVerified: acc.AuthorVerified,
- FollowerCount: acc.FollowerCount,
- EngagementScore: acc.EngagementScore,
- LikeCount: acc.LikeCount,
- ReplyCount: acc.ReplyCount,
- PostCount: acc.PostCount,
- })
- }
- samples := make([]types.CopyAudienceSampleData, 0, len(item.ResearchMap.AudienceSamples))
- for _, sample := range item.ResearchMap.AudienceSamples {
- samples = append(samples, types.CopyAudienceSampleData{
- Username: sample.Username,
- SamplePostId: sample.SamplePostID,
- SampleText: sample.SampleText,
- ReplyLikeCount: sample.ReplyLikeCount,
- Appearances: sample.Appearances,
- FirstSeenAt: sample.FirstSeenAt,
- LastSeenAt: sample.LastSeenAt,
- Status: sample.Status,
- })
- }
- return types.CopyMissionData{
- ID: item.ID,
- PersonaID: item.PersonaID,
- Label: item.Label,
- SeedQuery: item.SeedQuery,
- Brief: item.Brief,
- ResearchMap: types.CopyMissionResearchMapData{
- AudienceSummary: item.ResearchMap.AudienceSummary,
- ContentGoal: item.ResearchMap.ContentGoal,
- Questions: append([]string(nil), item.ResearchMap.Questions...),
- Pillars: append([]string(nil), item.ResearchMap.Pillars...),
- Exclusions: append([]string(nil), item.ResearchMap.Exclusions...),
- KnowledgeItems: append([]string(nil), item.ResearchMap.KnowledgeItems...),
- SelectedKnowledgeItems: append([]string(nil), item.ResearchMap.SelectedKnowledgeItems...),
- ResearchItems: toResearchItemData(item.ResearchMap.ResearchItems),
- SuggestedTags: tags,
- SimilarAccounts: accounts,
- AudienceSamples: samples,
- BenchmarkNotes: item.ResearchMap.BenchmarkNotes,
- },
- SelectedTags: append([]string(nil), item.SelectedTags...),
- LastScanJobID: item.LastScanJobID,
- Status: item.Status,
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
- }
-}
-
-func toListData(result *missiondomain.ListResult) *types.ListCopyMissionsData {
- if result == nil {
- return &types.ListCopyMissionsData{List: []types.CopyMissionData{}}
- }
- list := make([]types.CopyMissionData, 0, len(result.List))
- for _, item := range result.List {
- list = append(list, toCopyMissionData(item))
- }
- return &types.ListCopyMissionsData{List: list}
-}
-
-func toEntityResearchMap(data types.CopyMissionResearchMapData) missionentity.ResearchMap {
- tags := make([]missionentity.SuggestedTag, 0, len(data.SuggestedTags))
- for _, tag := range data.SuggestedTags {
- tags = append(tags, missionentity.SuggestedTag{
- Tag: tag.Tag,
- Reason: tag.Reason,
- SearchIntent: tag.SearchIntent,
- SearchType: tag.SearchType,
- })
- }
- accounts := make([]missionentity.SimilarAccount, 0, len(data.SimilarAccounts))
- for _, acc := range data.SimilarAccounts {
- accounts = append(accounts, missionentity.SimilarAccount{
- Username: acc.Username,
- Reason: acc.Reason,
- Source: acc.Source,
- MatchedSource: append([]string(nil), acc.MatchedSource...),
- Confidence: acc.Confidence,
- Status: acc.Status,
- TopicRelevance: acc.TopicRelevance,
- LastSeenAt: acc.LastSeenAt,
- ProfileURL: acc.ProfileUrl,
- AuthorVerified: acc.AuthorVerified,
- FollowerCount: acc.FollowerCount,
- EngagementScore: acc.EngagementScore,
- LikeCount: acc.LikeCount,
- ReplyCount: acc.ReplyCount,
- PostCount: acc.PostCount,
- })
- }
- samples := make([]missionentity.AudienceSample, 0, len(data.AudienceSamples))
- for _, sample := range data.AudienceSamples {
- samples = append(samples, missionentity.AudienceSample{
- Username: sample.Username,
- SamplePostID: sample.SamplePostId,
- SampleText: sample.SampleText,
- ReplyLikeCount: sample.ReplyLikeCount,
- Appearances: sample.Appearances,
- FirstSeenAt: sample.FirstSeenAt,
- LastSeenAt: sample.LastSeenAt,
- Status: sample.Status,
- })
- }
- return missionentity.ResearchMap{
- AudienceSummary: data.AudienceSummary,
- ContentGoal: data.ContentGoal,
- Questions: append([]string(nil), data.Questions...),
- Pillars: append([]string(nil), data.Pillars...),
- Exclusions: append([]string(nil), data.Exclusions...),
- KnowledgeItems: append([]string(nil), data.KnowledgeItems...),
- SelectedKnowledgeItems: append([]string(nil), data.SelectedKnowledgeItems...),
- SuggestedTags: tags,
- SimilarAccounts: accounts,
- AudienceSamples: samples,
- BenchmarkNotes: data.BenchmarkNotes,
- }
-}
-
-func toViralScanPostsData(posts []scanpostdomain.ScanPostSummary) *types.ListPersonaViralScanPostsData {
- list := make([]types.ViralScanPostData, 0, len(posts))
- for _, post := range posts {
- list = append(list, types.ViralScanPostData{
- ID: post.ID,
- SearchTag: post.SearchTag,
- Permalink: post.Permalink,
- Author: post.Author,
- AuthorVerified: post.AuthorVerified,
- FollowerCount: post.FollowerCount,
- Text: post.Text,
- LikeCount: post.LikeCount,
- ReplyCount: post.ReplyCount,
- EngagementScore: post.EngagementScore,
- Source: post.Source,
- ScanJobID: post.ScanJobID,
- Replies: toScanReplyData(post.Replies),
- CreateAt: post.CreateAt,
- })
- }
- return &types.ListPersonaViralScanPostsData{List: list, Total: len(list)}
-}
-
-func toScanReplyData(replies []scanpostdomain.ScanReplySummary) []types.ScanReplyData {
- if len(replies) == 0 {
- return nil
- }
- out := make([]types.ScanReplyData, 0, len(replies))
- for _, reply := range replies {
- out = append(out, types.ScanReplyData{
- ExternalID: reply.ExternalID,
- Author: reply.Author,
- Text: reply.Text,
- Permalink: reply.Permalink,
- LikeCount: reply.LikeCount,
- PostedAt: reply.PostedAt,
- })
- }
- return out
-}
-
-func toCopyDraftData(item copydraftdomain.CopyDraftSummary) types.CopyDraftData {
- return types.CopyDraftData{
- ID: item.ID,
- PersonaID: item.PersonaID,
- CopyMissionID: item.CopyMissionID,
- ScanPostID: item.ScanPostID,
- DraftType: item.DraftType,
- SortOrder: item.SortOrder,
- Text: item.Text,
- Angle: item.Angle,
- Hook: item.Hook,
- Rationale: item.Rationale,
- ReferenceNotes: item.ReferenceNotes,
- Sources: item.Sources,
- Status: item.Status,
- PublishedMediaID: item.PublishedMediaID,
- PublishedPermalink: item.PublishedPermalink,
- PublishedAt: item.PublishedAt,
- CreateAt: item.CreateAt,
- }
-}
diff --git a/backend/internal/logic/copy_mission/matrix_guard.go b/backend/internal/logic/copy_mission/matrix_guard.go
deleted file mode 100644
index e2aa93c..0000000
--- a/backend/internal/logic/copy_mission/matrix_guard.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package copy_mission
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/job/domain/enum"
- domrepo "haixun-backend/internal/model/job/domain/repository"
- "haixun-backend/internal/svc"
-)
-
-func ensureNoActiveMatrixJob(ctx context.Context, svcCtx *svc.ServiceContext, missionID string) error {
- if svcCtx == nil || svcCtx.Job == nil {
- return nil
- }
- missionID = strings.TrimSpace(missionID)
- if missionID == "" {
- return nil
- }
- runs, _, _, _, _, err := svcCtx.Job.ListRuns(ctx, domrepo.RunListFilter{
- Scope: "copy_mission",
- ScopeID: missionID,
- Statuses: []enum.RunStatus{
- enum.RunStatusPending,
- enum.RunStatusQueued,
- enum.RunStatusRunning,
- enum.RunStatusWaitingWorker,
- enum.RunStatusCancelRequested,
- },
- }, 1, 20)
- if err != nil {
- return err
- }
- for _, run := range runs {
- if run != nil && run.TemplateType == "generate-copy-matrix" {
- return app.For(code.Job).ResInvalidState("內容矩陣背景任務進行中,請稍後再試")
- }
- }
- return nil
-}
\ No newline at end of file
diff --git a/backend/internal/logic/copy_mission/patch_copy_mission_audience_sample_logic.go b/backend/internal/logic/copy_mission/patch_copy_mission_audience_sample_logic.go
index f13a687..7c773a8 100644
--- a/backend/internal/logic/copy_mission/patch_copy_mission_audience_sample_logic.go
+++ b/backend/internal/logic/copy_mission/patch_copy_mission_audience_sample_logic.go
@@ -5,9 +5,7 @@ package copy_mission
import (
"context"
- "strings"
- missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@@ -28,22 +26,8 @@ func NewPatchCopyMissionAudienceSampleLogic(ctx context.Context, svcCtx *svc.Ser
}
}
-func (l *PatchCopyMissionAudienceSampleLogic) PatchCopyMissionAudienceSample(req *types.PatchAudienceSampleHandlerReq) (*types.CopyMissionData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.CopyMission.PatchAudienceSample(l.ctx, missiondomain.PatchAudienceSampleRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: strings.TrimSpace(req.PersonaID),
- MissionID: strings.TrimSpace(req.ID),
- Username: strings.TrimSpace(req.Username),
- Status: req.Status,
- })
- if err != nil {
- return nil, err
- }
- data := toCopyMissionData(*item)
- return &data, nil
+func (l *PatchCopyMissionAudienceSampleLogic) PatchCopyMissionAudienceSample(req *types.PatchAudienceSampleHandlerReq) (resp *types.CopyMissionData, err error) {
+ // todo: add your logic here and delete this line
+
+ return
}
diff --git a/backend/internal/logic/copy_mission/patch_copy_mission_graph_nodes_logic.go b/backend/internal/logic/copy_mission/patch_copy_mission_graph_nodes_logic.go
index a6f48d0..1c2598c 100644
--- a/backend/internal/logic/copy_mission/patch_copy_mission_graph_nodes_logic.go
+++ b/backend/internal/logic/copy_mission/patch_copy_mission_graph_nodes_logic.go
@@ -1,14 +1,11 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
- "strings"
- libcopy "haixun-backend/internal/library/copymission"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
- kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@@ -29,73 +26,8 @@ func NewPatchCopyMissionGraphNodesLogic(ctx context.Context, svcCtx *svc.Service
}
}
-func (l *PatchCopyMissionGraphNodesLogic) PatchCopyMissionGraphNodes(req *types.PatchCopyMissionGraphNodesHandlerReq) (*types.KnowledgeGraphData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- if len(req.Updates) == 0 {
- return nil, app.For(code.Persona).InputMissingRequired("updates is required")
- }
- personaID := strings.TrimSpace(req.PersonaID)
- missionID := strings.TrimSpace(req.ID)
- mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID)
- if err != nil {
- return nil, err
- }
+func (l *PatchCopyMissionGraphNodesLogic) PatchCopyMissionGraphNodes(req *types.PatchCopyMissionGraphNodesHandlerReq) (resp *types.KnowledgeGraphData, err error) {
+ // todo: add your logic here and delete this line
- updates := make([]kgusecase.NodeUpdate, 0, len(req.Updates))
- for _, item := range req.Updates {
- nodeID := strings.TrimSpace(item.NodeID)
- if nodeID == "" {
- continue
- }
- update := kgusecase.NodeUpdate{NodeID: nodeID}
- if item.SelectedForScan != nil {
- update.SelectedForScan = item.SelectedForScan
- }
- if item.RelevanceTags != nil {
- update.RelevanceTagsSet = true
- update.RelevanceTags = append([]string(nil), item.RelevanceTags...)
- }
- if item.RecencyTags != nil {
- update.RecencyTagsSet = true
- update.RecencyTags = append([]string(nil), item.RecencyTags...)
- }
- if update.SelectedForScan == nil && !update.RelevanceTagsSet && !update.RecencyTagsSet {
- return nil, app.For(code.Persona).InputMissingRequired("each update needs selected_for_scan or patrol tags")
- }
- updates = append(updates, update)
- }
- if len(updates) == 0 {
- return nil, app.For(code.Persona).InputMissingRequired("updates is required")
- }
-
- graph, err := l.svcCtx.KnowledgeGraph.UpdateNodes(l.ctx, kgusecase.UpdateNodesRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- CopyMissionID: missionID,
- Updates: updates,
- })
- if err != nil {
- return nil, err
- }
-
- prev := summaryToEntityResearchMap(mission.ResearchMap)
- researchMap := libcopy.BuildResearchMapFromGraph(prev, graph.Nodes, graph.BraveSources)
- _, err = l.svcCtx.CopyMission.Update(l.ctx, missiondomain.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: personaID,
- MissionID: missionID,
- Patch: missiondomain.MissionPatch{
- ResearchMap: &researchMap,
- },
- })
- if err != nil {
- return nil, err
- }
-
- data := toKnowledgeGraphData(graph)
- return &data, nil
-}
\ No newline at end of file
+ return
+}
diff --git a/backend/internal/logic/copy_mission/patch_copy_mission_similar_account_logic.go b/backend/internal/logic/copy_mission/patch_copy_mission_similar_account_logic.go
index cf59b9d..b8a0637 100644
--- a/backend/internal/logic/copy_mission/patch_copy_mission_similar_account_logic.go
+++ b/backend/internal/logic/copy_mission/patch_copy_mission_similar_account_logic.go
@@ -5,11 +5,7 @@ package copy_mission
import (
"context"
- "strings"
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
- missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@@ -30,40 +26,8 @@ func NewPatchCopyMissionSimilarAccountLogic(ctx context.Context, svcCtx *svc.Ser
}
}
-func (l *PatchCopyMissionSimilarAccountLogic) PatchCopyMissionSimilarAccount(req *types.PatchSimilarAccountHandlerReq) (*types.CopyMissionData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.CopyMission.PatchSimilarAccount(l.ctx, missiondomain.PatchSimilarAccountRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: strings.TrimSpace(req.PersonaID),
- MissionID: strings.TrimSpace(req.ID),
- Username: strings.TrimSpace(req.Username),
- Status: req.Status,
- })
- if err != nil {
- return nil, err
- }
- if status := strings.TrimSpace(req.Status); status == missionentity.SimilarAccountStatusExcluded || status == missionentity.SimilarAccountStatusRecommended {
- if research, researchErr := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid); researchErr == nil {
- if memberCtx, memberErr := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research); memberErr == nil && memberCtx.ActiveAccountID != "" {
- username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@"))
- if username != "" {
- _ = l.svcCtx.ThreadsAccount.UpsertKnownAccountsFromScan(l.ctx, threadsaccountdomain.UpsertKnownAccountsFromScanRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- AccountID: memberCtx.ActiveAccountID,
- SimilarAccounts: []missionentity.SimilarAccount{{
- Username: username,
- Status: status,
- }},
- })
- }
- }
- }
- }
- data := toCopyMissionData(*item)
- return &data, nil
+func (l *PatchCopyMissionSimilarAccountLogic) PatchCopyMissionSimilarAccount(req *types.PatchSimilarAccountHandlerReq) (resp *types.CopyMissionData, err error) {
+ // todo: add your logic here and delete this line
+
+ return
}
diff --git a/backend/internal/logic/copy_mission/research_map_entity.go b/backend/internal/logic/copy_mission/research_map_entity.go
deleted file mode 100644
index 780b201..0000000
--- a/backend/internal/logic/copy_mission/research_map_entity.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package copy_mission
-
-import (
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
- missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
-)
-
-func summaryResearchItemsToEntity(items []missiondomain.ResearchItemSummary) []missionentity.ResearchItem {
- out := make([]missionentity.ResearchItem, 0, len(items))
- for _, item := range items {
- out = append(out, missionentity.ResearchItem{
- Title: item.Title,
- URL: item.URL,
- Snippet: item.Snippet,
- Query: item.Query,
- })
- }
- return out
-}
-
-func summaryToEntityResearchMap(summary missiondomain.ResearchMapSummary) missionentity.ResearchMap {
- tags := make([]missionentity.SuggestedTag, 0, len(summary.SuggestedTags))
- for _, tag := range summary.SuggestedTags {
- tags = append(tags, missionentity.SuggestedTag{
- Tag: tag.Tag,
- Reason: tag.Reason,
- SearchIntent: tag.SearchIntent,
- SearchType: tag.SearchType,
- })
- }
- accounts := make([]missionentity.SimilarAccount, 0, len(summary.SimilarAccounts))
- for _, acc := range summary.SimilarAccounts {
- accounts = append(accounts, missionentity.SimilarAccount{
- Username: acc.Username,
- Reason: acc.Reason,
- Source: acc.Source,
- MatchedSource: append([]string(nil), acc.MatchedSource...),
- Confidence: acc.Confidence,
- Status: acc.Status,
- TopicRelevance: acc.TopicRelevance,
- LastSeenAt: acc.LastSeenAt,
- ProfileURL: acc.ProfileURL,
- AuthorVerified: acc.AuthorVerified,
- FollowerCount: acc.FollowerCount,
- EngagementScore: acc.EngagementScore,
- LikeCount: acc.LikeCount,
- ReplyCount: acc.ReplyCount,
- PostCount: acc.PostCount,
- })
- }
- samples := make([]missionentity.AudienceSample, 0, len(summary.AudienceSamples))
- for _, sample := range summary.AudienceSamples {
- samples = append(samples, missionentity.AudienceSample{
- Username: sample.Username,
- SamplePostID: sample.SamplePostID,
- SampleText: sample.SampleText,
- ReplyLikeCount: sample.ReplyLikeCount,
- Appearances: sample.Appearances,
- FirstSeenAt: sample.FirstSeenAt,
- LastSeenAt: sample.LastSeenAt,
- Status: sample.Status,
- })
- }
- return missionentity.ResearchMap{
- AudienceSummary: summary.AudienceSummary,
- ContentGoal: summary.ContentGoal,
- Questions: append([]string(nil), summary.Questions...),
- Pillars: append([]string(nil), summary.Pillars...),
- Exclusions: append([]string(nil), summary.Exclusions...),
- KnowledgeItems: append([]string(nil), summary.KnowledgeItems...),
- SelectedKnowledgeItems: append([]string(nil), summary.SelectedKnowledgeItems...),
- ResearchItems: summaryResearchItemsToEntity(summary.ResearchItems),
- SuggestedTags: tags,
- SimilarAccounts: accounts,
- AudienceSamples: samples,
- BenchmarkNotes: summary.BenchmarkNotes,
- }
-}
\ No newline at end of file
diff --git a/backend/internal/logic/copy_mission/schedule_copy_mission_drafts_logic.go b/backend/internal/logic/copy_mission/schedule_copy_mission_drafts_logic.go
new file mode 100644
index 0000000..cca830a
--- /dev/null
+++ b/backend/internal/logic/copy_mission/schedule_copy_mission_drafts_logic.go
@@ -0,0 +1,33 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package copy_mission
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ScheduleCopyMissionDraftsLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewScheduleCopyMissionDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ScheduleCopyMissionDraftsLogic {
+ return &ScheduleCopyMissionDraftsLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *ScheduleCopyMissionDraftsLogic) ScheduleCopyMissionDrafts(req *types.ScheduleCopyMissionDraftsHandlerReq) (resp *types.CopyMissionScheduleCopyDraftsData, err error) {
+ // todo: add your logic here and delete this line
+
+ return
+}
diff --git a/backend/internal/logic/copy_mission/schedule_helper.go b/backend/internal/logic/copy_mission/schedule_helper.go
deleted file mode 100644
index 23f59d7..0000000
--- a/backend/internal/logic/copy_mission/schedule_helper.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package copy_mission
-
-import (
- "context"
-
- jobentity "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-const viralScanTemplate = "scan-viral"
-
-func findCopyMissionScanSchedule(ctx context.Context, svcCtx *svc.ServiceContext, missionID string) (*jobentity.Schedule, error) {
- schedules, _, _, _, _, err := svcCtx.Job.ListSchedules(ctx, "copy_mission", missionID, 1, 50)
- if err != nil {
- return nil, err
- }
- for _, schedule := range schedules {
- if schedule != nil && schedule.TemplateType == viralScanTemplate {
- return schedule, nil
- }
- }
- return nil, nil
-}
-
-func toCopyMissionScanScheduleData(schedule *jobentity.Schedule, personaID, missionID string) *types.CopyMissionScanScheduleData {
- if schedule == nil {
- return &types.CopyMissionScanScheduleData{
- PersonaID: personaID,
- MissionID: missionID,
- Cron: "0 9 * * *",
- Timezone: "Asia/Taipei",
- Enabled: false,
- }
- }
- data := &types.CopyMissionScanScheduleData{
- ID: schedule.ID.Hex(),
- PersonaID: personaID,
- MissionID: missionID,
- Cron: schedule.Cron,
- Timezone: schedule.Timezone,
- Enabled: schedule.Enabled,
- NextRunAt: schedule.NextRunAt,
- }
- if schedule.LastRunAt != nil {
- data.LastRunAt = *schedule.LastRunAt
- }
- return data
-}
diff --git a/backend/internal/logic/copy_mission/start_copy_mission_analyze_job_logic.go b/backend/internal/logic/copy_mission/start_copy_mission_analyze_job_logic.go
index ec491ec..05e3fd6 100644
--- a/backend/internal/logic/copy_mission/start_copy_mission_analyze_job_logic.go
+++ b/backend/internal/logic/copy_mission/start_copy_mission_analyze_job_logic.go
@@ -1,67 +1,33 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
- "strings"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
)
type StartCopyMissionAnalyzeJobLogic struct {
+ logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewStartCopyMissionAnalyzeJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionAnalyzeJobLogic {
- return &StartCopyMissionAnalyzeJobLogic{ctx: ctx, svcCtx: svcCtx}
+ return &StartCopyMissionAnalyzeJobLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
}
-func (l *StartCopyMissionAnalyzeJobLogic) StartCopyMissionAnalyzeJob(
- req *types.CopyMissionPath,
-) (*types.StartCopyMissionAnalyzeJobData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.PersonaID)
- missionID := strings.TrimSpace(req.ID)
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
- return nil, err
- }
- if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil {
- return nil, err
- }
- if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil {
- return nil, err
- }
+func (l *StartCopyMissionAnalyzeJobLogic) StartCopyMissionAnalyzeJob(req *types.CopyMissionPath) (resp *types.StartCopyMissionAnalyzeJobData, err error) {
+ // todo: add your logic here and delete this line
- payload := map[string]any{
- "tenant_id": tenantID,
- "owner_uid": uid,
- "persona_id": personaID,
- "copy_mission_id": missionID,
- }
- run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
- TemplateType: "analyze-copy-mission",
- Scope: "copy_mission",
- ScopeID: missionID,
- TenantID: tenantID,
- OwnerUID: uid,
- Payload: payload,
- })
- if err != nil {
- return nil, err
- }
- if run == nil {
- return nil, app.For(code.Job).ResInvalidState("analyze job not created")
- }
- return &types.StartCopyMissionAnalyzeJobData{
- JobID: run.ID.Hex(),
- Status: string(run.Status),
- Message: "受眾與研究地圖分析已在背景執行,完成後可微調標籤並開始海巡",
- }, nil
+ return
}
diff --git a/backend/internal/logic/copy_mission/start_copy_mission_copy_draft_job_logic.go b/backend/internal/logic/copy_mission/start_copy_mission_copy_draft_job_logic.go
index 9d1cd25..48582cb 100644
--- a/backend/internal/logic/copy_mission/start_copy_mission_copy_draft_job_logic.go
+++ b/backend/internal/logic/copy_mission/start_copy_mission_copy_draft_job_logic.go
@@ -1,80 +1,33 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
- "strings"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/library/style8d"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
)
type StartCopyMissionCopyDraftJobLogic struct {
+ logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewStartCopyMissionCopyDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionCopyDraftJobLogic {
- return &StartCopyMissionCopyDraftJobLogic{ctx: ctx, svcCtx: svcCtx}
+ return &StartCopyMissionCopyDraftJobLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
}
-func (l *StartCopyMissionCopyDraftJobLogic) StartCopyMissionCopyDraftJob(
- req *types.StartCopyMissionCopyDraftJobHandlerReq,
-) (*types.StartCopyMissionCopyDraftJobData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.PersonaID)
- missionID := strings.TrimSpace(req.ID)
- scanPostID := strings.TrimSpace(req.ScanPostID)
- if scanPostID == "" {
- return nil, app.For(code.Persona).InputMissingRequired("scan_post_id is required")
- }
+func (l *StartCopyMissionCopyDraftJobLogic) StartCopyMissionCopyDraftJob(req *types.StartCopyMissionCopyDraftJobHandlerReq) (resp *types.StartCopyMissionCopyDraftJobData, err error) {
+ // todo: add your logic here and delete this line
- if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil {
- return nil, err
- }
- persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
- if err != nil {
- return nil, err
- }
- if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
- return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析")
- }
- if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil {
- return nil, err
- }
- post, err := l.svcCtx.ScanPost.GetForPersona(l.ctx, tenantID, uid, personaID, scanPostID)
- if err != nil {
- return nil, err
- }
- if postMission := strings.TrimSpace(post.CopyMissionID); postMission != "" && postMission != missionID {
- return nil, app.For(code.Persona).ResInvalidState("爆款不屬於此任務")
- }
-
- payload := map[string]any{
- "persona_id": personaID,
- "copy_mission_id": missionID,
- "scan_post_id": scanPostID,
- }
- run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
- TemplateType: "generate-copy-draft",
- Scope: "copy_mission",
- ScopeID: missionID,
- TenantID: tenantID,
- OwnerUID: uid,
- Payload: payload,
- })
- if err != nil {
- return nil, err
- }
- return &types.StartCopyMissionCopyDraftJobData{
- JobID: run.ID.Hex(),
- Status: string(run.Status),
- Message: "深仿寫已在背景執行",
- }, nil
+ return
}
diff --git a/backend/internal/logic/copy_mission/start_copy_mission_matrix_job_logic.go b/backend/internal/logic/copy_mission/start_copy_mission_matrix_job_logic.go
index 2315e07..31367aa 100644
--- a/backend/internal/logic/copy_mission/start_copy_mission_matrix_job_logic.go
+++ b/backend/internal/logic/copy_mission/start_copy_mission_matrix_job_logic.go
@@ -1,89 +1,33 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
- "strings"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/library/style8d"
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
)
type StartCopyMissionMatrixJobLogic struct {
+ logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewStartCopyMissionMatrixJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionMatrixJobLogic {
- return &StartCopyMissionMatrixJobLogic{ctx: ctx, svcCtx: svcCtx}
+ return &StartCopyMissionMatrixJobLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
}
-func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob(
- req *types.StartCopyMissionMatrixJobHandlerReq,
-) (*types.StartCopyMissionMatrixJobData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.PersonaID)
- missionID := strings.TrimSpace(req.ID)
- mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID)
- if err != nil {
- return nil, err
- }
- if mission.Status != string(missionentity.StatusMapped) &&
- mission.Status != string(missionentity.StatusScanned) &&
- mission.Status != string(missionentity.StatusDrafted) {
- return nil, app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣")
- }
- if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" {
- return nil, app.For(code.Persona).InputMissingRequired("請先完成研究地圖")
- }
+func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob(req *types.StartCopyMissionMatrixJobHandlerReq) (resp *types.StartCopyMissionMatrixJobData, err error) {
+ // todo: add your logic here and delete this line
- persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
- if err != nil {
- return nil, err
- }
- if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
- return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析")
- }
- if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil {
- return nil, err
- }
-
- count := req.Count
- if count <= 0 {
- count = 5
- }
- if count > 12 {
- count = 12
- }
-
- payload := map[string]any{
- "tenant_id": tenantID,
- "owner_uid": uid,
- "persona_id": personaID,
- "copy_mission_id": missionID,
- "count": count,
- }
- run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
- TemplateType: "generate-copy-matrix",
- Scope: "copy_mission",
- ScopeID: missionID,
- TenantID: tenantID,
- OwnerUID: uid,
- Payload: payload,
- })
- if err != nil {
- return nil, err
- }
- return &types.StartCopyMissionMatrixJobData{
- JobID: run.ID.Hex(),
- Status: string(run.Status),
- Message: "內容矩陣產出已在背景執行",
- }, nil
+ return
}
diff --git a/backend/internal/logic/copy_mission/start_copy_mission_scan_job_logic.go b/backend/internal/logic/copy_mission/start_copy_mission_scan_job_logic.go
index c4b0fc6..3681e2b 100644
--- a/backend/internal/logic/copy_mission/start_copy_mission_scan_job_logic.go
+++ b/backend/internal/logic/copy_mission/start_copy_mission_scan_job_logic.go
@@ -1,95 +1,33 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
- "strings"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libviral "haixun-backend/internal/library/viral"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
)
type StartCopyMissionScanJobLogic struct {
+ logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewStartCopyMissionScanJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionScanJobLogic {
- return &StartCopyMissionScanJobLogic{ctx: ctx, svcCtx: svcCtx}
+ return &StartCopyMissionScanJobLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
}
-func (l *StartCopyMissionScanJobLogic) StartCopyMissionScanJob(
- req *types.CopyMissionPath,
-) (*types.StartCopyMissionScanJobData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.PersonaID)
- missionID := strings.TrimSpace(req.ID)
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
- return nil, err
- }
- mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID)
- if err != nil {
- return nil, err
- }
- if mission.ResearchMap.AudienceSummary == "" && len(mission.ResearchMap.SuggestedTags) == 0 {
- return nil, app.For(code.Persona).InputMissingRequired("請先產生研究地圖")
- }
- if len(mission.SelectedTags) == 0 {
- return nil, app.For(code.Persona).InputMissingRequired("請至少勾選一個搜尋標籤")
- }
- if len(mission.SelectedTags) > libviral.MaxScanTags {
- return nil, app.For(code.Persona).InputMissingRequired("搜尋標籤最多 6 個,請減少勾選以節省搜尋配額")
- }
+func (l *StartCopyMissionScanJobLogic) StartCopyMissionScanJob(req *types.CopyMissionPath) (resp *types.StartCopyMissionScanJobData, err error) {
+ // todo: add your logic here and delete this line
- research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
- if err != nil {
- return nil, err
- }
- if !memberCtx.HasDiscoverPath() {
- return nil, app.For(code.Setting).InputMissingRequired("爆款掃描需要 Threads API、Chrome Session 或 Web Search API(請檢查連線模式與搜尋來源)")
- }
-
- if err := l.svcCtx.ScanPost.ClearCopyMissionViralScan(l.ctx, tenantID, uid, personaID, missionID); err != nil {
- return nil, err
- }
- if err := l.svcCtx.CopyDraft.ClearByMission(l.ctx, tenantID, uid, personaID, missionID); err != nil {
- return nil, err
- }
-
- payload := map[string]any{
- "persona_id": personaID,
- "copy_mission_id": missionID,
- "keywords": mission.SelectedTags,
- "bootstrap": false,
- }
- for key, value := range memberCtx.PayloadFields() {
- payload[key] = value
- }
-
- run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
- TemplateType: "scan-viral",
- Scope: "copy_mission",
- ScopeID: missionID,
- TenantID: tenantID,
- OwnerUID: uid,
- Payload: payload,
- })
- if err != nil {
- return nil, err
- }
- return &types.StartCopyMissionScanJobData{
- JobID: run.ID.Hex(),
- Status: string(run.Status),
- Message: "已清除舊爆款與產文草稿,海巡已在背景執行",
- }, nil
+ return
}
diff --git a/backend/internal/logic/copy_mission/update_copy_mission_logic.go b/backend/internal/logic/copy_mission/update_copy_mission_logic.go
index ef1c748..c936d7b 100644
--- a/backend/internal/logic/copy_mission/update_copy_mission_logic.go
+++ b/backend/internal/logic/copy_mission/update_copy_mission_logic.go
@@ -1,14 +1,11 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
- "strings"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
- missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@@ -29,39 +26,8 @@ func NewUpdateCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext)
}
}
-func (l *UpdateCopyMissionLogic) UpdateCopyMission(req *types.UpdateCopyMissionHandlerReq) (*types.CopyMissionData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- patch := toMissionPatch(&req.UpdateCopyMissionReq)
- if req.Status != nil {
- status := strings.TrimSpace(*req.Status)
- if status != string(missionentity.StatusArchived) && status != string(missionentity.StatusOpen) {
- return nil, app.For(code.Persona).InputMissingRequired("status 僅支援 archived 或 open")
- }
- st := missionentity.Status(status)
- patch.Status = &st
- }
- item, err := l.svcCtx.CopyMission.Update(l.ctx, missiondomain.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: strings.TrimSpace(req.PersonaID),
- MissionID: strings.TrimSpace(req.ID),
- Patch: patch,
- })
- if err != nil {
- return nil, err
- }
- if req.Status != nil && strings.TrimSpace(*req.Status) == string(missionentity.StatusArchived) {
- if schedule, err := findCopyMissionScanSchedule(l.ctx, l.svcCtx, strings.TrimSpace(req.ID)); err == nil && schedule != nil && schedule.Enabled {
- disabled := false
- _, _ = l.svcCtx.Job.UpdateSchedule(l.ctx, jobdom.UpdateScheduleRequest{
- ID: schedule.ID.Hex(),
- Enabled: &disabled,
- })
- }
- }
- data := toCopyMissionData(*item)
- return &data, nil
+func (l *UpdateCopyMissionLogic) UpdateCopyMission(req *types.UpdateCopyMissionHandlerReq) (resp *types.CopyMissionData, err error) {
+ // todo: add your logic here and delete this line
+
+ return
}
diff --git a/backend/internal/logic/copy_mission/upsert_copy_mission_scan_schedule_logic.go b/backend/internal/logic/copy_mission/upsert_copy_mission_scan_schedule_logic.go
index 80b2c13..916139a 100644
--- a/backend/internal/logic/copy_mission/upsert_copy_mission_scan_schedule_logic.go
+++ b/backend/internal/logic/copy_mission/upsert_copy_mission_scan_schedule_logic.go
@@ -1,106 +1,33 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
package copy_mission
import (
"context"
- "strings"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
)
type UpsertCopyMissionScanScheduleLogic struct {
+ logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpsertCopyMissionScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertCopyMissionScanScheduleLogic {
- return &UpsertCopyMissionScanScheduleLogic{ctx: ctx, svcCtx: svcCtx}
+ return &UpsertCopyMissionScanScheduleLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
}
-func (l *UpsertCopyMissionScanScheduleLogic) UpsertCopyMissionScanSchedule(
- req *types.UpsertCopyMissionScanScheduleHandlerReq,
-) (*types.CopyMissionScanScheduleData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.PersonaID)
- missionID := strings.TrimSpace(req.ID)
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
- return nil, err
- }
- mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID)
- if err != nil {
- return nil, err
- }
- if mission.Status == string(missionentity.StatusArchived) {
- return nil, app.For(code.Persona).ResInvalidState("已封存的任務無法設定排程")
- }
- if req.Enabled && len(mission.SelectedTags) == 0 {
- return nil, app.For(code.Persona).InputMissingRequired("啟用排程前請先勾選並儲存搜尋標籤")
- }
+func (l *UpsertCopyMissionScanScheduleLogic) UpsertCopyMissionScanSchedule(req *types.UpsertCopyMissionScanScheduleHandlerReq) (resp *types.CopyMissionScanScheduleData, err error) {
+ // todo: add your logic here and delete this line
- cronExpr := "0 9 * * *"
- timezone := "Asia/Taipei"
- if strings.TrimSpace(req.Cron) != "" {
- cronExpr = strings.TrimSpace(req.Cron)
- }
- if strings.TrimSpace(req.Timezone) != "" {
- timezone = strings.TrimSpace(req.Timezone)
- }
-
- research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
- if err != nil {
- return nil, err
- }
-
- payload := map[string]any{
- "persona_id": personaID,
- "copy_mission_id": missionID,
- "bootstrap": false,
- "scheduled": true,
- }
- for key, value := range memberCtx.PayloadFields() {
- payload[key] = value
- }
-
- existing, err := findCopyMissionScanSchedule(l.ctx, l.svcCtx, missionID)
- if err != nil {
- return nil, err
- }
- if existing != nil {
- updated, err := l.svcCtx.Job.UpdateSchedule(l.ctx, jobdom.UpdateScheduleRequest{
- ID: existing.ID.Hex(),
- Cron: cronExpr,
- Timezone: timezone,
- PayloadTemplate: payload,
- Enabled: &req.Enabled,
- })
- if err != nil {
- return nil, err
- }
- return toCopyMissionScanScheduleData(updated, personaID, missionID), nil
- }
-
- created, err := l.svcCtx.Job.CreateSchedule(l.ctx, jobdom.CreateScheduleRequest{
- TemplateType: viralScanTemplate,
- Scope: "copy_mission",
- ScopeID: missionID,
- Cron: cronExpr,
- Timezone: timezone,
- PayloadTemplate: payload,
- Enabled: req.Enabled,
- })
- if err != nil {
- return nil, err
- }
- return toCopyMissionScanScheduleData(created, personaID, missionID), nil
+ return
}
diff --git a/backend/internal/logic/crm/actor.go b/backend/internal/logic/crm/actor.go
deleted file mode 100644
index ff363fc..0000000
--- a/backend/internal/logic/crm/actor.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package crm
-
-import (
- "context"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
-)
-
-func actorFrom(ctx context.Context) (tenantID, uid string, err error) {
- actor, ok := authctx.ActorFromContext(ctx)
- if !ok {
- return "", "", app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- return actor.TenantID, actor.UID, nil
-}
diff --git a/backend/internal/logic/crm/create_crm_contact_logic.go b/backend/internal/logic/crm/create_crm_contact_logic.go
deleted file mode 100644
index 6cc276b..0000000
--- a/backend/internal/logic/crm/create_crm_contact_logic.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package crm
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type CreateCrmContactLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewCreateCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateCrmContactLogic {
- return &CreateCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *CreateCrmContactLogic) CreateCrmContact(req *types.CreateCrmContactReq) (*types.CrmContactData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
-
- authorID := strings.TrimSpace(req.AuthorID)
- authorName := strings.TrimSpace(req.AuthorName)
- if authorID == "" {
- return nil, app.For(code.Persona).InputMissingRequired("author_id is required")
- }
- if authorName == "" {
- return nil, app.For(code.Persona).InputMissingRequired("author_name is required")
- }
-
- result, err := l.svcCtx.CrmContact.Create(l.ctx, tenantID, uid, strings.TrimSpace(req.BrandID), authorID, authorName)
- if err != nil {
- return nil, err
- }
-
- return toCrmContactData(result), nil
-}
diff --git a/backend/internal/logic/crm/delete_crm_contact_logic.go b/backend/internal/logic/crm/delete_crm_contact_logic.go
deleted file mode 100644
index 669ebcf..0000000
--- a/backend/internal/logic/crm/delete_crm_contact_logic.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package crm
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type DeleteCrmContactLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewDeleteCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteCrmContactLogic {
- return &DeleteCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *DeleteCrmContactLogic) DeleteCrmContact(req *types.CrmContactIDPath) error {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return err
- }
-
- return l.svcCtx.CrmContact.Delete(l.ctx, tenantID, uid, req.ID)
-}
diff --git a/backend/internal/logic/crm/get_crm_contact_logic.go b/backend/internal/logic/crm/get_crm_contact_logic.go
deleted file mode 100644
index 943f6c6..0000000
--- a/backend/internal/logic/crm/get_crm_contact_logic.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package crm
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GetCrmContactLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCrmContactLogic {
- return &GetCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GetCrmContactLogic) GetCrmContact(req *types.CrmContactIDPath) (*types.CrmContactData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
-
- result, err := l.svcCtx.CrmContact.Get(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
-
- return toCrmContactData(result), nil
-}
diff --git a/backend/internal/logic/crm/list_crm_contacts_logic.go b/backend/internal/logic/crm/list_crm_contacts_logic.go
deleted file mode 100644
index ce16203..0000000
--- a/backend/internal/logic/crm/list_crm_contacts_logic.go
+++ /dev/null
@@ -1,54 +0,0 @@
-package crm
-
-import (
- "context"
-
- crmusecase "haixun-backend/internal/model/crm_contact/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListCrmContactsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListCrmContactsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCrmContactsLogic {
- return &ListCrmContactsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ListCrmContactsLogic) ListCrmContacts(req *types.ListCrmContactsReq) (*types.CrmContactListData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
-
- page := req.Page
- pageSize := req.PageSize
- if page < 1 {
- page = 1
- }
- if pageSize < 1 {
- pageSize = 20
- }
-
- listReq := crmusecase.ListRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- BrandID: req.BrandID,
- Status: req.Status,
- Tag: req.Tag,
- Page: page,
- PageSize: pageSize,
- }
-
- items, total, err := l.svcCtx.CrmContact.List(l.ctx, listReq)
- if err != nil {
- return nil, err
- }
-
- return toCrmContactListData(items, total, page, pageSize), nil
-}
diff --git a/backend/internal/logic/crm/mapper.go b/backend/internal/logic/crm/mapper.go
deleted file mode 100644
index 48a9738..0000000
--- a/backend/internal/logic/crm/mapper.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package crm
-
-import (
- crmusecase "haixun-backend/internal/model/crm_contact/domain/usecase"
- "haixun-backend/internal/types"
-)
-
-func toCrmContactData(item *crmusecase.CRMContactSummary) *types.CrmContactData {
- if item == nil {
- return nil
- }
- return &types.CrmContactData{
- ID: item.ID,
- BrandID: item.BrandID,
- AuthorID: item.AuthorID,
- AuthorName: item.AuthorName,
- AuthorAvatar: item.AuthorAvatar,
- AuthorFollowers: item.AuthorFollowers,
- ScanPostID: item.ScanPostID,
- Notes: item.Notes,
- Tags: item.Tags,
- Status: item.Status,
- OutreachCount: item.OutreachCount,
- LastContactedAt: item.LastContactedAt,
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
- }
-}
-
-func toCrmContactListData(items []crmusecase.CRMContactSummary, total, page, pageSize int) *types.CrmContactListData {
- totalPages := 0
- if total > 0 {
- totalPages = (total + pageSize - 1) / pageSize
- }
- data := make([]types.CrmContactData, 0, len(items))
- for _, item := range items {
- data = append(data, *toCrmContactData(&item))
- }
- return &types.CrmContactListData{
- List: data,
- Pagination: types.PaginationData{
- Total: int64(total),
- Page: int64(page),
- PageSize: int64(pageSize),
- TotalPages: int64(totalPages),
- },
- }
-}
diff --git a/backend/internal/logic/crm/update_crm_contact_logic.go b/backend/internal/logic/crm/update_crm_contact_logic.go
deleted file mode 100644
index 853c5e4..0000000
--- a/backend/internal/logic/crm/update_crm_contact_logic.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package crm
-
-import (
- "context"
-
- crmusecase "haixun-backend/internal/model/crm_contact/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpdateCrmContactLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdateCrmContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateCrmContactLogic {
- return &UpdateCrmContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *UpdateCrmContactLogic) UpdateCrmContact(req *types.UpdateCrmContactReq) (*types.CrmContactData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
-
- result, err := l.svcCtx.CrmContact.Update(l.ctx, crmusecase.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- ContactID: req.ID,
- Notes: req.Notes,
- Tags: req.Tags,
- Status: req.Status,
- })
- if err != nil {
- return nil, err
- }
-
- return toCrmContactData(result), nil
-}
diff --git a/backend/internal/logic/job/analyze_style8_d_from_worker_logic.go b/backend/internal/logic/job/analyze_style8_d_from_worker_logic.go
index d27b13a..c51bf43 100644
--- a/backend/internal/logic/job/analyze_style8_d_from_worker_logic.go
+++ b/backend/internal/logic/job/analyze_style8_d_from_worker_logic.go
@@ -15,6 +15,7 @@ import (
jobenum "haixun-backend/internal/model/job/domain/enum"
jobusecase "haixun-backend/internal/model/job/domain/usecase"
personausecase "haixun-backend/internal/model/persona/domain/usecase"
+ tausecase "haixun-backend/internal/model/threads_account/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@@ -36,12 +37,22 @@ func NewAnalyzeStyle8DFromWorkerLogic(ctx context.Context, svcCtx *svc.ServiceCo
}
func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.AnalyzeStyle8DReq) (resp *types.AnalyzeStyle8DData, err error) {
- credential, err := l.svcCtx.ThreadsAccount.ResolveWorkerAiCredential(
- l.ctx,
- req.TenantID,
- req.OwnerUID,
- req.ThreadsAccountID,
- )
+ var credential *tausecase.WorkerAiCredential
+ accountID := strings.TrimSpace(req.ThreadsAccountID)
+ if accountID != "" {
+ credential, err = l.svcCtx.ThreadsAccount.ResolveWorkerAiCredential(
+ l.ctx,
+ req.TenantID,
+ req.OwnerUID,
+ accountID,
+ )
+ } else {
+ credential, err = l.svcCtx.ThreadsAccount.ResolveMemberResearchAiCredential(
+ l.ctx,
+ req.TenantID,
+ req.OwnerUID,
+ )
+ }
if err != nil {
return nil, err
}
@@ -79,10 +90,16 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal
})
username := strings.TrimPrefix(strings.TrimSpace(req.Username), "@")
+ input := style8d.AnalyzeInput{
+ Mode: style8d.AnalyzeModeBenchmark,
+ Username: username,
+ Posts: posts,
+ }
systemPrompt, err := libprompt.Style8DSystem()
if err != nil {
return nil, app.For(code.AI).SysInternal("prompt config load failed")
}
+ maxTokens := 8192
result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
Provider: providerID,
Model: credential.Model,
@@ -91,8 +108,9 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal
},
System: systemPrompt,
Messages: []domai.Message{
- {Role: "user", Content: style8d.BuildUserPrompt(username, posts)},
+ {Role: "user", Content: input.BuildUserPrompt()},
},
+ MaxTokens: &maxTokens,
})
if err != nil {
if strings.Contains(err.Error(), "HTTP 401") {
@@ -108,7 +126,7 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal
if err != nil {
return nil, app.For(code.AI).SvcThirdParty("8D LLM 回傳無法解析:" + err.Error())
}
- profile := style8d.BuildStoredProfile(username, posts, parsed)
+ profile := input.BuildStoredProfile(parsed)
profileJSON, err := profile.JSON()
if err != nil {
return nil, err
@@ -126,9 +144,10 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal
})
personaDraft := strings.TrimSpace(profile.PersonaDraft)
+ benchmark := input.StyleBenchmark()
patch := personausecase.PersonaPatch{
StyleProfile: &profileJSON,
- StyleBenchmark: &username,
+ StyleBenchmark: &benchmark,
}
if personaDraft != "" {
patch.Persona = &personaDraft
diff --git a/backend/internal/logic/member/mapper.go b/backend/internal/logic/member/mapper.go
index ae262f9..44edd5e 100644
--- a/backend/internal/logic/member/mapper.go
+++ b/backend/internal/logic/member/mapper.go
@@ -33,6 +33,7 @@ func toPlacementSettingsData(settings *placementusecase.Settings) types.MemberPl
BraveSearchLang: settings.BraveSearchLang,
ExaUserLocation: settings.ExaUserLocation,
ExpandStrategy: settings.ExpandStrategy,
+ DevModeEnabled: settings.DevModeEnabled,
}
}
diff --git a/backend/internal/logic/member/update_member_placement_settings_logic.go b/backend/internal/logic/member/update_member_placement_settings_logic.go
index 14e918f..a0defad 100644
--- a/backend/internal/logic/member/update_member_placement_settings_logic.go
+++ b/backend/internal/logic/member/update_member_placement_settings_logic.go
@@ -40,6 +40,7 @@ func (l *UpdateMemberPlacementSettingsLogic) UpdateMemberPlacementSettings(req *
BraveSearchLang: req.BraveSearchLang,
ExaUserLocation: req.ExaUserLocation,
ExpandStrategy: req.ExpandStrategy,
+ DevModeEnabled: req.DevModeEnabled,
})
if err != nil {
return nil, err
diff --git a/backend/internal/logic/persona/content_ops_mapper.go b/backend/internal/logic/persona/content_ops_mapper.go
new file mode 100644
index 0000000..595c460
--- /dev/null
+++ b/backend/internal/logic/persona/content_ops_mapper.go
@@ -0,0 +1,30 @@
+package persona
+
+import (
+ contentops "haixun-backend/internal/model/content_ops/domain/usecase"
+ "haixun-backend/internal/types"
+)
+
+func toTopicCandidateData(item contentops.TopicCandidateSummary) types.TopicCandidateData {
+ return types.TopicCandidateData{ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Source: item.Source, Category: item.Category, SeedQuery: item.SeedQuery, TrendReason: item.TrendReason, TrendKeywords: item.TrendKeywords, HeatScore: item.HeatScore, FitScore: item.FitScore, InteractionScore: item.InteractionScore, ExtendScore: item.ExtendScore, RiskScore: item.RiskScore, FinalScore: item.FinalScore, RecommendedMissions: item.RecommendedMissions, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt}
+}
+
+func toContentPlanData(item contentops.ContentPlanSummary) types.ContentPlanData {
+ return types.ContentPlanData{ID: item.ID, PersonaID: item.PersonaID, TopicCandidateID: item.TopicCandidateID, Topic: item.Topic, Mission: item.Mission, TargetAudience: item.TargetAudience, Angle: item.Angle, OpeningType: item.OpeningType, BodyType: item.BodyType, Emotion: item.Emotion, EndingType: item.EndingType, CtaType: item.CtaType, RiskLevel: item.RiskLevel, RequiresHumanReview: item.RequiresHumanReview, Avoid: item.Avoid, SelectedKnowledge: item.SelectedKnowledge, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt}
+}
+
+func toFeedbackEventData(item contentops.FeedbackEventSummary) types.FeedbackEventData {
+ return types.FeedbackEventData{ID: item.ID, PersonaID: item.PersonaID, ContentPlanID: item.ContentPlanID, DraftID: item.DraftID, Decision: item.Decision, Note: item.Note, Snapshot: item.Snapshot, CreateAt: item.CreateAt}
+}
+
+func toKnowledgeSourceData(item contentops.KnowledgeSourceSummary) types.KnowledgeSourceData {
+ return types.KnowledgeSourceData{ID: item.ID, PersonaID: item.PersonaID, SourceType: item.SourceType, Title: item.Title, Filename: item.Filename, ParsedStatus: item.ParsedStatus, ChunkCount: item.ChunkCount, CreateAt: item.CreateAt}
+}
+
+func toKnowledgeChunkData(item contentops.KnowledgeChunkSummary) types.KnowledgeChunkData {
+ return types.KnowledgeChunkData{ID: item.ID, PersonaID: item.PersonaID, SourceID: item.SourceID, Content: item.Content, Topics: item.Topics, StyleTags: item.StyleTags, RiskLevel: item.RiskLevel, CreateAt: item.CreateAt}
+}
+
+func toFormulaPoolData(item contentops.FormulaPoolSummary) types.FormulaPoolData {
+ return types.FormulaPoolData{ID: item.ID, PersonaID: item.PersonaID, Type: item.Type, Name: item.Name, Pattern: item.Pattern, UseCases: item.UseCases, Avoid: item.Avoid, Weight: item.Weight, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt}
+}
diff --git a/backend/internal/logic/persona/copy_draft_mapper.go b/backend/internal/logic/persona/copy_draft_mapper.go
new file mode 100644
index 0000000..adf9610
--- /dev/null
+++ b/backend/internal/logic/persona/copy_draft_mapper.go
@@ -0,0 +1,40 @@
+package persona
+
+import (
+ copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
+ "haixun-backend/internal/types"
+)
+
+func toCopyDraftData(item copydraftdomain.CopyDraftSummary) types.CopyDraftData {
+ return types.CopyDraftData{
+ ID: item.ID,
+ PersonaID: item.PersonaID,
+ ContentPlanID: item.ContentPlanID,
+ CopyMissionID: item.CopyMissionID,
+ ScanPostID: item.ScanPostID,
+ FormulaID: item.FormulaID,
+ DraftType: item.DraftType,
+ SortOrder: item.SortOrder,
+ Text: item.Text,
+ TopicTag: item.TopicTag,
+ Angle: item.Angle,
+ Hook: item.Hook,
+ Rationale: item.Rationale,
+ ReferenceNotes: item.ReferenceNotes,
+ Sources: item.Sources,
+ AiScore: item.AiScore,
+ FormulaScore: item.FormulaScore,
+ BrandFitScore: item.BrandFitScore,
+ RiskScore: item.RiskScore,
+ SimilarityScore: item.SimilarityScore,
+ EngagementPotential: item.EngagementPotential,
+ FreshnessScore: item.FreshnessScore,
+ ReviewSuggestion: item.ReviewSuggestion,
+ Status: item.Status,
+ PublishQueueID: item.PublishQueueID,
+ PublishedMediaID: item.PublishedMediaID,
+ PublishedPermalink: item.PublishedPermalink,
+ PublishedAt: item.PublishedAt,
+ CreateAt: item.CreateAt,
+ }
+}
diff --git a/backend/internal/logic/persona/create_content_plan_logic.go b/backend/internal/logic/persona/create_content_plan_logic.go
new file mode 100644
index 0000000..b00ddde
--- /dev/null
+++ b/backend/internal/logic/persona/create_content_plan_logic.go
@@ -0,0 +1,41 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "context"
+
+ contentops "haixun-backend/internal/model/content_ops/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type CreateContentPlanLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewCreateContentPlanLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateContentPlanLogic {
+ return &CreateContentPlanLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *CreateContentPlanLogic) CreateContentPlan(req *types.CreateContentPlanHandlerReq) (resp *types.ContentPlanData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ item, err := l.svcCtx.ContentOps.CreateContentPlan(l.ctx, contentops.ContentPlanInput{TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, TopicCandidateID: req.TopicCandidateID, Topic: req.Topic, Mission: req.Mission, TargetAudience: req.TargetAudience, Angle: req.Angle, OpeningType: req.OpeningType, BodyType: req.BodyType, Emotion: req.Emotion, EndingType: req.EndingType, CtaType: req.CtaType, RiskLevel: req.RiskLevel, RequiresHumanReview: req.RequiresHumanReview, Avoid: req.Avoid, SelectedKnowledge: req.SelectedKnowledge})
+ if err != nil {
+ return nil, err
+ }
+ data := toContentPlanData(*item)
+ return &data, nil
+}
diff --git a/backend/internal/logic/persona/create_feedback_event_logic.go b/backend/internal/logic/persona/create_feedback_event_logic.go
new file mode 100644
index 0000000..aa1870e
--- /dev/null
+++ b/backend/internal/logic/persona/create_feedback_event_logic.go
@@ -0,0 +1,41 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "context"
+
+ contentops "haixun-backend/internal/model/content_ops/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type CreateFeedbackEventLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewCreateFeedbackEventLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateFeedbackEventLogic {
+ return &CreateFeedbackEventLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *CreateFeedbackEventLogic) CreateFeedbackEvent(req *types.CreateFeedbackEventHandlerReq) (resp *types.FeedbackEventData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ item, err := l.svcCtx.ContentOps.CreateFeedbackEvent(l.ctx, contentops.FeedbackEventInput{TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, ContentPlanID: req.ContentPlanID, DraftID: req.DraftID, Decision: req.Decision, Note: req.Note, Snapshot: req.Snapshot})
+ if err != nil {
+ return nil, err
+ }
+ data := toFeedbackEventData(*item)
+ return &data, nil
+}
diff --git a/backend/internal/logic/persona/create_formula_pool_logic.go b/backend/internal/logic/persona/create_formula_pool_logic.go
new file mode 100644
index 0000000..b769f8c
--- /dev/null
+++ b/backend/internal/logic/persona/create_formula_pool_logic.go
@@ -0,0 +1,41 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "context"
+
+ contentops "haixun-backend/internal/model/content_ops/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type CreateFormulaPoolLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewCreateFormulaPoolLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateFormulaPoolLogic {
+ return &CreateFormulaPoolLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *CreateFormulaPoolLogic) CreateFormulaPool(req *types.CreateFormulaPoolHandlerReq) (resp *types.FormulaPoolData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ item, err := l.svcCtx.ContentOps.CreateFormulaPool(l.ctx, contentops.FormulaPoolInput{TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, Type: req.Type, Name: req.Name, Pattern: req.Pattern, UseCases: req.UseCases, Avoid: req.Avoid, Weight: req.Weight})
+ if err != nil {
+ return nil, err
+ }
+ data := toFormulaPoolData(*item)
+ return &data, nil
+}
diff --git a/backend/internal/logic/persona/create_knowledge_source_logic.go b/backend/internal/logic/persona/create_knowledge_source_logic.go
new file mode 100644
index 0000000..4e83e74
--- /dev/null
+++ b/backend/internal/logic/persona/create_knowledge_source_logic.go
@@ -0,0 +1,41 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "context"
+
+ contentops "haixun-backend/internal/model/content_ops/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type CreateKnowledgeSourceLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewCreateKnowledgeSourceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateKnowledgeSourceLogic {
+ return &CreateKnowledgeSourceLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *CreateKnowledgeSourceLogic) CreateKnowledgeSource(req *types.CreateKnowledgeSourceHandlerReq) (resp *types.KnowledgeSourceData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ item, err := l.svcCtx.ContentOps.CreateKnowledgeSource(l.ctx, contentops.KnowledgeSourceInput{TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, SourceType: req.SourceType, Title: req.Title, Filename: req.Filename, Content: req.Content, ContentBase64: req.ContentBase64})
+ if err != nil {
+ return nil, err
+ }
+ data := toKnowledgeSourceData(*item)
+ return &data, nil
+}
diff --git a/backend/internal/logic/persona/delete_persona_copy_draft_logic.go b/backend/internal/logic/persona/delete_persona_copy_draft_logic.go
index ee99435..f4298f7 100644
--- a/backend/internal/logic/persona/delete_persona_copy_draft_logic.go
+++ b/backend/internal/logic/persona/delete_persona_copy_draft_logic.go
@@ -34,4 +34,4 @@ func (l *DeletePersonaCopyDraftLogic) DeletePersonaCopyDraft(req *types.CopyDraf
DraftID: draftID,
Message: "草稿已刪除",
}, nil
-}
\ No newline at end of file
+}
diff --git a/backend/internal/logic/persona/delete_style_preset_logic.go b/backend/internal/logic/persona/delete_style_preset_logic.go
new file mode 100644
index 0000000..0327d5b
--- /dev/null
+++ b/backend/internal/logic/persona/delete_style_preset_logic.go
@@ -0,0 +1,35 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type DeleteStylePresetLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewDeleteStylePresetLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteStylePresetLogic {
+ return &DeleteStylePresetLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *DeleteStylePresetLogic) DeleteStylePreset(req *types.StylePresetPath) error {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return err
+ }
+ return l.svcCtx.StylePreset.Delete(l.ctx, tenantID, uid, req.ID, req.PresetID)
+}
diff --git a/backend/internal/logic/persona/generate_from_content_formula_logic.go b/backend/internal/logic/persona/generate_from_content_formula_logic.go
new file mode 100644
index 0000000..f0f3bb1
--- /dev/null
+++ b/backend/internal/logic/persona/generate_from_content_formula_logic.go
@@ -0,0 +1,143 @@
+package persona
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ libformula "haixun-backend/internal/library/formula"
+ "haixun-backend/internal/library/placement"
+ "haixun-backend/internal/library/style8d"
+ "haixun-backend/internal/library/websearch"
+ domai "haixun-backend/internal/model/ai/domain/usecase"
+ aiusecase "haixun-backend/internal/model/ai/usecase"
+ copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity"
+ copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type GenerateFromContentFormulaLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGenerateFromContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateFromContentFormulaLogic {
+ return &GenerateFromContentFormulaLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *GenerateFromContentFormulaLogic) GenerateFromContentFormula(req *types.GenerateFromContentFormulaHandlerReq) (*types.GenerateFromContentFormulaData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ personaID := strings.TrimSpace(req.ID)
+ topic := strings.TrimSpace(req.Topic)
+ if topic == "" {
+ return nil, app.For(code.Persona).InputMissingRequired("topic is required")
+ }
+ persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
+ if err != nil {
+ return nil, err
+ }
+ if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
+ return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析")
+ }
+ formula, err := l.svcCtx.ContentFormula.Get(l.ctx, tenantID, uid, req.AccountID, req.FormulaID)
+ if err != nil {
+ return nil, err
+ }
+ count := req.DraftCount
+ if count <= 0 {
+ count = 1
+ }
+ if count > 5 {
+ count = 5
+ }
+
+ credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
+ if err != nil {
+ return nil, err
+ }
+ providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
+ if err != nil {
+ return nil, err
+ }
+ aiReq := domai.GenerateRequest{
+ Provider: providerID,
+ Model: credential.Model,
+ Credential: domai.Credential{APIKey: credential.APIKey},
+ }
+ personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
+
+ researchNotes := ""
+ if req.UseWebSearch {
+ research, rerr := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
+ if rerr == nil && placement.WebSearchAvailable(research) {
+ memberCtx, merr := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
+ if merr == nil {
+ webClient := websearch.New(memberCtx.WebSearchConfig())
+ if webClient.Enabled() {
+ resp, _ := webClient.Search(l.ctx, websearch.SearchOptions{
+ Query: topic + " " + strings.TrimSpace(req.Brief),
+ Limit: 5,
+ Mode: websearch.ModeKnowledgeExpand,
+ })
+ if len(resp.Results) > 0 {
+ var b strings.Builder
+ for _, snip := range resp.Results {
+ if snip.Title != "" {
+ b.WriteString("- ")
+ b.WriteString(snip.Title)
+ b.WriteString("\n")
+ }
+ if snip.Snippet != "" {
+ b.WriteString(snip.Snippet)
+ b.WriteString("\n")
+ }
+ }
+ researchNotes = strings.TrimSpace(b.String())
+ }
+ }
+ }
+ }
+ }
+
+ list := make([]types.CopyDraftData, 0, count)
+ for i := 0; i < count; i++ {
+ generated, genErr := libformula.GenerateDraft(l.ctx, l.svcCtx.AI, aiReq, libformula.GenerateInput{
+ Topic: topic,
+ Brief: req.Brief,
+ PersonaBlock: personaBlock,
+ ResearchNotes: researchNotes,
+ Formula: *formula,
+ })
+ if genErr != nil {
+ return nil, app.For(code.AI).SvcThirdParty("貼文生成失敗:" + genErr.Error())
+ }
+ saved, saveErr := l.svcCtx.CopyDraft.Create(l.ctx, copydraftdomain.CreateRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ PersonaID: personaID,
+ FormulaID: formula.ID,
+ DraftType: copydraftentity.DraftTypeFormula,
+ Text: generated.Text,
+ TopicTag: generated.TopicTag,
+ Angle: generated.Angle,
+ Hook: generated.Hook,
+ Rationale: generated.Rationale,
+ ReferenceNotes: generated.StructureNotes,
+ Sources: []string{formula.Label},
+ })
+ if saveErr != nil {
+ return nil, saveErr
+ }
+ list = append(list, toCopyDraftData(*saved))
+ }
+ return &types.GenerateFromContentFormulaData{
+ List: list,
+ Message: fmt.Sprintf("已產生 %d 篇可編輯草稿,已加入排程區", len(list)),
+ }, nil
+}
diff --git a/backend/internal/logic/persona/generate_persona_copy_draft_logic.go b/backend/internal/logic/persona/generate_persona_copy_draft_logic.go
index fbfdf12..389d017 100644
--- a/backend/internal/logic/persona/generate_persona_copy_draft_logic.go
+++ b/backend/internal/logic/persona/generate_persona_copy_draft_logic.go
@@ -103,13 +103,15 @@ func (l *GeneratePersonaCopyDraftLogic) GeneratePersonaCopyDraft(
}
}
+ replicateTemp := 0.82
result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
Provider: providerID,
Model: credential.Model,
Credential: domai.Credential{
APIKey: credential.APIKey,
},
- System: libviral.BuildSystemPrompt(),
+ System: libviral.BuildSystemPrompt(),
+ Temperature: &replicateTemp,
Messages: []domai.Message{
{
Role: "user",
@@ -121,6 +123,7 @@ func (l *GeneratePersonaCopyDraftLogic) GeneratePersonaCopyDraft(
OriginalText: post.Text,
AuthorName: post.Author,
StructureAnalysis: analysisText,
+ NarrativeSeed: scanPostID,
}),
},
},
@@ -153,22 +156,7 @@ func (l *GeneratePersonaCopyDraftLogic) GeneratePersonaCopyDraft(
}
return &types.GeneratePersonaCopyDraftData{
- Draft: types.CopyDraftData{
- ID: saved.ID,
- PersonaID: saved.PersonaID,
- CopyMissionID: saved.CopyMissionID,
- ScanPostID: saved.ScanPostID,
- DraftType: saved.DraftType,
- SortOrder: saved.SortOrder,
- Text: saved.Text,
- Angle: saved.Angle,
- Hook: saved.Hook,
- Rationale: saved.Rationale,
- ReferenceNotes: saved.ReferenceNotes,
- Sources: saved.Sources,
- Status: saved.Status,
- CreateAt: saved.CreateAt,
- },
+ Draft: toCopyDraftData(*saved),
Message: "已產出仿寫草稿",
}, nil
}
diff --git a/backend/internal/logic/persona/generate_persona_topic_matrix_logic.go b/backend/internal/logic/persona/generate_persona_topic_matrix_logic.go
new file mode 100644
index 0000000..7557b97
--- /dev/null
+++ b/backend/internal/logic/persona/generate_persona_topic_matrix_logic.go
@@ -0,0 +1,65 @@
+package persona
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ libpersonacopy "haixun-backend/internal/library/personacopy"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type GeneratePersonaTopicMatrixLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGeneratePersonaTopicMatrixLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GeneratePersonaTopicMatrixLogic {
+ return &GeneratePersonaTopicMatrixLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *GeneratePersonaTopicMatrixLogic) GeneratePersonaTopicMatrix(req *types.GeneratePersonaTopicMatrixHandlerReq) (*types.GeneratePersonaTopicMatrixData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ personaID := strings.TrimSpace(req.ID)
+ topic := strings.TrimSpace(req.Topic)
+ if topic == "" {
+ return nil, app.For(code.Persona).InputMissingRequired("topic is required")
+ }
+
+ created, err := libpersonacopy.RunTopicMatrix(l.ctx, libpersonacopy.TopicMatrixDeps{
+ Persona: l.svcCtx.Persona,
+ CopyDraft: l.svcCtx.CopyDraft,
+ ContentOps: l.svcCtx.ContentOps,
+ ThreadsAccount: l.svcCtx.ThreadsAccount,
+ Placement: l.svcCtx.Placement,
+ AI: l.svcCtx.AI,
+ }, libpersonacopy.TopicMatrixInput{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ PersonaID: personaID,
+ ContentPlanID: req.ContentPlanID,
+ Topic: topic,
+ Brief: req.Brief,
+ UseWebSearch: req.UseWebSearch,
+ DraftCount: req.DraftCount,
+ }, nil)
+ if err != nil {
+ return nil, app.For(code.AI).SvcThirdParty("矩陣草稿生成失敗:" + err.Error())
+ }
+
+ list := make([]types.CopyDraftData, 0, len(created))
+ for _, item := range created {
+ list = append(list, toCopyDraftData(item))
+ }
+
+ return &types.GeneratePersonaTopicMatrixData{
+ List: list,
+ Message: fmt.Sprintf("已產生 %d 篇矩陣草稿", len(list)),
+ }, nil
+}
diff --git a/backend/internal/logic/persona/list_content_plans_logic.go b/backend/internal/logic/persona/list_content_plans_logic.go
new file mode 100644
index 0000000..9fbe8fd
--- /dev/null
+++ b/backend/internal/logic/persona/list_content_plans_logic.go
@@ -0,0 +1,43 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListContentPlansLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListContentPlansLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListContentPlansLogic {
+ return &ListContentPlansLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *ListContentPlansLogic) ListContentPlans(req *types.PersonaPath) (resp *types.ListContentPlansData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ items, err := l.svcCtx.ContentOps.ListContentPlans(l.ctx, tenantID, uid, req.ID, 50)
+ if err != nil {
+ return nil, err
+ }
+ list := make([]types.ContentPlanData, 0, len(items))
+ for _, item := range items {
+ list = append(list, toContentPlanData(item))
+ }
+ return &types.ListContentPlansData{List: list}, nil
+}
diff --git a/backend/internal/logic/persona/list_formula_pools_logic.go b/backend/internal/logic/persona/list_formula_pools_logic.go
new file mode 100644
index 0000000..6eabec9
--- /dev/null
+++ b/backend/internal/logic/persona/list_formula_pools_logic.go
@@ -0,0 +1,43 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListFormulaPoolsLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListFormulaPoolsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListFormulaPoolsLogic {
+ return &ListFormulaPoolsLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *ListFormulaPoolsLogic) ListFormulaPools(req *types.PersonaPath) (resp *types.ListFormulaPoolsData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ items, err := l.svcCtx.ContentOps.ListFormulaPools(l.ctx, tenantID, uid, req.ID, 50)
+ if err != nil {
+ return nil, err
+ }
+ list := make([]types.FormulaPoolData, 0, len(items))
+ for _, item := range items {
+ list = append(list, toFormulaPoolData(item))
+ }
+ return &types.ListFormulaPoolsData{List: list}, nil
+}
diff --git a/backend/internal/logic/persona/list_knowledge_chunks_logic.go b/backend/internal/logic/persona/list_knowledge_chunks_logic.go
new file mode 100644
index 0000000..110e052
--- /dev/null
+++ b/backend/internal/logic/persona/list_knowledge_chunks_logic.go
@@ -0,0 +1,43 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListKnowledgeChunksLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListKnowledgeChunksLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListKnowledgeChunksLogic {
+ return &ListKnowledgeChunksLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *ListKnowledgeChunksLogic) ListKnowledgeChunks(req *types.PersonaPath) (resp *types.ListKnowledgeChunksData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ items, err := l.svcCtx.ContentOps.ListKnowledgeChunks(l.ctx, tenantID, uid, req.ID, 80)
+ if err != nil {
+ return nil, err
+ }
+ list := make([]types.KnowledgeChunkData, 0, len(items))
+ for _, item := range items {
+ list = append(list, toKnowledgeChunkData(item))
+ }
+ return &types.ListKnowledgeChunksData{List: list}, nil
+}
diff --git a/backend/internal/logic/persona/list_knowledge_sources_logic.go b/backend/internal/logic/persona/list_knowledge_sources_logic.go
new file mode 100644
index 0000000..c2ba664
--- /dev/null
+++ b/backend/internal/logic/persona/list_knowledge_sources_logic.go
@@ -0,0 +1,43 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListKnowledgeSourcesLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListKnowledgeSourcesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListKnowledgeSourcesLogic {
+ return &ListKnowledgeSourcesLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *ListKnowledgeSourcesLogic) ListKnowledgeSources(req *types.PersonaPath) (resp *types.ListKnowledgeSourcesData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ items, err := l.svcCtx.ContentOps.ListKnowledgeSources(l.ctx, tenantID, uid, req.ID, 50)
+ if err != nil {
+ return nil, err
+ }
+ list := make([]types.KnowledgeSourceData, 0, len(items))
+ for _, item := range items {
+ list = append(list, toKnowledgeSourceData(item))
+ }
+ return &types.ListKnowledgeSourcesData{List: list}, nil
+}
diff --git a/backend/internal/logic/persona/list_persona_content_inbox_logic.go b/backend/internal/logic/persona/list_persona_content_inbox_logic.go
new file mode 100644
index 0000000..ffe2e9c
--- /dev/null
+++ b/backend/internal/logic/persona/list_persona_content_inbox_logic.go
@@ -0,0 +1,112 @@
+package persona
+
+import (
+ "context"
+ "math"
+
+ copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
+ pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type ListPersonaContentInboxLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListPersonaContentInboxLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPersonaContentInboxLogic {
+ return &ListPersonaContentInboxLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ListPersonaContentInboxLogic) ListPersonaContentInbox(req *types.ListPersonaContentInboxHandlerReq) (*types.ListPersonaContentInboxData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ personaID := req.ID
+ if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
+ return nil, err
+ }
+ page := int(req.Page)
+ if page <= 0 {
+ page = 1
+ }
+ pageSize := int(req.PageSize)
+ if pageSize <= 0 {
+ pageSize = 12
+ }
+ result, err := l.svcCtx.CopyDraft.ListInbox(l.ctx, copydraftdomain.InboxListRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ PersonaID: personaID,
+ Status: req.Status,
+ RangeStart: req.RangeStart,
+ RangeEnd: req.RangeEnd,
+ Page: page,
+ PageSize: pageSize,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ queueIDs := make([]string, 0)
+ for _, item := range result.Items {
+ if item.PublishQueueID != "" {
+ queueIDs = append(queueIDs, item.PublishQueueID)
+ }
+ }
+ queueByID := map[string]pqdomain.QueueItemSummary{}
+ if len(queueIDs) > 0 {
+ queueItems, qerr := l.svcCtx.PublishQueue.ListByIDs(l.ctx, tenantID, uid, queueIDs)
+ if qerr == nil {
+ for _, q := range queueItems {
+ queueByID[q.ID] = q
+ }
+ }
+ }
+
+ list := make([]types.ContentInboxItemData, 0, len(result.Items))
+ for _, item := range result.Items {
+ queueItem := queueByID[item.PublishQueueID]
+ scheduledAt := queueItem.ScheduledAt
+ lifecycle, groupDate := inboxLifecycle(item, scheduledAt)
+ draftData := toCopyDraftData(item)
+ if queueItem.TopicTag != "" {
+ draftData.TopicTag = queueItem.TopicTag
+ }
+ list = append(list, types.ContentInboxItemData{
+ CopyDraftData: draftData,
+ ScheduledAt: scheduledAt,
+ Lifecycle: lifecycle,
+ GroupDate: groupDate,
+ })
+ }
+
+ totalPages := int64(0)
+ if pageSize > 0 {
+ totalPages = int64(math.Ceil(float64(result.Total) / float64(pageSize)))
+ }
+ return &types.ListPersonaContentInboxData{
+ Pagination: types.PaginationData{
+ Total: result.Total,
+ Page: int64(page),
+ PageSize: int64(pageSize),
+ TotalPages: totalPages,
+ },
+ List: list,
+ }, nil
+}
+
+func inboxLifecycle(item copydraftdomain.CopyDraftSummary, scheduledAt int64) (lifecycle string, groupDate int64) {
+ if item.PublishedAt > 0 {
+ return "published", item.PublishedAt
+ }
+ if item.PublishQueueID != "" || scheduledAt > 0 {
+ if scheduledAt > 0 {
+ return "scheduled", scheduledAt
+ }
+ return "scheduled", item.CreateAt
+ }
+ return "draft", item.CreateAt
+}
diff --git a/backend/internal/logic/persona/list_persona_copy_drafts_logic.go b/backend/internal/logic/persona/list_persona_copy_drafts_logic.go
index de35a46..c3f5042 100644
--- a/backend/internal/logic/persona/list_persona_copy_drafts_logic.go
+++ b/backend/internal/logic/persona/list_persona_copy_drafts_logic.go
@@ -33,22 +33,7 @@ func (l *ListPersonaCopyDraftsLogic) ListPersonaCopyDrafts(req *types.PersonaPat
}
list := make([]types.CopyDraftData, 0, len(drafts))
for _, item := range drafts {
- list = append(list, types.CopyDraftData{
- ID: item.ID,
- PersonaID: item.PersonaID,
- CopyMissionID: item.CopyMissionID,
- ScanPostID: item.ScanPostID,
- DraftType: item.DraftType,
- SortOrder: item.SortOrder,
- Text: item.Text,
- Angle: item.Angle,
- Hook: item.Hook,
- Rationale: item.Rationale,
- ReferenceNotes: item.ReferenceNotes,
- Sources: item.Sources,
- Status: item.Status,
- CreateAt: item.CreateAt,
- })
+ list = append(list, toCopyDraftData(item))
}
return &types.ListPersonaCopyDraftsData{List: list, Total: len(list)}, nil
}
diff --git a/backend/internal/logic/persona/list_style_presets_logic.go b/backend/internal/logic/persona/list_style_presets_logic.go
new file mode 100644
index 0000000..c190192
--- /dev/null
+++ b/backend/internal/logic/persona/list_style_presets_logic.go
@@ -0,0 +1,43 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListStylePresetsLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListStylePresetsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListStylePresetsLogic {
+ return &ListStylePresetsLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *ListStylePresetsLogic) ListStylePresets(req *types.PersonaPath) (resp *types.ListStylePresetsData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ items, err := l.svcCtx.StylePreset.List(l.ctx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+ list := make([]types.StylePresetData, 0, len(items))
+ for _, item := range items {
+ list = append(list, toStylePresetData(item))
+ }
+ return &types.ListStylePresetsData{List: list}, nil
+}
diff --git a/backend/internal/logic/persona/list_topic_candidates_logic.go b/backend/internal/logic/persona/list_topic_candidates_logic.go
new file mode 100644
index 0000000..b670d0f
--- /dev/null
+++ b/backend/internal/logic/persona/list_topic_candidates_logic.go
@@ -0,0 +1,43 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListTopicCandidatesLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListTopicCandidatesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListTopicCandidatesLogic {
+ return &ListTopicCandidatesLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *ListTopicCandidatesLogic) ListTopicCandidates(req *types.PersonaPath) (resp *types.ListTopicCandidatesData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ items, err := l.svcCtx.ContentOps.ListTopicCandidates(l.ctx, tenantID, uid, req.ID, 50)
+ if err != nil {
+ return nil, err
+ }
+ list := make([]types.TopicCandidateData, 0, len(items))
+ for _, item := range items {
+ list = append(list, toTopicCandidateData(item))
+ }
+ return &types.ListTopicCandidatesData{List: list}, nil
+}
diff --git a/backend/internal/logic/persona/mapper.go b/backend/internal/logic/persona/mapper.go
index 0f852e7..3d3c6dc 100644
--- a/backend/internal/logic/persona/mapper.go
+++ b/backend/internal/logic/persona/mapper.go
@@ -2,6 +2,7 @@ package persona
import (
domusecase "haixun-backend/internal/model/persona/domain/usecase"
+ stylepresetdomain "haixun-backend/internal/model/style_preset/domain/usecase"
"haixun-backend/internal/types"
)
@@ -55,3 +56,11 @@ func toPersonaPatch(req *types.UpdatePersonaReq) domusecase.PersonaPatch {
StyleBenchmark: req.StyleBenchmark,
}
}
+
+func toStylePresetData(item stylepresetdomain.PresetSummary) types.StylePresetData {
+ return types.StylePresetData{
+ ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Tone: item.Tone,
+ CTA: append([]string(nil), item.CTA...), BannedWords: append([]string(nil), item.BannedWords...),
+ Notes: item.Notes, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt,
+ }
+}
diff --git a/backend/internal/logic/persona/prune_persona_copy_drafts_logic.go b/backend/internal/logic/persona/prune_persona_copy_drafts_logic.go
new file mode 100644
index 0000000..cfdc3c4
--- /dev/null
+++ b/backend/internal/logic/persona/prune_persona_copy_drafts_logic.go
@@ -0,0 +1,46 @@
+package persona
+
+import (
+ "context"
+ "fmt"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type PrunePersonaCopyDraftsLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewPrunePersonaCopyDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PrunePersonaCopyDraftsLogic {
+ return &PrunePersonaCopyDraftsLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *PrunePersonaCopyDraftsLogic) PrunePersonaCopyDrafts(req *types.PrunePersonaCopyDraftsHandlerReq) (*types.PrunePersonaCopyDraftsData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ personaID := req.ID
+ if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
+ return nil, err
+ }
+ keep := int(req.Keep)
+ if keep <= 0 {
+ keep = 40
+ }
+ deleted, err := l.svcCtx.CopyDraft.PruneIdleDrafts(l.ctx, tenantID, uid, personaID, keep)
+ if err != nil {
+ return nil, err
+ }
+ message := fmt.Sprintf("已保留最近 %d 篇草稿", keep)
+ if deleted > 0 {
+ message = fmt.Sprintf("已刪除 %d 篇多餘舊草稿,保留最近 %d 篇", deleted, keep)
+ }
+ return &types.PrunePersonaCopyDraftsData{
+ Deleted: deleted,
+ Kept: keep,
+ Message: message,
+ }, nil
+}
diff --git a/backend/internal/logic/persona/publish_persona_copy_draft_logic.go b/backend/internal/logic/persona/publish_persona_copy_draft_logic.go
index 4536e00..78d08d6 100644
--- a/backend/internal/logic/persona/publish_persona_copy_draft_logic.go
+++ b/backend/internal/logic/persona/publish_persona_copy_draft_logic.go
@@ -3,12 +3,14 @@ package persona
import (
"context"
"strings"
+ "time"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
libthreads "haixun-backend/internal/library/threadsapi"
"haixun-backend/internal/library/threadspost"
copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
+ publishqueuedomain "haixun-backend/internal/model/publish_queue/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
@@ -57,19 +59,31 @@ func (l *PublishPersonaCopyDraftLogic) PublishPersonaCopyDraft(
if err := threadspost.ValidatePublish(text); err != nil {
return nil, app.For(code.Persona).InputInvalidFormat(err.Error())
}
+ topicTag := firstNonEmpty(req.TopicTag, draft.TopicTag)
+ patch := copydraftdomain.CopyDraftPatch{}
+ needsUpdate := false
if strings.TrimSpace(req.Text) != "" && req.Text != draft.Text {
- updatedText, err := l.svcCtx.CopyDraft.Update(l.ctx, copydraftdomain.UpdateRequest{
+ patch.Text = &req.Text
+ needsUpdate = true
+ }
+ if strings.TrimSpace(req.TopicTag) != "" && req.TopicTag != draft.TopicTag {
+ patch.TopicTag = &req.TopicTag
+ needsUpdate = true
+ }
+ if needsUpdate {
+ updated, err := l.svcCtx.CopyDraft.Update(l.ctx, copydraftdomain.UpdateRequest{
TenantID: tenantID,
OwnerUID: uid,
PersonaID: personaID,
DraftID: draftID,
- Patch: copydraftdomain.CopyDraftPatch{Text: &req.Text},
+ Patch: patch,
})
if err != nil {
return nil, err
}
- draft = updatedText
- text = strings.TrimSpace(updatedText.Text)
+ draft = updated
+ text = strings.TrimSpace(updated.Text)
+ topicTag = firstNonEmpty(updated.TopicTag, req.TopicTag)
}
cred, err := l.svcCtx.ThreadsAccount.ResolveMemberThreadsPublishCredential(l.ctx, tenantID, uid)
@@ -80,11 +94,26 @@ func (l *PublishPersonaCopyDraftLogic) PublishPersonaCopyDraft(
ThreadsUserID: cred.ThreadsUserID,
AccessToken: cred.AccessToken,
Text: text,
+ TopicTag: topicTag,
})
if err != nil {
return nil, app.For(code.ThreadsAccount).SvcThirdParty("Threads API 發布貼文失敗:" + err.Error())
}
+ now := time.Now().UnixNano()
+ if cred.AccountID != "" {
+ _, _ = l.svcCtx.PublishQueue.RecordPublished(l.ctx, publishqueuedomain.RecordPublishedRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ AccountID: cred.AccountID,
+ Text: text,
+ TopicTag: topicTag,
+ MediaID: result.MediaID,
+ Permalink: result.Permalink,
+ PublishedAt: now,
+ })
+ }
+
updated, err := l.svcCtx.CopyDraft.MarkPublished(l.ctx, copydraftdomain.MarkPublishedRequest{
TenantID: tenantID,
OwnerUID: uid,
diff --git a/backend/internal/logic/persona/schedule_persona_copy_draft_logic.go b/backend/internal/logic/persona/schedule_persona_copy_draft_logic.go
new file mode 100644
index 0000000..55cdfca
--- /dev/null
+++ b/backend/internal/logic/persona/schedule_persona_copy_draft_logic.go
@@ -0,0 +1,83 @@
+package persona
+
+import (
+ "context"
+ "fmt"
+
+ "haixun-backend/internal/library/clock"
+ copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
+ pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type SchedulePersonaCopyDraftLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewSchedulePersonaCopyDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SchedulePersonaCopyDraftLogic {
+ return &SchedulePersonaCopyDraftLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *SchedulePersonaCopyDraftLogic) SchedulePersonaCopyDraft(req *types.SchedulePersonaCopyDraftHandlerReq) (*types.ScheduleCopyDraftsData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.ID); err != nil {
+ return nil, err
+ }
+ if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.AccountID); err != nil {
+ return nil, err
+ }
+ draft, err := l.svcCtx.CopyDraft.Get(l.ctx, tenantID, uid, req.ID, req.DraftID)
+ if err != nil {
+ return nil, err
+ }
+ if draft.PublishQueueID != "" {
+ queueItems, err := l.svcCtx.PublishQueue.ListByIDs(l.ctx, tenantID, uid, []string{draft.PublishQueueID})
+ if err != nil {
+ return nil, err
+ }
+ if len(queueItems) > 0 {
+ return &types.ScheduleCopyDraftsData{
+ Scheduled: 1,
+ List: []types.PublishQueueItemData{publishQueueData(&queueItems[0])},
+ Message: fmt.Sprintf("草稿 %s 已排程", draft.ID),
+ }, nil
+ }
+ }
+ scheduledAt := req.ScheduledAt
+ if scheduledAt <= 0 {
+ scheduledAt = clock.NowUnixNano()
+ }
+ item, err := l.svcCtx.PublishQueue.Create(l.ctx, pqdomain.CreateRequest{
+ TenantID: tenantID, OwnerUID: uid, AccountID: req.AccountID,
+ PersonaID: req.ID, CopyMissionID: draft.CopyMissionID, CopyDraftID: draft.ID,
+ Text: draft.Text, TopicTag: firstNonEmpty(req.TopicTag, draft.TopicTag), ScheduledAt: scheduledAt,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if _, err := l.svcCtx.CopyDraft.MarkScheduled(l.ctx, copydraftdomain.MarkScheduledRequest{
+ TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, DraftID: draft.ID, QueueID: item.ID,
+ }); err != nil {
+ _ = l.svcCtx.PublishQueue.Delete(l.ctx, tenantID, uid, req.AccountID, item.ID)
+ return nil, err
+ }
+ return &types.ScheduleCopyDraftsData{
+ Scheduled: 1,
+ List: []types.PublishQueueItemData{publishQueueData(item)},
+ Message: fmt.Sprintf("已排程草稿 %s", draft.ID),
+ }, nil
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if value != "" {
+ return value
+ }
+ }
+ return ""
+}
diff --git a/backend/internal/logic/persona/schedule_persona_drafts_logic.go b/backend/internal/logic/persona/schedule_persona_drafts_logic.go
new file mode 100644
index 0000000..fe22150
--- /dev/null
+++ b/backend/internal/logic/persona/schedule_persona_drafts_logic.go
@@ -0,0 +1,107 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "context"
+ "fmt"
+
+ "haixun-backend/internal/library/publishschedule"
+ copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
+ pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type SchedulePersonaDraftsLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewSchedulePersonaDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SchedulePersonaDraftsLogic {
+ return &SchedulePersonaDraftsLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *SchedulePersonaDraftsLogic) SchedulePersonaDrafts(req *types.SchedulePersonaDraftsHandlerReq) (resp *types.ScheduleCopyDraftsData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.ID); err != nil {
+ return nil, err
+ }
+ if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.AccountID); err != nil {
+ return nil, err
+ }
+ times := scheduleTimes(req.StartAt, req.Timezone, req.Slots, len(req.DraftIDs))
+ list := make([]types.PublishQueueItemData, 0, len(req.DraftIDs))
+ for idx, draftID := range req.DraftIDs {
+ draft, err := l.svcCtx.CopyDraft.Get(l.ctx, tenantID, uid, req.ID, draftID)
+ if err != nil {
+ return nil, err
+ }
+ if draft.PublishQueueID != "" {
+ queueItems, err := l.svcCtx.PublishQueue.ListByIDs(l.ctx, tenantID, uid, []string{draft.PublishQueueID})
+ if err != nil {
+ return nil, err
+ }
+ if len(queueItems) > 0 {
+ list = append(list, publishQueueData(&queueItems[0]))
+ continue
+ }
+ }
+ scheduledAt := int64(0)
+ if idx < len(times) {
+ scheduledAt = times[idx]
+ }
+ item, err := l.svcCtx.PublishQueue.Create(l.ctx, pqdomain.CreateRequest{
+ TenantID: tenantID, OwnerUID: uid, AccountID: req.AccountID,
+ PersonaID: req.ID, CopyMissionID: draft.CopyMissionID, CopyDraftID: draft.ID,
+ Text: draft.Text, TopicTag: draft.TopicTag, ScheduledAt: scheduledAt,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if _, err := l.svcCtx.CopyDraft.MarkScheduled(l.ctx, copydraftdomain.MarkScheduledRequest{
+ TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, DraftID: draft.ID, QueueID: item.ID,
+ }); err != nil {
+ _ = l.svcCtx.PublishQueue.Delete(l.ctx, tenantID, uid, req.AccountID, item.ID)
+ return nil, err
+ }
+ list = append(list, publishQueueData(item))
+ }
+ return &types.ScheduleCopyDraftsData{
+ Scheduled: len(list), List: list, Message: fmt.Sprintf("已排程 %d 篇草稿", len(list)),
+ }, nil
+}
+
+func scheduleTimes(startAt int64, timezone string, slots []types.PublishSlotData, count int) []int64 {
+ converted := make([]publishschedule.Slot, 0, len(slots))
+ for _, slot := range slots {
+ converted = append(converted, publishschedule.Slot{Weekday: slot.Weekday, Time: slot.Time})
+ }
+ return publishschedule.BuildSchedule(startAt, timezone, converted, count)
+}
+
+func publishQueueData(item *pqdomain.QueueItemSummary) types.PublishQueueItemData {
+ if item == nil {
+ return types.PublishQueueItemData{}
+ }
+ return types.PublishQueueItemData{
+ ID: item.ID, AccountID: item.AccountID, PersonaID: item.PersonaID,
+ CopyMissionID: item.CopyMissionID, CopyDraftID: item.CopyDraftID,
+ Text: item.Text, TopicTag: item.TopicTag, ScheduledAt: item.ScheduledAt, Status: item.Status,
+ MediaID: item.MediaID, Permalink: item.Permalink, PublishedAt: item.PublishedAt,
+ ErrorMessage: item.ErrorMessage, RetryCount: item.RetryCount, LastAttemptAt: item.LastAttemptAt,
+ NextAttemptAt: item.NextAttemptAt, MissedAt: item.MissedAt, PausedReason: item.PausedReason,
+ CreateAt: item.CreateAt, UpdateAt: item.UpdateAt,
+ }
+}
diff --git a/backend/internal/logic/persona/start_persona_formula_draft_job_logic.go b/backend/internal/logic/persona/start_persona_formula_draft_job_logic.go
new file mode 100644
index 0000000..7faa3af
--- /dev/null
+++ b/backend/internal/logic/persona/start_persona_formula_draft_job_logic.go
@@ -0,0 +1,83 @@
+package persona
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ jobdom "haixun-backend/internal/model/job/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type StartPersonaFormulaDraftJobLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewStartPersonaFormulaDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaFormulaDraftJobLogic {
+ return &StartPersonaFormulaDraftJobLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *StartPersonaFormulaDraftJobLogic) StartPersonaFormulaDraftJob(req *types.StartPersonaFormulaDraftJobHandlerReq) (*types.StartPersonaFormulaDraftJobData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ personaID := strings.TrimSpace(req.ID)
+ accountID := strings.TrimSpace(req.AccountID)
+ formulaID := strings.TrimSpace(req.FormulaID)
+ topic := strings.TrimSpace(req.Topic)
+ if accountID == "" || formulaID == "" || topic == "" {
+ return nil, app.For(code.Persona).InputMissingRequired("account_id, formula_id, and topic are required")
+ }
+ if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
+ return nil, err
+ }
+ credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
+ if err != nil {
+ return nil, err
+ }
+
+ count := req.DraftCount
+ if count <= 0 {
+ count = 1
+ }
+ if count > 5 {
+ count = 5
+ }
+
+ run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
+ TemplateType: "generate-formula-draft",
+ Scope: "persona",
+ ScopeID: personaID,
+ TenantID: tenantID,
+ OwnerUID: uid,
+ Payload: map[string]any{
+ "tenant_id": tenantID,
+ "owner_uid": uid,
+ "persona_id": personaID,
+ "account_id": accountID,
+ "formula_id": formulaID,
+ "topic": topic,
+ "brief": strings.TrimSpace(req.Brief),
+ "use_web_search": req.UseWebSearch,
+ "draft_count": count,
+ "ai_provider": credential.Provider,
+ "ai_model": credential.Model,
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &types.StartPersonaFormulaDraftJobData{
+ JobID: run.ID.Hex(),
+ Status: string(run.Status),
+ AiProvider: credential.Provider,
+ AiModel: credential.Model,
+ Message: fmt.Sprintf("貼文改寫已在背景執行(%s / %s),可自由切換頁面", credential.Provider, credential.Model),
+ }, nil
+}
diff --git a/backend/internal/logic/persona/start_persona_rewrite_draft_job_logic.go b/backend/internal/logic/persona/start_persona_rewrite_draft_job_logic.go
new file mode 100644
index 0000000..76dee67
--- /dev/null
+++ b/backend/internal/logic/persona/start_persona_rewrite_draft_job_logic.go
@@ -0,0 +1,87 @@
+package persona
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ jobdom "haixun-backend/internal/model/job/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type StartPersonaRewriteDraftJobLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewStartPersonaRewriteDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaRewriteDraftJobLogic {
+ return &StartPersonaRewriteDraftJobLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *StartPersonaRewriteDraftJobLogic) StartPersonaRewriteDraftJob(req *types.StartPersonaRewriteDraftJobHandlerReq) (*types.StartPersonaRewriteDraftJobData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ personaID := strings.TrimSpace(req.ID)
+ accountID := strings.TrimSpace(req.AccountID)
+ referenceText := strings.TrimSpace(req.ReferenceText)
+ topic := strings.TrimSpace(req.Topic)
+ if accountID == "" || referenceText == "" || topic == "" {
+ return nil, app.For(code.Persona).InputMissingRequired("account_id, reference_text, and topic are required")
+ }
+ if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
+ return nil, err
+ }
+ if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, accountID); err != nil {
+ return nil, err
+ }
+ credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
+ if err != nil {
+ return nil, err
+ }
+
+ count := req.DraftCount
+ if count <= 0 {
+ count = 1
+ }
+ if count > 5 {
+ count = 5
+ }
+
+ run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
+ TemplateType: "generate-rewrite-draft",
+ Scope: "persona",
+ ScopeID: personaID,
+ TenantID: tenantID,
+ OwnerUID: uid,
+ Payload: map[string]any{
+ "tenant_id": tenantID,
+ "owner_uid": uid,
+ "persona_id": personaID,
+ "account_id": accountID,
+ "reference_text": referenceText,
+ "topic": topic,
+ "brief": strings.TrimSpace(req.Brief),
+ "save_label": strings.TrimSpace(req.SaveLabel),
+ "use_web_search": req.UseWebSearch,
+ "draft_count": count,
+ "ai_provider": credential.Provider,
+ "ai_model": credential.Model,
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &types.StartPersonaRewriteDraftJobData{
+ JobID: run.ID.Hex(),
+ Status: string(run.Status),
+ AiProvider: credential.Provider,
+ AiModel: credential.Model,
+ Message: fmt.Sprintf("貼文改寫已在背景執行(%s / %s),可自由切換頁面", credential.Provider, credential.Model),
+ }, nil
+}
diff --git a/backend/internal/logic/persona/start_persona_style_analysis_from_text_logic.go b/backend/internal/logic/persona/start_persona_style_analysis_from_text_logic.go
new file mode 100644
index 0000000..e806988
--- /dev/null
+++ b/backend/internal/logic/persona/start_persona_style_analysis_from_text_logic.go
@@ -0,0 +1,154 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "context"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ libprompt "haixun-backend/internal/library/prompt"
+ "haixun-backend/internal/library/style8d"
+ domai "haixun-backend/internal/model/ai/domain/usecase"
+ aiusecase "haixun-backend/internal/model/ai/usecase"
+ personausecase "haixun-backend/internal/model/persona/domain/usecase"
+ tausecase "haixun-backend/internal/model/threads_account/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type StartPersonaStyleAnalysisFromTextLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewStartPersonaStyleAnalysisFromTextLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaStyleAnalysisFromTextLogic {
+ return &StartPersonaStyleAnalysisFromTextLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *StartPersonaStyleAnalysisFromTextLogic) StartPersonaStyleAnalysisFromText(
+ req *types.StartPersonaStyleAnalysisFromTextHandlerReq,
+) (resp *types.StartPersonaStyleAnalysisFromTextData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ if req == nil {
+ return nil, app.For(code.Persona).InputMissingRequired("request is required")
+ }
+
+ texts, err := style8d.NormalizeReferenceTexts(req.ReferenceTexts, req.RawText)
+ if err != nil {
+ return nil, app.For(code.Persona).InputInvalidFormat(err.Error())
+ }
+
+ persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ activeAccountID := ""
+ if member, memberErr := l.svcCtx.Member.GetByUID(l.ctx, tenantID, uid); memberErr == nil {
+ activeAccountID = member.ActiveThreadsAccountID
+ }
+
+ credential, err := l.resolveResearchCredential(tenantID, uid, activeAccountID)
+ if err != nil {
+ return nil, err
+ }
+
+ providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
+ if err != nil {
+ return nil, err
+ }
+
+ posts := style8d.TextsToPosts(texts)
+ input := style8d.AnalyzeInput{
+ Mode: style8d.AnalyzeModeManual,
+ SourceLabel: strings.TrimSpace(req.SourceLabel),
+ Brief: strings.TrimSpace(persona.Brief),
+ Posts: posts,
+ }
+
+ systemPrompt, err := libprompt.Style8DSystem()
+ if err != nil {
+ return nil, app.For(code.AI).SysInternal("prompt config load failed")
+ }
+ maxTokens := 8192
+ result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
+ Provider: providerID,
+ Model: credential.Model,
+ Credential: domai.Credential{
+ APIKey: credential.APIKey,
+ },
+ System: systemPrompt,
+ Messages: []domai.Message{
+ {Role: "user", Content: input.BuildUserPrompt()},
+ },
+ MaxTokens: &maxTokens,
+ })
+ if err != nil {
+ if strings.Contains(err.Error(), "HTTP 401") {
+ err = app.For(code.AI).SvcThirdParty(
+ "8D AI 分析授權失敗:請到「設定 > 帳號 AI 設定」確認研究用 provider=" +
+ credential.Provider + "、model=" + credential.Model + ",並重新貼上對應 provider 的 API key",
+ )
+ }
+ return nil, err
+ }
+
+ parsed, err := style8d.ParseLLMOutput(result.Text)
+ if err != nil {
+ return nil, app.For(code.AI).SvcThirdParty("8D LLM 回傳無法解析:" + err.Error())
+ }
+ profile := input.BuildStoredProfile(parsed)
+ profileJSON, err := profile.JSON()
+ if err != nil {
+ return nil, err
+ }
+
+ benchmark := input.StyleBenchmark()
+ personaDraft := strings.TrimSpace(profile.PersonaDraft)
+ patch := personausecase.PersonaPatch{
+ StyleProfile: &profileJSON,
+ StyleBenchmark: &benchmark,
+ }
+ if personaDraft != "" {
+ patch.Persona = &personaDraft
+ }
+ updated, err := l.svcCtx.Persona.Update(l.ctx, personausecase.UpdateRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ PersonaID: req.ID,
+ Patch: patch,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ out := toPersonaData(*updated)
+ return &types.StartPersonaStyleAnalysisFromTextData{
+ Persona: out,
+ PostCount: len(posts),
+ Message: "8D 分析完成,風格檔案已寫入人設",
+ }, nil
+}
+
+func (l *StartPersonaStyleAnalysisFromTextLogic) resolveResearchCredential(
+ tenantID, uid, activeAccountID string,
+) (*tausecase.WorkerAiCredential, error) {
+ accountID := strings.TrimSpace(activeAccountID)
+ if accountID != "" {
+ return l.svcCtx.ThreadsAccount.ResolveWorkerAiCredential(l.ctx, tenantID, uid, accountID)
+ }
+ return l.svcCtx.ThreadsAccount.ResolveMemberResearchAiCredential(l.ctx, tenantID, uid)
+}
diff --git a/backend/internal/logic/persona/start_persona_topic_matrix_job_logic.go b/backend/internal/logic/persona/start_persona_topic_matrix_job_logic.go
new file mode 100644
index 0000000..acf1f3b
--- /dev/null
+++ b/backend/internal/logic/persona/start_persona_topic_matrix_job_logic.go
@@ -0,0 +1,80 @@
+package persona
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ jobdom "haixun-backend/internal/model/job/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type StartPersonaTopicMatrixJobLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewStartPersonaTopicMatrixJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaTopicMatrixJobLogic {
+ return &StartPersonaTopicMatrixJobLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *StartPersonaTopicMatrixJobLogic) StartPersonaTopicMatrixJob(req *types.StartPersonaTopicMatrixJobHandlerReq) (*types.StartPersonaTopicMatrixJobData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ personaID := strings.TrimSpace(req.ID)
+ topic := strings.TrimSpace(req.Topic)
+ if topic == "" {
+ return nil, app.For(code.Persona).InputMissingRequired("topic is required")
+ }
+ if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
+ return nil, err
+ }
+ credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
+ if err != nil {
+ return nil, err
+ }
+
+ count := req.DraftCount
+ if count <= 0 {
+ count = 5
+ }
+ if count > 5 {
+ count = 5
+ }
+
+ run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
+ TemplateType: "generate-topic-matrix",
+ Scope: "persona",
+ ScopeID: personaID,
+ TenantID: tenantID,
+ OwnerUID: uid,
+ Payload: map[string]any{
+ "tenant_id": tenantID,
+ "owner_uid": uid,
+ "persona_id": personaID,
+ "content_plan_id": strings.TrimSpace(req.ContentPlanID),
+ "topic": topic,
+ "brief": strings.TrimSpace(req.Brief),
+ "use_web_search": req.UseWebSearch,
+ "draft_count": count,
+ "ai_provider": credential.Provider,
+ "ai_model": credential.Model,
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &types.StartPersonaTopicMatrixJobData{
+ JobID: run.ID.Hex(),
+ Status: string(run.Status),
+ AiProvider: credential.Provider,
+ AiModel: credential.Model,
+ Message: fmt.Sprintf("話題矩陣已在背景執行(%s / %s),可自由切換頁面", credential.Provider, credential.Model),
+ }, nil
+}
diff --git a/backend/internal/logic/persona/update_content_plan_logic.go b/backend/internal/logic/persona/update_content_plan_logic.go
new file mode 100644
index 0000000..97d939a
--- /dev/null
+++ b/backend/internal/logic/persona/update_content_plan_logic.go
@@ -0,0 +1,41 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "context"
+
+ contentops "haixun-backend/internal/model/content_ops/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type UpdateContentPlanLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewUpdateContentPlanLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateContentPlanLogic {
+ return &UpdateContentPlanLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *UpdateContentPlanLogic) UpdateContentPlan(req *types.UpdateContentPlanHandlerReq) (resp *types.ContentPlanData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ item, err := l.svcCtx.ContentOps.UpdateContentPlan(l.ctx, tenantID, uid, req.ID, req.ContentPlanID, contentops.ContentPlanPatch{Topic: req.Topic, Mission: req.Mission, TargetAudience: req.TargetAudience, Angle: req.Angle, OpeningType: req.OpeningType, BodyType: req.BodyType, Emotion: req.Emotion, EndingType: req.EndingType, CtaType: req.CtaType, RiskLevel: req.RiskLevel, RequiresHumanReview: req.RequiresHumanReview, Avoid: req.Avoid, SelectedKnowledge: req.SelectedKnowledge, Status: req.Status})
+ if err != nil {
+ return nil, err
+ }
+ data := toContentPlanData(*item)
+ return &data, nil
+}
diff --git a/backend/internal/logic/persona/update_persona_copy_draft_logic.go b/backend/internal/logic/persona/update_persona_copy_draft_logic.go
index 07c78cb..5ce3bbb 100644
--- a/backend/internal/logic/persona/update_persona_copy_draft_logic.go
+++ b/backend/internal/logic/persona/update_persona_copy_draft_logic.go
@@ -45,33 +45,17 @@ func (l *UpdatePersonaCopyDraftLogic) UpdatePersonaCopyDraft(
PersonaID: personaID,
DraftID: draftID,
Patch: copydraftdomain.CopyDraftPatch{
- Text: req.Text,
- Hook: req.Hook,
- Angle: req.Angle,
- Status: req.Status,
+ Text: req.Text,
+ TopicTag: req.TopicTag,
+ Hook: req.Hook,
+ Angle: req.Angle,
+ Rationale: req.Rationale,
+ Status: req.Status,
},
})
if err != nil {
return nil, err
}
- data := types.CopyDraftData{
- ID: updated.ID,
- PersonaID: updated.PersonaID,
- CopyMissionID: updated.CopyMissionID,
- ScanPostID: updated.ScanPostID,
- DraftType: updated.DraftType,
- SortOrder: updated.SortOrder,
- Text: updated.Text,
- Angle: updated.Angle,
- Hook: updated.Hook,
- Rationale: updated.Rationale,
- ReferenceNotes: updated.ReferenceNotes,
- Sources: updated.Sources,
- Status: updated.Status,
- PublishedMediaID: updated.PublishedMediaID,
- PublishedPermalink: updated.PublishedPermalink,
- PublishedAt: updated.PublishedAt,
- CreateAt: updated.CreateAt,
- }
+ data := toCopyDraftData(*updated)
return &data, nil
}
diff --git a/backend/internal/logic/persona/upsert_style_preset_logic.go b/backend/internal/logic/persona/upsert_style_preset_logic.go
new file mode 100644
index 0000000..33ca67a
--- /dev/null
+++ b/backend/internal/logic/persona/upsert_style_preset_logic.go
@@ -0,0 +1,44 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package persona
+
+import (
+ "context"
+
+ stylepresetdomain "haixun-backend/internal/model/style_preset/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type UpsertStylePresetLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewUpsertStylePresetLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertStylePresetLogic {
+ return &UpsertStylePresetLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *UpsertStylePresetLogic) UpsertStylePreset(req *types.UpsertStylePresetHandlerReq) (resp *types.StylePresetData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ item, err := l.svcCtx.StylePreset.Upsert(l.ctx, stylepresetdomain.UpsertRequest{
+ TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, PresetID: req.PresetID,
+ Name: req.Name, Tone: req.Tone, CTA: req.CTA, BannedWords: req.BannedWords, Notes: req.Notes, Apply: req.Apply,
+ })
+ if err != nil {
+ return nil, err
+ }
+ data := toStylePresetData(*item)
+ return &data, nil
+}
diff --git a/backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go b/backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go
index 61fe85e..a00aac5 100644
--- a/backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go
+++ b/backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go
@@ -2,7 +2,10 @@ package placement_topic
import (
"context"
+ "strings"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
brandlogic "haixun-backend/internal/logic/brand"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@@ -29,6 +32,13 @@ func (l *GeneratePlacementTopicOutreachDraftsLogic) GeneratePlacementTopicOutrea
if err != nil {
return nil, err
}
+ if strings.TrimSpace(req.VoicePersonaID) == "" {
+ return nil, app.For(code.Brand).InputMissingRequired("voice_persona_id is required")
+ }
+ productID := strings.TrimSpace(req.ProductID)
+ if productID == "" {
+ productID = strings.TrimSpace(scope.Topic.ProductID)
+ }
return brandlogic.NewGenerateOutreachDraftsLogic(l.ctx, l.svcCtx).GenerateOutreachDrafts(&types.GenerateOutreachDraftsHandlerReq{
BrandPath: types.BrandPath{ID: scope.BrandID},
GenerateOutreachDraftsReq: types.GenerateOutreachDraftsReq{
@@ -36,7 +46,7 @@ func (l *GeneratePlacementTopicOutreachDraftsLogic) GeneratePlacementTopicOutrea
TopicID: scope.TopicID,
Count: req.Count,
VoicePersonaID: req.VoicePersonaID,
- ProductID: scope.Topic.ProductID,
+ ProductID: productID,
},
})
}
diff --git a/backend/internal/logic/placement_topic/mapper.go b/backend/internal/logic/placement_topic/mapper.go
index a25ae52..ab7f04b 100644
--- a/backend/internal/logic/placement_topic/mapper.go
+++ b/backend/internal/logic/placement_topic/mapper.go
@@ -163,6 +163,7 @@ func toScanPostData(post *scanpostusecase.ScanPostSummary) *types.ScanPostData {
GraphNodeID: post.GraphNodeID,
SearchTag: post.SearchTag,
QueryDimension: post.QueryDimension,
+ RecencyDays: post.RecencyDays,
ExternalID: post.ExternalID,
Permalink: post.Permalink,
Author: post.Author,
diff --git a/backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go b/backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go
index 3c5c85a..143f18e 100644
--- a/backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go
+++ b/backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go
@@ -31,7 +31,8 @@ func (l *PatchPlacementTopicGraphNodesLogic) PatchPlacementTopicGraphNodes(req *
if len(req.Updates) == 0 {
return nil, app.For(code.Brand).InputMissingRequired("updates is required")
}
- if _, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID); err != nil {
+ scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID)
+ if err != nil {
return nil, err
}
updates := make([]kgusecase.NodeUpdate, 0, len(req.Updates))
@@ -63,6 +64,7 @@ func (l *PatchPlacementTopicGraphNodesLogic) PatchPlacementTopicGraphNodes(req *
graph, err := l.svcCtx.KnowledgeGraph.UpdateNodes(l.ctx, kgusecase.UpdateNodesRequest{
TenantID: tenantID,
OwnerUID: uid,
+ BrandID: scope.BrandID,
TopicID: req.ID,
Updates: updates,
})
diff --git a/backend/internal/logic/placement_topic/start_placement_topic_outreach_draft_job_logic.go b/backend/internal/logic/placement_topic/start_placement_topic_outreach_draft_job_logic.go
new file mode 100644
index 0000000..f190908
--- /dev/null
+++ b/backend/internal/logic/placement_topic/start_placement_topic_outreach_draft_job_logic.go
@@ -0,0 +1,127 @@
+package placement_topic
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ jobdom "haixun-backend/internal/model/job/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type StartPlacementTopicOutreachDraftJobLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewStartPlacementTopicOutreachDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPlacementTopicOutreachDraftJobLogic {
+ return &StartPlacementTopicOutreachDraftJobLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *StartPlacementTopicOutreachDraftJobLogic) StartPlacementTopicOutreachDraftJob(
+ req *types.StartPlacementTopicOutreachDraftJobHandlerReq,
+) (*types.StartPlacementTopicOutreachDraftJobsData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ scanPostIDs := append([]string{}, req.ScanPostIDs...)
+ if id := strings.TrimSpace(req.ScanPostID); id != "" {
+ scanPostIDs = append(scanPostIDs, id)
+ }
+ seen := map[string]struct{}{}
+ unique := make([]string, 0, len(scanPostIDs))
+ for _, id := range scanPostIDs {
+ id = strings.TrimSpace(id)
+ if id == "" {
+ continue
+ }
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ unique = append(unique, id)
+ }
+ if len(unique) == 0 {
+ return nil, app.For(code.Brand).InputMissingRequired("scan_post_id or scan_post_ids is required")
+ }
+ if strings.TrimSpace(req.VoicePersonaID) == "" {
+ return nil, app.For(code.Brand).InputMissingRequired("voice_persona_id is required")
+ }
+ if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.VoicePersonaID); err != nil {
+ return nil, err
+ }
+
+ count := req.Count
+ if count <= 0 {
+ count = 3
+ }
+ if count > 4 {
+ count = 4
+ }
+
+ productID := strings.TrimSpace(req.ProductID)
+ if productID == "" {
+ productID = strings.TrimSpace(scope.Topic.ProductID)
+ }
+
+ jobs := make([]types.StartBrandScanJobData, 0, len(unique))
+ for _, scanPostID := range unique {
+ if _, err := l.svcCtx.ScanPost.Get(l.ctx, tenantID, uid, scope.BrandID, scanPostID); err != nil {
+ return nil, err
+ }
+ regenerate := false
+ if existing, existingErr := l.svcCtx.OutreachDraft.GetLatestByScanPost(
+ l.ctx, tenantID, uid, scope.BrandID, scope.TopicID, scanPostID,
+ ); existingErr == nil && existing != nil && len(existing.Drafts) > 0 {
+ regenerate = true
+ }
+ payload := map[string]any{
+ "brand_id": scope.BrandID,
+ "topic_id": scope.TopicID,
+ "scan_post_id": scanPostID,
+ "count": count,
+ "voice_persona_id": strings.TrimSpace(req.VoicePersonaID),
+ "regenerate": regenerate,
+ }
+ if productID != "" {
+ payload["product_id"] = productID
+ }
+ run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
+ TemplateType: "generate-outreach-draft",
+ Scope: "placement_topic",
+ ScopeID: scope.TopicID,
+ TenantID: tenantID,
+ OwnerUID: uid,
+ Payload: payload,
+ })
+ if err != nil {
+ return nil, err
+ }
+ jobs = append(jobs, types.StartBrandScanJobData{
+ JobID: run.ID.Hex(),
+ Status: string(run.Status),
+ Message: "獲客草稿背景任務已建立",
+ })
+ }
+
+ message := "獲客草稿已在背景執行,可繼續瀏覽其他頁面"
+ if len(jobs) > 1 {
+ message = fmt.Sprintf("已送出 %d 個獲客草稿背景任務,可繼續瀏覽其他頁面", len(jobs))
+ }
+ return &types.StartPlacementTopicOutreachDraftJobsData{
+ Jobs: jobs,
+ Message: message,
+ }, nil
+}
diff --git a/backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go b/backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go
index f0688e3..7a27e83 100644
--- a/backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go
+++ b/backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go
@@ -68,6 +68,7 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types.
}
dualTrack := true
patrolMode := req.PatrolMode
+ testPatrol := req.TestPatrol
brandForPatrol := scope.Brand
brandForPatrol.ProductID = scope.Topic.ProductID
brandForPatrol.ResearchMap = scope.Topic.ResearchMap
@@ -80,15 +81,22 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types.
if graph != nil {
patrolNodes = graph.Nodes
}
+ if len(nodeIDs) == 0 && selected > 0 && graph != nil {
+ for _, node := range graph.Nodes {
+ if node.SelectedForScan {
+ nodeIDs = append(nodeIDs, node.ID)
+ }
+ }
+ }
patrolKeywords := libkg.ResolveScanPatrolKeywords(
req.PatrolKeywords,
scope.Topic.ResearchMap.PatrolKeywords,
patrolInput,
patrolNodes,
)
- if patrolMode || (len(nodeIDs) == 0 && selected == 0) {
+ if patrolMode || selected > 0 || len(nodeIDs) > 0 {
if len(patrolKeywords) == 0 {
- return nil, app.For(code.Brand).InputMissingRequired("請先產生研究地圖,系統會依研究地圖自動整理海巡關鍵字")
+ return nil, app.For(code.Brand).InputMissingRequired("請先勾選痛點節點或產生研究地圖,系統會綜合知識圖譜與產品自動整理海巡關鍵字")
}
patrolMode = true
} else if graph == nil {
@@ -98,21 +106,33 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types.
if err != nil {
return nil, err
}
+ placementSettings, err := l.svcCtx.Placement.Get(l.ctx, tenantID, uid)
+ if err != nil {
+ return nil, err
+ }
memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
if err != nil {
return nil, err
}
- if !memberCtx.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler {
- return nil, app.For(code.Setting).InputMissingRequired("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session")
- }
- if placement.MemberNeedsWebSearchKey(memberCtx) && placement.MissingWebSearchKey(research) {
- return nil, app.For(code.Setting).InputMissingRequired(placement.WebSearchKeyRequiredMessage(research))
- }
- if memberCtx.DevMode && !memberCtx.BrowserConnected {
- return nil, app.For(code.Setting).InputMissingRequired("開發模式需先同步 Chrome Session")
- }
- if !memberCtx.DevMode && memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected {
- return nil, app.For(code.Setting).InputMissingRequired("正式模式需先完成 Threads API 連線")
+ if testPatrol {
+ if placementSettings == nil || !placementSettings.DevModeEnabled {
+ return nil, app.For(code.Setting).InputMissingRequired("請先在設定頁開啟「開發模式(測試海巡)」")
+ }
+ if !memberCtx.BrowserConnected {
+ return nil, app.For(code.Setting).InputMissingRequired("測試海巡需先同步 Chrome Extension Session")
+ }
+ memberCtx = placement.MemberContextForCrawlerOnly(memberCtx)
+ patrolMode = true
+ } else {
+ if !memberCtx.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler {
+ return nil, app.For(code.Setting).InputMissingRequired("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session")
+ }
+ if placement.MemberNeedsWebSearchKey(memberCtx) && placement.MissingWebSearchKey(research) {
+ return nil, app.For(code.Setting).InputMissingRequired(placement.WebSearchKeyRequiredMessage(research))
+ }
+ if memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected {
+ return nil, app.For(code.Setting).InputMissingRequired("請先完成 Threads API 連線後再開始海巡")
+ }
}
payload := map[string]any{
"topic_id": scope.TopicID,
@@ -125,6 +145,9 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types.
payload["patrol_mode"] = true
payload["patrol_keywords"] = patrolKeywords
}
+ if testPatrol {
+ payload["test_patrol"] = true
+ }
for key, value := range memberCtx.PayloadFields() {
payload[key] = value
}
diff --git a/backend/internal/logic/placement_topic/update_placement_topic_outreach_draft_logic.go b/backend/internal/logic/placement_topic/update_placement_topic_outreach_draft_logic.go
new file mode 100644
index 0000000..4a17552
--- /dev/null
+++ b/backend/internal/logic/placement_topic/update_placement_topic_outreach_draft_logic.go
@@ -0,0 +1,42 @@
+package placement_topic
+
+import (
+ "context"
+
+ brandlogic "haixun-backend/internal/logic/brand"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type UpdatePlacementTopicOutreachDraftLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewUpdatePlacementTopicOutreachDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdatePlacementTopicOutreachDraftLogic {
+ return &UpdatePlacementTopicOutreachDraftLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *UpdatePlacementTopicOutreachDraftLogic) UpdatePlacementTopicOutreachDraft(
+ req *types.UpdatePlacementTopicOutreachDraftHandlerReq,
+) (*types.GenerateOutreachDraftsData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+ return brandlogic.NewUpdateOutreachDraftLogic(l.ctx, l.svcCtx).UpdateOutreachDraft(&types.UpdateOutreachDraftHandlerReq{
+ BrandPath: types.BrandPath{ID: scope.BrandID},
+ DraftID: req.DraftID,
+ UpdateOutreachDraftReq: types.UpdateOutreachDraftReq{
+ DraftIndex: req.DraftIndex,
+ Text: req.Text,
+ },
+ })
+}
diff --git a/backend/internal/logic/scan_post/lookalike_logic.go b/backend/internal/logic/scan_post/lookalike_logic.go
deleted file mode 100644
index 510b778..0000000
--- a/backend/internal/logic/scan_post/lookalike_logic.go
+++ /dev/null
@@ -1,126 +0,0 @@
-package scan_post
-
-import (
- "context"
- "math"
- "sort"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type LookalikeLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewLookalikeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LookalikeLogic {
- return &LookalikeLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *LookalikeLogic) Lookalike(req *types.LookalikeReq) (*types.LookalikeData, error) {
- actor, ok := authctx.ActorFromContext(l.ctx)
- if !ok {
- return nil, app.For(code.Auth).AuthUnauthorized("missing actor")
- }
-
- source, err := l.svcCtx.ScanPost.Get(l.ctx, actor.TenantID, actor.UID, req.BrandID, req.SourcePostID)
- if err != nil {
- return nil, err
- }
- if len(source.Embedding) == 0 {
- return &types.LookalikeData{List: nil}, nil
- }
-
- brandID := req.BrandID
- if brandID == "" {
- brandID = source.BrandID
- }
-
- limit := req.Limit
- if limit < 1 {
- limit = 10
- }
- if limit > 50 {
- limit = 50
- }
-
- candidates, err := l.svcCtx.ScanPost.List(l.ctx, scanpostusecase.ListRequest{
- TenantID: actor.TenantID,
- OwnerUID: actor.UID,
- BrandID: brandID,
- Limit: 200,
- })
- if err != nil {
- return nil, err
- }
-
- type scored struct {
- post scanpostusecase.ScanPostSummary
- sim float64
- }
- var scoredList []scored
- for _, c := range candidates {
- if c.ID == req.SourcePostID {
- continue
- }
- if len(c.Embedding) == 0 {
- continue
- }
- sim := cosineSimilarity(source.Embedding, c.Embedding)
- if sim < 0.3 {
- continue
- }
- scoredList = append(scoredList, scored{post: c, sim: sim})
- }
-
- sort.Slice(scoredList, func(i, j int) bool {
- return scoredList[i].sim > scoredList[j].sim
- })
-
- if len(scoredList) > limit {
- scoredList = scoredList[:limit]
- }
-
- out := make([]types.LookalikeItemData, 0, len(scoredList))
- for _, s := range scoredList {
- out = append(out, types.LookalikeItemData{
- PostID: s.post.ID,
- AuthorID: s.post.AuthorID,
- AuthorName: s.post.Author,
- AuthorAvatar: s.post.AuthorAvatar,
- Permalink: s.post.Permalink,
- Text: s.post.Text,
- PlacementScore: s.post.PlacementScore,
- SemanticScore: s.post.SemanticScore,
- EngagementPredicted: s.post.EngagementPredicted,
- AudienceQuality: s.post.AudienceQualityScore,
- Similarity: math.Round(s.sim*1000) / 1000,
- })
- }
-
- return &types.LookalikeData{List: out}, nil
-}
-
-func cosineSimilarity(a, b []float32) float64 {
- if len(a) == 0 || len(b) == 0 || len(a) != len(b) {
- return 0
- }
- var dot, na, nb float64
- for i := range a {
- dot += float64(a[i]) * float64(b[i])
- na += float64(a[i]) * float64(a[i])
- nb += float64(b[i]) * float64(b[i])
- }
- if na == 0 || nb == 0 {
- return 0
- }
- return dot / (math.Sqrt(na) * math.Sqrt(nb))
-}
diff --git a/backend/internal/logic/threads_account/analyze_content_formula_logic.go b/backend/internal/logic/threads_account/analyze_content_formula_logic.go
new file mode 100644
index 0000000..12075ec
--- /dev/null
+++ b/backend/internal/logic/threads_account/analyze_content_formula_logic.go
@@ -0,0 +1,227 @@
+package threads_account
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ libformula "haixun-backend/internal/library/formula"
+ liboutreach "haixun-backend/internal/library/outreach"
+ libownpost "haixun-backend/internal/library/ownpost"
+ libviral "haixun-backend/internal/library/viral"
+ domai "haixun-backend/internal/model/ai/domain/usecase"
+ aiusecase "haixun-backend/internal/model/ai/usecase"
+ cfentity "haixun-backend/internal/model/content_formula/domain/entity"
+ cfdom "haixun-backend/internal/model/content_formula/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type AnalyzeContentFormulaLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewAnalyzeContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AnalyzeContentFormulaLogic {
+ return &AnalyzeContentFormulaLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *AnalyzeContentFormulaLogic) AnalyzeContentFormula(req *types.AnalyzeContentFormulaHandlerReq) (*types.AnalyzeContentFormulaData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ sourceType := strings.TrimSpace(req.SourceType)
+ if sourceType == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("source_type is required")
+ }
+ if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID); err != nil {
+ return nil, err
+ }
+
+ credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
+ if err != nil {
+ return nil, err
+ }
+ providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
+ if err != nil {
+ return nil, err
+ }
+ aiReq := domai.GenerateRequest{
+ Provider: providerID,
+ Model: credential.Model,
+ Credential: domai.Credential{APIKey: credential.APIKey},
+ }
+ personaBlock := l.resolvePersonaBlock(tenantID, uid, req.PersonaID)
+
+ var analysis *libformula.PastedAnalysis
+ var sourceRef, postText, author, permalink string
+ var metrics *cfentity.SourceMetrics
+
+ switch sourceType {
+ case cfentity.SourcePaste:
+ postText = strings.TrimSpace(req.PostText)
+ if postText == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("post_text is required for paste")
+ }
+ author = strings.TrimSpace(req.AuthorName)
+ analysis, err = libformula.AnalyzePasted(l.ctx, l.svcCtx.AI, aiReq, libformula.AnalyzePastedInput{
+ PostText: postText, AuthorName: author, PersonaBlock: personaBlock,
+ LikeCount: req.LikeCount, ReplyCount: req.ReplyCount,
+ })
+ sourceRef = "paste"
+ case cfentity.SourceOwnPost:
+ mediaID := strings.TrimSpace(req.MediaID)
+ if mediaID == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("media_id is required for own_post")
+ }
+ review, rerr := l.svcCtx.OwnPostFormula.Get(l.ctx, tenantID, uid, req.ID, mediaID)
+ if rerr != nil || review == nil || req.ForceRefresh {
+ gen := NewGenerateOwnPostFormulaLogic(l.ctx, l.svcCtx)
+ force := req.ForceRefresh
+ genData, genErr := gen.GenerateOwnPostFormula(&types.GenerateOwnPostFormulaHandlerReq{
+ ThreadsAccountPath: types.ThreadsAccountPath{ID: req.ID},
+ GenerateOwnPostFormulaReq: types.GenerateOwnPostFormulaReq{
+ MediaID: mediaID, PersonaID: req.PersonaID, PostText: req.PostText,
+ LikeCount: req.LikeCount, ReplyCount: req.ReplyCount, Views: req.Views,
+ Force: force,
+ },
+ })
+ if genErr != nil {
+ return nil, genErr
+ }
+ analysis = &libformula.PastedAnalysis{
+ Summary: genData.Summary, Wins: genData.Wins, Improvements: genData.Improvements,
+ Formula: genData.Formula, PostTemplate: genData.PostTemplate,
+ HookPattern: genData.HookPattern, Structure: genData.Structure,
+ ReplicationTips: genData.ReplicationTips, Avoid: genData.Avoid,
+ }
+ } else {
+ analysis = libformula.FromOwnPostReview(&libownpost.PostFormulaReview{
+ Summary: review.Summary, Wins: review.Wins, Improvements: review.Improvements,
+ Formula: review.Formula, PostTemplate: review.PostTemplate,
+ HookPattern: review.HookPattern, Structure: review.Structure,
+ ReplicationTips: review.ReplicationTips, Avoid: review.Avoid,
+ })
+ }
+ postText = strings.TrimSpace(req.PostText)
+ if postText == "" && review != nil {
+ postText = review.PostText
+ }
+ sourceRef = mediaID
+ case cfentity.SourceScanPost:
+ scanPostID := strings.TrimSpace(req.ScanPostID)
+ personaID := strings.TrimSpace(req.PersonaID)
+ if scanPostID == "" || personaID == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("scan_post_id and persona_id are required for scan_post")
+ }
+ post, perr := l.svcCtx.ScanPost.GetForPersona(l.ctx, tenantID, uid, personaID, scanPostID)
+ if perr != nil {
+ return nil, perr
+ }
+ postText = post.Text
+ author = post.Author
+ permalink = post.Permalink
+ analysis, err = l.analyzeViralPost(aiReq, post.Text, post.Author, post.LikeCount, post.ReplyCount, post.SearchTag, personaBlock)
+ sourceRef = scanPostID
+ metrics = &cfentity.SourceMetrics{LikeCount: post.LikeCount, ReplyCount: post.ReplyCount}
+ case cfentity.SourceKeywordSearch:
+ postText = strings.TrimSpace(req.PostText)
+ if postText == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("post_text is required after selecting a search result")
+ }
+ author = strings.TrimSpace(req.AuthorName)
+ permalink = strings.TrimSpace(req.Permalink)
+ analysis, err = l.analyzeViralPost(aiReq, postText, author, req.LikeCount, req.ReplyCount, req.Keyword, personaBlock)
+ sourceRef = strings.TrimSpace(req.Keyword)
+ metrics = &cfentity.SourceMetrics{
+ LikeCount: req.LikeCount, ReplyCount: req.ReplyCount,
+ Views: req.Views, RepostCount: req.RepostCount,
+ QuoteCount: req.QuoteCount, Shares: req.Shares,
+ }
+ default:
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("unsupported source_type")
+ }
+ if err != nil {
+ return nil, app.For(code.AI).SvcThirdParty("分析失敗:" + err.Error())
+ }
+ if analysis == nil {
+ return nil, app.For(code.AI).SvcThirdParty("分析未產出有效公式")
+ }
+
+ label := strings.TrimSpace(req.SaveLabel)
+ if label == "" {
+ label = defaultFormulaLabel(sourceType, postText, author)
+ }
+ saved, err := l.svcCtx.ContentFormula.Create(l.ctx, cfdom.CreateRequest{
+ TenantID: tenantID, OwnerUID: uid, AccountID: req.ID,
+ Label: label, SourceType: sourceType, SourceRef: sourceRef,
+ SourcePostText: postText, SourceAuthor: author, SourcePermalink: permalink,
+ SourceMetrics: metrics,
+ Summary: analysis.Summary, Wins: analysis.Wins, Improvements: analysis.Improvements,
+ Formula: analysis.Formula, PostTemplate: analysis.PostTemplate,
+ HookPattern: analysis.HookPattern, Structure: analysis.Structure,
+ ReplicationTips: analysis.ReplicationTips, Avoid: analysis.Avoid,
+ })
+ if err != nil {
+ return nil, err
+ }
+ data := toContentFormulaData(*saved)
+ return &types.AnalyzeContentFormulaData{
+ Formula: data,
+ Message: fmt.Sprintf("已分析並存入公式庫:%s", data.Label),
+ }, nil
+}
+
+func (l *AnalyzeContentFormulaLogic) resolvePersonaBlock(tenantID, uid, personaID string) string {
+ personaID = strings.TrimSpace(personaID)
+ if personaID == "" {
+ return ""
+ }
+ persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
+ if err != nil {
+ return ""
+ }
+ return liboutreach.FormatVoicePersonaBlock(persona)
+}
+
+func (l *AnalyzeContentFormulaLogic) analyzeViralPost(
+ aiReq domai.GenerateRequest,
+ postText, author string, likes, replies int, tag, personaBlock string,
+) (*libformula.PastedAnalysis, error) {
+ out, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
+ Provider: aiReq.Provider, Model: aiReq.Model, Credential: aiReq.Credential,
+ System: libviral.BuildAnalyzeViralSystemPrompt(),
+ Messages: []domai.Message{{Role: "user", Content: libviral.BuildAnalyzeViralUserPrompt(libviral.AnalyzeViralInput{
+ PostText: postText, AuthorName: author, LikeCount: likes, ReplyCount: replies,
+ SearchTag: tag, TopicLabel: tag, Persona: personaBlock,
+ })}},
+ })
+ if err != nil {
+ return nil, err
+ }
+ parsed, err := libviral.ParseAnalyzeViralOutput(out.Text)
+ if err != nil {
+ return nil, err
+ }
+ return libformula.FromViralAnalysis(
+ parsed.HookPattern, parsed.StructurePattern, parsed.EmotionalTrigger,
+ parsed.ReplicationStrategy, parsed.KeyTakeaways,
+ ), nil
+}
+
+func defaultFormulaLabel(sourceType, postText, author string) string {
+ preview := strings.TrimSpace(postText)
+ if len([]rune(preview)) > 24 {
+ preview = string([]rune(preview)[:24]) + "…"
+ }
+ if author != "" {
+ return fmt.Sprintf("%s · @%s", sourceType, author)
+ }
+ if preview != "" {
+ return preview
+ }
+ return sourceType + " 公式"
+}
diff --git a/backend/internal/logic/threads_account/cancel_publish_queue_item_logic.go b/backend/internal/logic/threads_account/cancel_publish_queue_item_logic.go
new file mode 100644
index 0000000..5059d0d
--- /dev/null
+++ b/backend/internal/logic/threads_account/cancel_publish_queue_item_logic.go
@@ -0,0 +1,29 @@
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type CancelPublishQueueItemLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewCancelPublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CancelPublishQueueItemLogic {
+ return &CancelPublishQueueItemLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *CancelPublishQueueItemLogic) CancelPublishQueueItem(req *types.PublishQueueItemPath) (*types.PublishQueueItemData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, err := l.svcCtx.PublishQueue.Cancel(l.ctx, tenantID, uid, req.ID, req.QID)
+ if err != nil {
+ return nil, err
+ }
+ return toPublishQueueItemData(result), nil
+}
diff --git a/backend/internal/logic/threads_account/content_formula_mapper.go b/backend/internal/logic/threads_account/content_formula_mapper.go
new file mode 100644
index 0000000..2103b2e
--- /dev/null
+++ b/backend/internal/logic/threads_account/content_formula_mapper.go
@@ -0,0 +1,58 @@
+package threads_account
+
+import (
+ cfentity "haixun-backend/internal/model/content_formula/domain/entity"
+ cfdom "haixun-backend/internal/model/content_formula/domain/usecase"
+ "haixun-backend/internal/types"
+)
+
+func toContentFormulaData(item cfdom.FormulaSummary) types.ContentFormulaData {
+ var metrics *types.ContentFormulaSourceMetricsData
+ if item.SourceMetrics != nil {
+ metrics = &types.ContentFormulaSourceMetricsData{
+ LikeCount: item.SourceMetrics.LikeCount,
+ ReplyCount: item.SourceMetrics.ReplyCount,
+ Views: item.SourceMetrics.Views,
+ RepostCount: item.SourceMetrics.RepostCount,
+ QuoteCount: item.SourceMetrics.QuoteCount,
+ Shares: item.SourceMetrics.Shares,
+ }
+ }
+ return types.ContentFormulaData{
+ ID: item.ID,
+ AccountID: item.AccountID,
+ Label: item.Label,
+ SourceType: item.SourceType,
+ SourceRef: item.SourceRef,
+ SourcePostText: item.SourcePostText,
+ SourceAuthor: item.SourceAuthor,
+ SourcePermalink: item.SourcePermalink,
+ SourceMetrics: metrics,
+ Summary: item.Summary,
+ Wins: item.Wins,
+ Improvements: item.Improvements,
+ Formula: item.Formula,
+ PostTemplate: item.PostTemplate,
+ HookPattern: item.HookPattern,
+ Structure: item.Structure,
+ ReplicationTips: item.ReplicationTips,
+ Avoid: item.Avoid,
+ Tags: item.Tags,
+ CreateAt: item.CreateAt,
+ UpdateAt: item.UpdateAt,
+ }
+}
+
+func toSourceMetrics(data *types.ContentFormulaSourceMetricsData) *cfentity.SourceMetrics {
+ if data == nil {
+ return nil
+ }
+ return &cfentity.SourceMetrics{
+ LikeCount: data.LikeCount,
+ ReplyCount: data.ReplyCount,
+ Views: data.Views,
+ RepostCount: data.RepostCount,
+ QuoteCount: data.QuoteCount,
+ Shares: data.Shares,
+ }
+}
diff --git a/backend/internal/logic/threads_account/create_publish_queue_item_logic.go b/backend/internal/logic/threads_account/create_publish_queue_item_logic.go
new file mode 100644
index 0000000..96fdfef
--- /dev/null
+++ b/backend/internal/logic/threads_account/create_publish_queue_item_logic.go
@@ -0,0 +1,37 @@
+package threads_account
+
+import (
+ "context"
+
+ pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type CreatePublishQueueItemLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewCreatePublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreatePublishQueueItemLogic {
+ return &CreatePublishQueueItemLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *CreatePublishQueueItemLogic) CreatePublishQueueItem(req *types.CreatePublishQueueHandlerReq) (*types.PublishQueueItemData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, err := l.svcCtx.PublishQueue.Create(l.ctx, pqdomain.CreateRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ AccountID: req.ID,
+ Text: req.Text,
+ TopicTag: req.TopicTag,
+ ScheduledAt: req.ScheduledAt,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return toPublishQueueItemData(result), nil
+}
diff --git a/backend/internal/logic/threads_account/delete_content_formula_logic.go b/backend/internal/logic/threads_account/delete_content_formula_logic.go
new file mode 100644
index 0000000..b6b4af5
--- /dev/null
+++ b/backend/internal/logic/threads_account/delete_content_formula_logic.go
@@ -0,0 +1,32 @@
+package threads_account
+
+import (
+ "context"
+ "fmt"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type DeleteContentFormulaLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewDeleteContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteContentFormulaLogic {
+ return &DeleteContentFormulaLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *DeleteContentFormulaLogic) DeleteContentFormula(req *types.GetContentFormulaHandlerReq) (*types.DeleteContentFormulaData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := l.svcCtx.ContentFormula.Delete(l.ctx, tenantID, uid, req.ID, req.FormulaID); err != nil {
+ return nil, err
+ }
+ return &types.DeleteContentFormulaData{
+ FormulaID: req.FormulaID,
+ Message: fmt.Sprintf("已刪除公式 %s", req.FormulaID),
+ }, nil
+}
diff --git a/backend/internal/logic/threads_account/delete_publish_queue_item_logic.go b/backend/internal/logic/threads_account/delete_publish_queue_item_logic.go
new file mode 100644
index 0000000..e9d9529
--- /dev/null
+++ b/backend/internal/logic/threads_account/delete_publish_queue_item_logic.go
@@ -0,0 +1,25 @@
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type DeletePublishQueueItemLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewDeletePublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeletePublishQueueItemLogic {
+ return &DeletePublishQueueItemLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *DeletePublishQueueItemLogic) DeletePublishQueueItem(req *types.PublishQueueItemPath) error {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return err
+ }
+ return l.svcCtx.PublishQueue.Delete(l.ctx, tenantID, uid, req.ID, req.QID)
+}
diff --git a/backend/internal/logic/threads_account/delete_threads_account_logic.go b/backend/internal/logic/threads_account/delete_threads_account_logic.go
new file mode 100644
index 0000000..296aec4
--- /dev/null
+++ b/backend/internal/logic/threads_account/delete_threads_account_logic.go
@@ -0,0 +1,36 @@
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type DeleteThreadsAccountLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewDeleteThreadsAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteThreadsAccountLogic {
+ return &DeleteThreadsAccountLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *DeleteThreadsAccountLogic) DeleteThreadsAccount(req *types.ThreadsAccountPath) (*types.DeleteThreadsAccountData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := l.svcCtx.ThreadsAccount.Delete(l.ctx, tenantID, uid, req.ID); err != nil {
+ return nil, err
+ }
+ accounts, err := l.svcCtx.ThreadsAccount.List(l.ctx, tenantID, uid)
+ if err != nil {
+ return nil, err
+ }
+ return &types.DeleteThreadsAccountData{
+ DeletedID: req.ID,
+ ActiveAccountID: accounts.ActiveAccountID,
+ Message: "帳號已刪除",
+ }, nil
+}
diff --git a/backend/internal/logic/threads_account/disconnect_threads_api_logic.go b/backend/internal/logic/threads_account/disconnect_threads_api_logic.go
new file mode 100644
index 0000000..44c486a
--- /dev/null
+++ b/backend/internal/logic/threads_account/disconnect_threads_api_logic.go
@@ -0,0 +1,30 @@
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type DisconnectThreadsApiLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewDisconnectThreadsApiLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DisconnectThreadsApiLogic {
+ return &DisconnectThreadsApiLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *DisconnectThreadsApiLogic) DisconnectThreadsApi(req *types.ThreadsAccountPath) (*types.ThreadsAccountData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ item, err := l.svcCtx.ThreadsAccount.DisconnectThreadsAPI(l.ctx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+ out := toThreadsAccountData(*item)
+ return &out, nil
+}
diff --git a/backend/internal/logic/threads_account/generate_own_post_formula_logic.go b/backend/internal/logic/threads_account/generate_own_post_formula_logic.go
new file mode 100644
index 0000000..fb60458
--- /dev/null
+++ b/backend/internal/logic/threads_account/generate_own_post_formula_logic.go
@@ -0,0 +1,124 @@
+package threads_account
+
+import (
+ "context"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ liboutreach "haixun-backend/internal/library/outreach"
+ libownpost "haixun-backend/internal/library/ownpost"
+ domai "haixun-backend/internal/model/ai/domain/usecase"
+ aiusecase "haixun-backend/internal/model/ai/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GenerateOwnPostFormulaLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGenerateOwnPostFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateOwnPostFormulaLogic {
+ return &GenerateOwnPostFormulaLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *GenerateOwnPostFormulaLogic) GenerateOwnPostFormula(
+ req *types.GenerateOwnPostFormulaHandlerReq,
+) (*types.GenerateOwnPostFormulaData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ if req.MediaID == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("media_id is required")
+ }
+
+ account, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ if !req.Force {
+ if cached, cerr := l.svcCtx.OwnPostFormula.Get(l.ctx, tenantID, uid, req.ID, req.MediaID); cerr == nil && cached != nil {
+ data := toOwnPostFormulaReviewData(cached)
+ data.FromCache = true
+ return &types.GenerateOwnPostFormulaData{OwnPostFormulaReviewData: data}, nil
+ }
+ }
+
+ credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
+ if err != nil {
+ return nil, err
+ }
+ providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
+ if err != nil {
+ return nil, err
+ }
+
+ personaBlock := ""
+ if personaID := strings.TrimSpace(req.PersonaID); personaID != "" {
+ persona, perr := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
+ if perr != nil {
+ return nil, perr
+ }
+ personaBlock = liboutreach.FormatVoicePersonaBlock(persona)
+ } else if personaID = strings.TrimSpace(account.PersonaID); personaID != "" {
+ persona, perr := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
+ if perr == nil {
+ personaBlock = liboutreach.FormatVoicePersonaBlock(persona)
+ }
+ }
+
+ accountName := account.DisplayName
+ if accountName == "" {
+ accountName = account.Username
+ }
+
+ review, err := libownpost.GeneratePostFormula(l.ctx, l.svcCtx.AI, domai.GenerateRequest{
+ Provider: providerID,
+ Model: credential.Model,
+ Credential: domai.Credential{
+ APIKey: credential.APIKey,
+ },
+ }, libownpost.PostFormulaInput{
+ AccountName: accountName,
+ PersonaBlock: personaBlock,
+ PostText: req.PostText,
+ TopicTag: req.TopicTag,
+ MediaType: req.MediaType,
+ LikeCount: req.LikeCount,
+ ReplyCount: req.ReplyCount,
+ Views: req.Views,
+ RepostCount: req.RepostCount,
+ QuoteCount: req.QuoteCount,
+ Shares: req.Shares,
+ Timestamp: req.Timestamp,
+ })
+ if err != nil {
+ return nil, app.For(code.AI).SvcThirdParty("AI 貼文覆盤失敗:" + err.Error())
+ }
+ saved, err := l.svcCtx.OwnPostFormula.SaveGenerated(
+ l.ctx,
+ tenantID,
+ uid,
+ req.ID,
+ req.MediaID,
+ strings.TrimSpace(req.PersonaID),
+ req.PostText,
+ review,
+ )
+ if err != nil {
+ return nil, err
+ }
+ data := toOwnPostFormulaReviewData(saved)
+ data.FromCache = false
+ return &types.GenerateOwnPostFormulaData{OwnPostFormulaReviewData: data}, nil
+}
diff --git a/backend/internal/logic/threads_account/generate_own_post_reply_draft_logic.go b/backend/internal/logic/threads_account/generate_own_post_reply_draft_logic.go
new file mode 100644
index 0000000..fa36120
--- /dev/null
+++ b/backend/internal/logic/threads_account/generate_own_post_reply_draft_logic.go
@@ -0,0 +1,98 @@
+package threads_account
+
+import (
+ "context"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ liboutreach "haixun-backend/internal/library/outreach"
+ libownpost "haixun-backend/internal/library/ownpost"
+ domai "haixun-backend/internal/model/ai/domain/usecase"
+ aiusecase "haixun-backend/internal/model/ai/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GenerateOwnPostReplyDraftLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGenerateOwnPostReplyDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateOwnPostReplyDraftLogic {
+ return &GenerateOwnPostReplyDraftLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *GenerateOwnPostReplyDraftLogic) GenerateOwnPostReplyDraft(
+ req *types.GenerateOwnPostReplyDraftHandlerReq,
+) (*types.GenerateOwnPostReplyDraftData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ if req.MediaID == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("media_id is required")
+ }
+
+ account, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
+ if err != nil {
+ return nil, err
+ }
+ providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
+ if err != nil {
+ return nil, err
+ }
+
+ personaID := strings.TrimSpace(req.PersonaID)
+ if personaID == "" {
+ personaID = strings.TrimSpace(account.PersonaID)
+ }
+ if personaID == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("請先選擇人設(persona_id)")
+ }
+ persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
+ if err != nil {
+ return nil, err
+ }
+ personaBlock := liboutreach.FormatVoicePersonaBlock(persona)
+ if strings.TrimSpace(personaBlock) == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("所選人設尚未填寫語氣或 8D 風格,請先到人設頁補齊")
+ }
+
+ accountName := account.DisplayName
+ if accountName == "" {
+ accountName = account.Username
+ }
+
+ text, err := libownpost.GenerateReplyDraft(l.ctx, l.svcCtx.AI, domai.GenerateRequest{
+ Provider: providerID,
+ Model: credential.Model,
+ Credential: domai.Credential{
+ APIKey: credential.APIKey,
+ },
+ }, libownpost.ReplyDraftInput{
+ AccountName: accountName,
+ PersonaBlock: personaBlock,
+ PostText: req.PostText,
+ ReplyText: req.ReplyText,
+ ReplyToID: req.ReplyToID,
+ ThreadPostText: req.ThreadPostText,
+ ParentReplyText: req.ParentReplyText,
+ })
+ if err != nil {
+ return nil, app.For(code.AI).SvcThirdParty("AI 回覆草稿生成失敗:" + err.Error())
+ }
+ return &types.GenerateOwnPostReplyDraftData{Text: text}, nil
+}
diff --git a/backend/internal/logic/threads_account/get_content_formula_logic.go b/backend/internal/logic/threads_account/get_content_formula_logic.go
new file mode 100644
index 0000000..31adef3
--- /dev/null
+++ b/backend/internal/logic/threads_account/get_content_formula_logic.go
@@ -0,0 +1,30 @@
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type GetContentFormulaLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetContentFormulaLogic {
+ return &GetContentFormulaLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *GetContentFormulaLogic) GetContentFormula(req *types.GetContentFormulaHandlerReq) (*types.ContentFormulaData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ item, err := l.svcCtx.ContentFormula.Get(l.ctx, tenantID, uid, req.ID, req.FormulaID)
+ if err != nil {
+ return nil, err
+ }
+ data := toContentFormulaData(*item)
+ return &data, nil
+}
diff --git a/backend/internal/logic/threads_account/get_publish_dashboard_summary_logic.go b/backend/internal/logic/threads_account/get_publish_dashboard_summary_logic.go
new file mode 100644
index 0000000..da4d205
--- /dev/null
+++ b/backend/internal/logic/threads_account/get_publish_dashboard_summary_logic.go
@@ -0,0 +1,72 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetPublishDashboardSummaryLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetPublishDashboardSummaryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishDashboardSummaryLogic {
+ return &GetPublishDashboardSummaryLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *GetPublishDashboardSummaryLogic) GetPublishDashboardSummary() (resp *types.PublishDashboardSummaryData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ accounts, err := l.svcCtx.ThreadsAccount.List(l.ctx, tenantID, uid)
+ if err != nil {
+ return nil, err
+ }
+ resp = &types.PublishDashboardSummaryData{List: []types.PublishDashboardAccountSummary{}}
+ for _, account := range accounts.List {
+ health, hErr := l.svcCtx.PublishQueue.ListPublishHealth(l.ctx, tenantID, uid, account.ID, 1, 10)
+ if hErr != nil {
+ continue
+ }
+ guard, _ := l.svcCtx.PublishGuard.Get(l.ctx, tenantID, uid, account.ID)
+ slots, _ := l.svcCtx.PublishQueue.ListSlotInsights(l.ctx, tenantID, uid, account.ID, "Asia/Taipei")
+ bestSlot, lowSlot := "", ""
+ for _, slot := range slots {
+ if bestSlot == "" && slot.Recommendation == "recommended" {
+ bestSlot = slot.Time
+ }
+ if lowSlot == "" && slot.Recommendation == "low" {
+ lowSlot = slot.Time
+ }
+ }
+ row := types.PublishDashboardAccountSummary{
+ AccountID: account.ID, AccountName: firstNonEmpty(account.DisplayName, account.Username, account.ID),
+ PendingScheduled: health.Summary.PendingScheduled, FailedCount: health.Summary.FailedCount,
+ Published7d: health.Summary.Published7d, TotalLikes7d: health.Summary.TotalLikes7d,
+ TotalReplies7d: health.Summary.TotalReplies7d, BestSlot: bestSlot, LowSlot: lowSlot,
+ }
+ if guard != nil {
+ row.Paused = guard.Paused
+ }
+ resp.List = append(resp.List, row)
+ resp.TotalPending += row.PendingScheduled
+ resp.TotalFailed += row.FailedCount
+ resp.TotalPublished7d += row.Published7d
+ resp.TotalLikes7d += row.TotalLikes7d
+ resp.TotalReplies7d += row.TotalReplies7d
+ }
+ return resp, nil
+}
diff --git a/backend/internal/logic/threads_account/get_publish_guard_policy_logic.go b/backend/internal/logic/threads_account/get_publish_guard_policy_logic.go
new file mode 100644
index 0000000..6e15f0b
--- /dev/null
+++ b/backend/internal/logic/threads_account/get_publish_guard_policy_logic.go
@@ -0,0 +1,39 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetPublishGuardPolicyLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetPublishGuardPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishGuardPolicyLogic {
+ return &GetPublishGuardPolicyLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *GetPublishGuardPolicyLogic) GetPublishGuardPolicy(req *types.ThreadsAccountPath) (resp *types.PublishGuardPolicyData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ item, err := l.svcCtx.PublishGuard.Get(l.ctx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+ return toGuardPolicyData(item), nil
+}
diff --git a/backend/internal/logic/threads_account/get_publish_inventory_policy_logic.go b/backend/internal/logic/threads_account/get_publish_inventory_policy_logic.go
new file mode 100644
index 0000000..cde2e9e
--- /dev/null
+++ b/backend/internal/logic/threads_account/get_publish_inventory_policy_logic.go
@@ -0,0 +1,39 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetPublishInventoryPolicyLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetPublishInventoryPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishInventoryPolicyLogic {
+ return &GetPublishInventoryPolicyLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *GetPublishInventoryPolicyLogic) GetPublishInventoryPolicy(req *types.ThreadsAccountPath) (resp *types.PublishInventoryPolicyData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ item, err := l.svcCtx.PublishInventory.Get(l.ctx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+ return toInventoryPolicyData(item), nil
+}
diff --git a/backend/internal/logic/threads_account/get_publish_queue_item_logic.go b/backend/internal/logic/threads_account/get_publish_queue_item_logic.go
new file mode 100644
index 0000000..ee2efec
--- /dev/null
+++ b/backend/internal/logic/threads_account/get_publish_queue_item_logic.go
@@ -0,0 +1,29 @@
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type GetPublishQueueItemLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetPublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishQueueItemLogic {
+ return &GetPublishQueueItemLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *GetPublishQueueItemLogic) GetPublishQueueItem(req *types.PublishQueueItemPath) (*types.PublishQueueItemData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, err := l.svcCtx.PublishQueue.Get(l.ctx, tenantID, uid, req.ID, req.QID)
+ if err != nil {
+ return nil, err
+ }
+ return toPublishQueueItemData(result), nil
+}
diff --git a/backend/internal/logic/threads_account/get_publish_slot_insights_logic.go b/backend/internal/logic/threads_account/get_publish_slot_insights_logic.go
new file mode 100644
index 0000000..7b84ba3
--- /dev/null
+++ b/backend/internal/logic/threads_account/get_publish_slot_insights_logic.go
@@ -0,0 +1,53 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetPublishSlotInsightsLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetPublishSlotInsightsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishSlotInsightsLogic {
+ return &GetPublishSlotInsightsLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *GetPublishSlotInsightsLogic) GetPublishSlotInsights(req *types.ThreadsAccountPath) (resp *types.PublishSlotInsightsData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ policy, _ := l.svcCtx.PublishInventory.Get(l.ctx, tenantID, uid, req.ID)
+ timezone := "Asia/Taipei"
+ if policy != nil && policy.Timezone != "" {
+ timezone = policy.Timezone
+ }
+ items, err := l.svcCtx.PublishQueue.ListSlotInsights(l.ctx, tenantID, uid, req.ID, timezone)
+ if err != nil {
+ return nil, err
+ }
+ list := make([]types.PublishSlotInsightData, 0, len(items))
+ for _, item := range items {
+ list = append(list, types.PublishSlotInsightData{
+ Weekday: item.Weekday, Time: item.Time, AvgLikes1h: item.AvgLikes1h, AvgReplies1h: item.AvgReplies1h,
+ AvgLikes24h: item.AvgLikes24h, AvgReplies24h: item.AvgReplies24h,
+ AvgLikes7d: item.AvgLikes7d, AvgReplies7d: item.AvgReplies7d,
+ SampleSize: item.SampleSize, Recommendation: item.Recommendation,
+ })
+ }
+ return &types.PublishSlotInsightsData{AccountID: req.ID, Timezone: timezone, Slots: list}, nil
+}
diff --git a/backend/internal/logic/threads_account/get_threads_diagnostics_logic.go b/backend/internal/logic/threads_account/get_threads_diagnostics_logic.go
new file mode 100644
index 0000000..c4a23d5
--- /dev/null
+++ b/backend/internal/logic/threads_account/get_threads_diagnostics_logic.go
@@ -0,0 +1,55 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/library/clock"
+ libthreads "haixun-backend/internal/library/threadsapi"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetThreadsDiagnosticsLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetThreadsDiagnosticsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetThreadsDiagnosticsLogic {
+ return &GetThreadsDiagnosticsLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *GetThreadsDiagnosticsLogic) GetThreadsDiagnostics(req *types.ThreadsAccountPath) (resp *types.ThreadsDiagnosticsData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ account, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+ smoke, smokeErr := l.svcCtx.ThreadsAccount.RunAPISmokeTest(l.ctx, tenantID, uid, req.ID)
+ items := make([]types.ThreadsAPISmokeTestItem, 0, len(smoke))
+ for _, item := range smoke {
+ items = append(items, types.ThreadsAPISmokeTestItem{
+ Scope: item.Scope, Name: item.Name, Status: item.Status, Message: item.Message, Count: item.Count,
+ })
+ }
+ message := "診斷完成"
+ if smokeErr != nil {
+ message = smokeErr.Error()
+ }
+ return &types.ThreadsDiagnosticsData{
+ AccountID: req.ID, CheckedAt: clock.NowUnixNano(), ApiConnected: account.ApiConnected,
+ TokenExpiresAt: account.APITokenExpiresAt, Scopes: libthreads.DefaultOAuthScopes, Items: items, Message: message,
+ }, nil
+}
diff --git a/backend/internal/logic/threads_account/get_threads_oauth_config_logic.go b/backend/internal/logic/threads_account/get_threads_oauth_config_logic.go
new file mode 100644
index 0000000..162b872
--- /dev/null
+++ b/backend/internal/logic/threads_account/get_threads_oauth_config_logic.go
@@ -0,0 +1,27 @@
+package threads_account
+
+import (
+ "strings"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type GetThreadsOAuthConfigLogic struct {
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetThreadsOAuthConfigLogic(svcCtx *svc.ServiceContext) *GetThreadsOAuthConfigLogic {
+ return &GetThreadsOAuthConfigLogic{svcCtx: svcCtx}
+}
+
+func (l *GetThreadsOAuthConfigLogic) GetThreadsOAuthConfig() *types.ThreadsOAuthConfigData {
+ cfg := l.svcCtx.Config.ThreadsOAuth
+ redirect := strings.TrimSpace(cfg.RedirectURI)
+ return &types.ThreadsOAuthConfigData{
+ RedirectURI: redirect,
+ SuccessRedirect: strings.TrimSpace(cfg.SuccessRedirect),
+ Enabled: strings.TrimSpace(cfg.AppID) != "" && strings.TrimSpace(cfg.AppSecret) != "" && redirect != "",
+ RequiresHTTPS: !strings.HasPrefix(strings.ToLower(redirect), "https://"),
+ }
+}
diff --git a/backend/internal/logic/threads_account/import_own_post_to_content_formula_logic.go b/backend/internal/logic/threads_account/import_own_post_to_content_formula_logic.go
new file mode 100644
index 0000000..fde6ef1
--- /dev/null
+++ b/backend/internal/logic/threads_account/import_own_post_to_content_formula_logic.go
@@ -0,0 +1,30 @@
+package threads_account
+
+import (
+ "context"
+
+ cfentity "haixun-backend/internal/model/content_formula/domain/entity"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type ImportOwnPostToContentFormulaLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewImportOwnPostToContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ImportOwnPostToContentFormulaLogic {
+ return &ImportOwnPostToContentFormulaLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ImportOwnPostToContentFormulaLogic) ImportOwnPostToContentFormula(req *types.ImportOwnPostFormulaHandlerReq) (*types.AnalyzeContentFormulaData, error) {
+ analyze := NewAnalyzeContentFormulaLogic(l.ctx, l.svcCtx)
+ return analyze.AnalyzeContentFormula(&types.AnalyzeContentFormulaHandlerReq{
+ ThreadsAccountPath: types.ThreadsAccountPath{ID: req.ID},
+ AnalyzeContentFormulaReq: types.AnalyzeContentFormulaReq{
+ SourceType: cfentity.SourceOwnPost,
+ MediaID: req.MediaID,
+ SaveLabel: req.Label,
+ },
+ })
+}
diff --git a/backend/internal/logic/threads_account/list_content_formulas_logic.go b/backend/internal/logic/threads_account/list_content_formulas_logic.go
new file mode 100644
index 0000000..6399e96
--- /dev/null
+++ b/backend/internal/logic/threads_account/list_content_formulas_logic.go
@@ -0,0 +1,63 @@
+package threads_account
+
+import (
+ "context"
+ "math"
+
+ cfdom "haixun-backend/internal/model/content_formula/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type ListContentFormulasLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListContentFormulasLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListContentFormulasLogic {
+ return &ListContentFormulasLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ListContentFormulasLogic) ListContentFormulas(req *types.ListContentFormulasHandlerReq) (*types.ListContentFormulasData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ page := int(req.Page)
+ if page <= 0 {
+ page = 1
+ }
+ pageSize := int(req.PageSize)
+ if pageSize <= 0 {
+ pageSize = 20
+ }
+ result, err := l.svcCtx.ContentFormula.List(l.ctx, cfdom.ListRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ AccountID: req.ID,
+ SourceType: req.SourceType,
+ Tag: req.Tag,
+ Page: page,
+ PageSize: pageSize,
+ })
+ if err != nil {
+ return nil, err
+ }
+ list := make([]types.ContentFormulaData, 0, len(result.Items))
+ for _, item := range result.Items {
+ list = append(list, toContentFormulaData(item))
+ }
+ totalPages := int64(0)
+ if pageSize > 0 {
+ totalPages = int64(math.Ceil(float64(result.Total) / float64(pageSize)))
+ }
+ return &types.ListContentFormulasData{
+ Pagination: types.PaginationData{
+ Total: result.Total,
+ Page: int64(page),
+ PageSize: int64(pageSize),
+ TotalPages: totalPages,
+ },
+ List: list,
+ }, nil
+}
diff --git a/backend/internal/logic/threads_account/list_mention_inbox_logic.go b/backend/internal/logic/threads_account/list_mention_inbox_logic.go
new file mode 100644
index 0000000..9ce78d1
--- /dev/null
+++ b/backend/internal/logic/threads_account/list_mention_inbox_logic.go
@@ -0,0 +1,53 @@
+package threads_account
+
+import (
+ "context"
+
+ mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListMentionInboxLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListMentionInboxLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListMentionInboxLogic {
+ return &ListMentionInboxLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *ListMentionInboxLogic) ListMentionInbox(req *types.ListMentionInboxHandlerReq) (*types.ListMentionInboxData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, err := l.svcCtx.MentionInbox.List(l.ctx, mentioninboxdomain.ListRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ AccountID: req.ID,
+ Page: req.Page,
+ PageSize: req.PageSize,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &types.ListMentionInboxData{
+ List: toMentionInboxItems(result.List),
+ Pagination: types.PaginationData{
+ Total: result.Pagination.Total,
+ Page: result.Pagination.Page,
+ PageSize: result.Pagination.PageSize,
+ TotalPages: result.Pagination.TotalPages,
+ },
+ ReadyTotal: result.Pagination.ReadyTotal,
+ NewestSyncedAt: result.Pagination.NewestSyncedAt,
+ }, nil
+}
diff --git a/backend/internal/logic/threads_account/list_own_post_formulas_logic.go b/backend/internal/logic/threads_account/list_own_post_formulas_logic.go
new file mode 100644
index 0000000..23c3be3
--- /dev/null
+++ b/backend/internal/logic/threads_account/list_own_post_formulas_logic.go
@@ -0,0 +1,43 @@
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListOwnPostFormulasLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListOwnPostFormulasLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListOwnPostFormulasLogic {
+ return &ListOwnPostFormulasLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *ListOwnPostFormulasLogic) ListOwnPostFormulas(req *types.ListOwnPostFormulasHandlerReq) (*types.ListOwnPostFormulasData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID); err != nil {
+ return nil, err
+ }
+ items, err := l.svcCtx.OwnPostFormula.ListByAccount(l.ctx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+ list := make([]types.OwnPostFormulaReviewData, 0, len(items))
+ for i := range items {
+ list = append(list, toOwnPostFormulaReviewData(&items[i]))
+ }
+ return &types.ListOwnPostFormulasData{List: list}, nil
+}
diff --git a/backend/internal/logic/threads_account/list_publish_alerts_logic.go b/backend/internal/logic/threads_account/list_publish_alerts_logic.go
new file mode 100644
index 0000000..1d239c2
--- /dev/null
+++ b/backend/internal/logic/threads_account/list_publish_alerts_logic.go
@@ -0,0 +1,46 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListPublishAlertsLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListPublishAlertsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPublishAlertsLogic {
+ return &ListPublishAlertsLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *ListPublishAlertsLogic) ListPublishAlerts(req *types.ThreadsAccountPath) (resp *types.PublishAlertsData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ items, err := l.svcCtx.PublishQueue.ListAlerts(l.ctx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+ list := make([]types.PublishAlertData, 0, len(items))
+ for _, item := range items {
+ list = append(list, types.PublishAlertData{
+ Type: item.Type, Severity: item.Severity, Message: item.Message,
+ QueueID: item.QueueID, AccountID: item.AccountID, CreateAt: item.CreateAt,
+ })
+ }
+ return &types.PublishAlertsData{List: list}, nil
+}
diff --git a/backend/internal/logic/threads_account/list_publish_queue_events_logic.go b/backend/internal/logic/threads_account/list_publish_queue_events_logic.go
new file mode 100644
index 0000000..327998a
--- /dev/null
+++ b/backend/internal/logic/threads_account/list_publish_queue_events_logic.go
@@ -0,0 +1,39 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListPublishQueueEventsLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListPublishQueueEventsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPublishQueueEventsLogic {
+ return &ListPublishQueueEventsLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *ListPublishQueueEventsLogic) ListPublishQueueEvents(req *types.PublishQueueItemPath) (resp *types.PublishQueueEventsData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ items, err := l.svcCtx.PublishQueueEvent.ListByQueue(l.ctx, tenantID, uid, req.ID, req.QID, 100)
+ if err != nil {
+ return nil, err
+ }
+ return &types.PublishQueueEventsData{List: toQueueEventData(items)}, nil
+}
diff --git a/backend/internal/logic/threads_account/list_publish_queue_items_logic.go b/backend/internal/logic/threads_account/list_publish_queue_items_logic.go
new file mode 100644
index 0000000..020661d
--- /dev/null
+++ b/backend/internal/logic/threads_account/list_publish_queue_items_logic.go
@@ -0,0 +1,37 @@
+package threads_account
+
+import (
+ "context"
+
+ pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type ListPublishQueueItemsLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListPublishQueueItemsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPublishQueueItemsLogic {
+ return &ListPublishQueueItemsLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ListPublishQueueItemsLogic) ListPublishQueueItems(req *types.ListPublishQueueHandlerReq) (*types.PublishQueueListData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, err := l.svcCtx.PublishQueue.List(l.ctx, pqdomain.ListRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ AccountID: req.ID,
+ Status: req.Status,
+ Page: req.Page,
+ PageSize: req.PageSize,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return toPublishQueueListData(result), nil
+}
diff --git a/backend/internal/logic/threads_account/list_publish_queue_range_logic.go b/backend/internal/logic/threads_account/list_publish_queue_range_logic.go
new file mode 100644
index 0000000..db34dbc
--- /dev/null
+++ b/backend/internal/logic/threads_account/list_publish_queue_range_logic.go
@@ -0,0 +1,43 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "context"
+
+ pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListPublishQueueRangeLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListPublishQueueRangeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPublishQueueRangeLogic {
+ return &ListPublishQueueRangeLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *ListPublishQueueRangeLogic) ListPublishQueueRange(req *types.PublishQueueRangeHandlerReq) (resp *types.PublishQueueListData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, err := l.svcCtx.PublishQueue.ListRange(l.ctx, pqdomain.RangeRequest{
+ TenantID: tenantID, OwnerUID: uid, AccountID: req.ID,
+ Status: req.Status, StartAt: req.StartAt, EndAt: req.EndAt, Page: 1, PageSize: 200,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return toPublishQueueListData(result), nil
+}
diff --git a/backend/internal/logic/threads_account/list_threads_oauth_logs_logic.go b/backend/internal/logic/threads_account/list_threads_oauth_logs_logic.go
new file mode 100644
index 0000000..ebad032
--- /dev/null
+++ b/backend/internal/logic/threads_account/list_threads_oauth_logs_logic.go
@@ -0,0 +1,32 @@
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type ListThreadsOAuthLogsLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListThreadsOAuthLogsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListThreadsOAuthLogsLogic {
+ return &ListThreadsOAuthLogsLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ListThreadsOAuthLogsLogic) ListThreadsOAuthLogs() (*types.ListThreadsOAuthLogsData, error) {
+ items := l.svcCtx.ThreadsAccount.ListOAuthDebugLogs(50)
+ list := make([]types.ThreadsOAuthLogItem, 0, len(items))
+ for _, item := range items {
+ list = append(list, types.ThreadsOAuthLogItem{
+ At: item.At,
+ Level: item.Level,
+ Stage: item.Stage,
+ AccountID: item.AccountID,
+ Message: item.Message,
+ })
+ }
+ return &types.ListThreadsOAuthLogsData{List: list}, nil
+}
diff --git a/backend/internal/logic/threads_account/list_threads_post_performance_logic.go b/backend/internal/logic/threads_account/list_threads_post_performance_logic.go
new file mode 100644
index 0000000..6153768
--- /dev/null
+++ b/backend/internal/logic/threads_account/list_threads_post_performance_logic.go
@@ -0,0 +1,39 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListThreadsPostPerformanceLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListThreadsPostPerformanceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListThreadsPostPerformanceLogic {
+ return &ListThreadsPostPerformanceLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *ListThreadsPostPerformanceLogic) ListThreadsPostPerformance(req *types.ListThreadsPostPerformanceHandlerReq) (*types.ThreadsPostPerformanceData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, err := l.svcCtx.ThreadsAccount.ListPostPerformance(l.ctx, tenantID, uid, req.ID, req.Limit)
+ if err != nil {
+ return nil, err
+ }
+ return toPostPerformanceData(result), nil
+}
diff --git a/backend/internal/logic/threads_account/list_threads_publish_health_logic.go b/backend/internal/logic/threads_account/list_threads_publish_health_logic.go
new file mode 100644
index 0000000..db640d6
--- /dev/null
+++ b/backend/internal/logic/threads_account/list_threads_publish_health_logic.go
@@ -0,0 +1,29 @@
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type ListThreadsPublishHealthLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListThreadsPublishHealthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListThreadsPublishHealthLogic {
+ return &ListThreadsPublishHealthLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ListThreadsPublishHealthLogic) ListThreadsPublishHealth(req *types.ListThreadsPublishHealthHandlerReq) (*types.ThreadsPublishHealthData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, err := l.svcCtx.PublishQueue.ListPublishHealth(l.ctx, tenantID, uid, req.ID, req.Page, req.PageSize)
+ if err != nil {
+ return nil, err
+ }
+ return toPublishHealthData(result), nil
+}
diff --git a/backend/internal/logic/threads_account/mapper.go b/backend/internal/logic/threads_account/mapper.go
index 2e55f7f..f83071d 100644
--- a/backend/internal/logic/threads_account/mapper.go
+++ b/backend/internal/logic/threads_account/mapper.go
@@ -1,22 +1,62 @@
package threads_account
import (
+ guarddomain "haixun-backend/internal/model/publish_guard/domain/usecase"
+ inventorydomain "haixun-backend/internal/model/publish_inventory/domain/usecase"
+ pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
+ eventdomain "haixun-backend/internal/model/publish_queue_event/domain/usecase"
domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
"haixun-backend/internal/types"
)
func toThreadsAccountData(item domusecase.AccountSummary) types.ThreadsAccountData {
return types.ThreadsAccountData{
- ID: item.ID,
- DisplayName: item.DisplayName,
- Username: item.Username,
- ThreadsUserID: item.ThreadsUserID,
- PersonaID: item.PersonaID,
- BrowserConnected: item.BrowserConnected,
- ApiConnected: item.ApiConnected,
- Status: item.Status,
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
+ ID: item.ID,
+ DisplayName: item.DisplayName,
+ Username: item.Username,
+ ThreadsUserID: item.ThreadsUserID,
+ PersonaID: item.PersonaID,
+ BrowserConnected: item.BrowserConnected,
+ ApiConnected: item.ApiConnected,
+ APITokenExpiresAt: item.APITokenExpiresAt,
+ Status: item.Status,
+ CreateAt: item.CreateAt,
+ UpdateAt: item.UpdateAt,
+ }
+}
+
+func toPostPerformanceData(result *domusecase.PostPerformanceResult) *types.ThreadsPostPerformanceData {
+ if result == nil {
+ return &types.ThreadsPostPerformanceData{Posts: []types.ThreadsPostPerformanceItem{}}
+ }
+ caps := make([]types.ThreadsPostMetricCapability, 0, len(result.Capabilities))
+ for _, item := range result.Capabilities {
+ caps = append(caps, types.ThreadsPostMetricCapability{
+ Scope: item.Scope, Name: item.Name, Metrics: item.Metrics,
+ Trackable: item.Trackable, Note: item.Note,
+ })
+ }
+ metrics := make([]types.ThreadsInsightMetricItem, 0, len(result.AccountInsights.Metrics))
+ for _, item := range result.AccountInsights.Metrics {
+ metrics = append(metrics, types.ThreadsInsightMetricItem{Name: item.Name, Value: item.Value})
+ }
+ posts := make([]types.ThreadsPostPerformanceItem, 0, len(result.Posts))
+ for _, item := range result.Posts {
+ posts = append(posts, types.ThreadsPostPerformanceItem{
+ MediaID: item.MediaID, Text: item.Text, Permalink: item.Permalink, Timestamp: item.Timestamp,
+ MediaType: item.MediaType, MediaURL: item.MediaURL, ThumbnailURL: item.ThumbnailURL,
+ TopicTag: item.TopicTag, Shortcode: item.Shortcode,
+ LikeCount: item.LikeCount, ReplyCount: item.ReplyCount, RepostCount: item.RepostCount, QuoteCount: item.QuoteCount,
+ Views: item.Views, Shares: item.Shares, InsightsStatus: item.InsightsStatus, InsightsMessage: item.InsightsMessage,
+ })
+ }
+ return &types.ThreadsPostPerformanceData{
+ AccountID: result.AccountID, Username: result.Username, ThreadsUserID: result.ThreadsUserID,
+ FetchedAt: result.FetchedAt, Capabilities: caps,
+ AccountInsights: types.ThreadsAccountInsightsBlock{
+ Status: result.AccountInsights.Status, Message: result.AccountInsights.Message, Metrics: metrics,
+ },
+ Posts: posts,
}
}
@@ -57,6 +97,142 @@ func toConnectionData(data *domusecase.ConnectionData) *types.ThreadsAccountConn
}
}
+func toPublishQueueItemData(item *pqdomain.QueueItemSummary) *types.PublishQueueItemData {
+ if item == nil {
+ return nil
+ }
+ return &types.PublishQueueItemData{
+ ID: item.ID,
+ AccountID: item.AccountID,
+ PersonaID: item.PersonaID,
+ CopyMissionID: item.CopyMissionID,
+ CopyDraftID: item.CopyDraftID,
+ Text: item.Text,
+ TopicTag: item.TopicTag,
+ ScheduledAt: item.ScheduledAt,
+ Status: item.Status,
+ MediaID: item.MediaID,
+ Permalink: item.Permalink,
+ PublishedAt: item.PublishedAt,
+ ErrorMessage: item.ErrorMessage,
+ RetryCount: item.RetryCount,
+ LastAttemptAt: item.LastAttemptAt,
+ NextAttemptAt: item.NextAttemptAt,
+ MissedAt: item.MissedAt,
+ PausedReason: item.PausedReason,
+ CreateAt: item.CreateAt,
+ UpdateAt: item.UpdateAt,
+ }
+}
+
+func toInventoryPolicyData(item *inventorydomain.PolicySummary) *types.PublishInventoryPolicyData {
+ if item == nil {
+ return nil
+ }
+ slots := make([]types.PublishSlotData, 0, len(item.Slots))
+ for _, slot := range item.Slots {
+ slots = append(slots, types.PublishSlotData{Weekday: slot.Weekday, Time: slot.Time})
+ }
+ refs := make([]types.PublishSourceRefData, 0, len(item.SourceRefs))
+ for _, ref := range item.SourceRefs {
+ refs = append(refs, types.PublishSourceRefData{
+ Type: ref.Type, PersonaID: ref.PersonaID, CopyMissionID: ref.CopyMissionID, ScanPostID: ref.ScanPostID, ManualSeed: ref.ManualSeed,
+ })
+ }
+ return &types.PublishInventoryPolicyData{
+ AccountID: item.AccountID, Enabled: item.Enabled, TargetDailyCount: item.TargetDailyCount,
+ LowStockThreshold: item.LowStockThreshold, LookaheadDays: item.LookaheadDays,
+ Timezone: item.Timezone, Slots: slots, SourceRefs: refs, UpdateAt: item.UpdateAt,
+ }
+}
+
+func toGuardPolicyData(item *guarddomain.PolicySummary) *types.PublishGuardPolicyData {
+ if item == nil {
+ return nil
+ }
+ return &types.PublishGuardPolicyData{
+ AccountID: item.AccountID, Enabled: item.Enabled, MaxDailyPosts: item.MaxDailyPosts,
+ MinIntervalMinutes: item.MinIntervalMinutes, ConsecutiveFailurePauseLimit: item.ConsecutiveFailurePauseLimit,
+ Paused: item.Paused, PausedReason: item.PausedReason, UpdateAt: item.UpdateAt,
+ }
+}
+
+func toQueueEventData(items []eventdomain.EventSummary) []types.PublishQueueEventData {
+ out := make([]types.PublishQueueEventData, 0, len(items))
+ for _, item := range items {
+ out = append(out, types.PublishQueueEventData{
+ ID: item.ID, QueueID: item.QueueID, EventType: item.EventType,
+ FromStatus: item.FromStatus, ToStatus: item.ToStatus, Message: item.Message, CreateAt: item.CreateAt,
+ })
+ }
+ return out
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func toPublishQueueListData(result *pqdomain.ListResult) *types.PublishQueueListData {
+ if result == nil {
+ return &types.PublishQueueListData{List: []types.PublishQueueItemData{}}
+ }
+ list := make([]types.PublishQueueItemData, 0, len(result.Items))
+ for _, item := range result.Items {
+ if data := toPublishQueueItemData(&item); data != nil {
+ list = append(list, *data)
+ }
+ }
+ return &types.PublishQueueListData{
+ Pagination: types.PaginationData{
+ Total: result.Total,
+ Page: int64(result.Page),
+ PageSize: int64(result.PageSize),
+ TotalPages: int64(result.TotalPages),
+ },
+ List: list,
+ }
+}
+
+func toPublishHealthData(result *pqdomain.PublishHealthResult) *types.ThreadsPublishHealthData {
+ if result == nil {
+ return &types.ThreadsPublishHealthData{List: []types.ThreadsPublishHealthItem{}}
+ }
+ list := make([]types.ThreadsPublishHealthItem, 0, len(result.Items))
+ for _, item := range result.Items {
+ cps := make([]types.ThreadsPublishHealthCheckpoint, 0, len(item.Checkpoints))
+ for _, cp := range item.Checkpoints {
+ cps = append(cps, types.ThreadsPublishHealthCheckpoint{
+ Checkpoint: cp.Checkpoint, LikeCount: cp.LikeCount, ReplyCount: cp.ReplyCount,
+ RepostCount: cp.RepostCount, QuoteCount: cp.QuoteCount, Views: cp.Views, CheckedAt: cp.CheckedAt,
+ })
+ }
+ list = append(list, types.ThreadsPublishHealthItem{
+ MediaID: item.MediaID, Permalink: item.Permalink, Text: item.Text,
+ PublishedAt: item.PublishedAt, LikeCount: item.LikeCount, ReplyCount: item.ReplyCount,
+ RepostCount: item.RepostCount, QuoteCount: item.QuoteCount,
+ Views: item.Views, Shares: item.Shares, Checkpoints: cps,
+ })
+ }
+ return &types.ThreadsPublishHealthData{
+ Summary: types.ThreadsPublishHealthSummary{
+ PendingScheduled: result.Summary.PendingScheduled,
+ FailedCount: result.Summary.FailedCount,
+ Published7d: result.Summary.Published7d,
+ TotalLikes7d: result.Summary.TotalLikes7d,
+ TotalReplies7d: result.Summary.TotalReplies7d,
+ },
+ Pagination: types.PaginationData{
+ Total: result.Total, Page: int64(result.Page), PageSize: int64(result.PageSize), TotalPages: int64(result.TotalPages),
+ },
+ List: list,
+ }
+}
+
func toConnectionPatch(req *types.UpdateThreadsAccountConnectionReq) domusecase.ConnectionPrefsPatch {
if req == nil {
return domusecase.ConnectionPrefsPatch{}
diff --git a/backend/internal/logic/threads_account/mention_inbox_mapper.go b/backend/internal/logic/threads_account/mention_inbox_mapper.go
new file mode 100644
index 0000000..867d844
--- /dev/null
+++ b/backend/internal/logic/threads_account/mention_inbox_mapper.go
@@ -0,0 +1,32 @@
+package threads_account
+
+import (
+ mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase"
+ "haixun-backend/internal/types"
+)
+
+func toMentionInboxItems(items []mentioninboxdomain.MentionSummary) []types.MentionInboxItemData {
+ out := make([]types.MentionInboxItemData, 0, len(items))
+ for _, item := range items {
+ out = append(out, types.MentionInboxItemData{
+ MediaID: item.MediaID,
+ AuthorUsername: item.AuthorUsername,
+ Text: item.Text,
+ Permalink: item.Permalink,
+ Shortcode: item.Shortcode,
+ Timestamp: item.Timestamp,
+ MediaType: item.MediaType,
+ IsReply: item.IsReply,
+ IsQuotePost: item.IsQuotePost,
+ HasReplies: item.HasReplies,
+ RootPostID: item.RootPostID,
+ ParentID: item.ParentID,
+ ThreadPostText: item.ThreadPostText,
+ ParentReplyText: item.ParentReplyText,
+ ReplyReady: item.ReplyReady,
+ ReplyReadyMsg: item.ReplyReadyMsg,
+ SyncedAt: item.SyncedAt,
+ })
+ }
+ return out
+}
diff --git a/backend/internal/logic/threads_account/own_post_formula_mapper.go b/backend/internal/logic/threads_account/own_post_formula_mapper.go
new file mode 100644
index 0000000..a6336c0
--- /dev/null
+++ b/backend/internal/logic/threads_account/own_post_formula_mapper.go
@@ -0,0 +1,25 @@
+package threads_account
+
+import (
+ "haixun-backend/internal/model/own_post_formula/domain/entity"
+ "haixun-backend/internal/types"
+)
+
+func toOwnPostFormulaReviewData(item *entity.Review) types.OwnPostFormulaReviewData {
+ if item == nil {
+ return types.OwnPostFormulaReviewData{}
+ }
+ return types.OwnPostFormulaReviewData{
+ MediaID: item.MediaID,
+ ReviewedAt: item.UpdateAt,
+ Summary: item.Summary,
+ Wins: item.Wins,
+ Improvements: item.Improvements,
+ Formula: item.Formula,
+ PostTemplate: item.PostTemplate,
+ HookPattern: item.HookPattern,
+ Structure: item.Structure,
+ ReplicationTips: item.ReplicationTips,
+ Avoid: item.Avoid,
+ }
+}
diff --git a/backend/internal/logic/threads_account/patch_content_formula_logic.go b/backend/internal/logic/threads_account/patch_content_formula_logic.go
new file mode 100644
index 0000000..7e994aa
--- /dev/null
+++ b/backend/internal/logic/threads_account/patch_content_formula_logic.go
@@ -0,0 +1,42 @@
+package threads_account
+
+import (
+ "context"
+
+ cfdom "haixun-backend/internal/model/content_formula/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type PatchContentFormulaLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewPatchContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchContentFormulaLogic {
+ return &PatchContentFormulaLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *PatchContentFormulaLogic) PatchContentFormula(req *types.PatchContentFormulaHandlerReq) (*types.ContentFormulaData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ item, err := l.svcCtx.ContentFormula.Update(l.ctx, cfdom.UpdateRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ AccountID: req.ID,
+ FormulaID: req.FormulaID,
+ Label: req.Label,
+ Formula: req.Formula,
+ PostTemplate: req.PostTemplate,
+ HookPattern: req.HookPattern,
+ Structure: req.Structure,
+ Tags: req.Tags,
+ })
+ if err != nil {
+ return nil, err
+ }
+ data := toContentFormulaData(*item)
+ return &data, nil
+}
diff --git a/backend/internal/logic/threads_account/patch_publish_queue_item_logic.go b/backend/internal/logic/threads_account/patch_publish_queue_item_logic.go
new file mode 100644
index 0000000..3e70636
--- /dev/null
+++ b/backend/internal/logic/threads_account/patch_publish_queue_item_logic.go
@@ -0,0 +1,38 @@
+package threads_account
+
+import (
+ "context"
+
+ pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type PatchPublishQueueItemLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewPatchPublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchPublishQueueItemLogic {
+ return &PatchPublishQueueItemLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *PatchPublishQueueItemLogic) PatchPublishQueueItem(req *types.PatchPublishQueueHandlerReq) (*types.PublishQueueItemData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, err := l.svcCtx.PublishQueue.Update(l.ctx, pqdomain.UpdateRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ AccountID: req.ID,
+ QueueID: req.QID,
+ Text: req.Text,
+ TopicTag: req.TopicTag,
+ ScheduledAt: req.ScheduledAt,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return toPublishQueueItemData(result), nil
+}
diff --git a/backend/internal/logic/threads_account/publish_mention_reply_logic.go b/backend/internal/logic/threads_account/publish_mention_reply_logic.go
new file mode 100644
index 0000000..67b1797
--- /dev/null
+++ b/backend/internal/logic/threads_account/publish_mention_reply_logic.go
@@ -0,0 +1,46 @@
+package threads_account
+
+import (
+ "context"
+
+ mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type PublishMentionReplyLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewPublishMentionReplyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublishMentionReplyLogic {
+ return &PublishMentionReplyLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *PublishMentionReplyLogic) PublishMentionReply(req *types.PublishMentionReplyHandlerReq) (*types.PublishMentionReplyData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, err := l.svcCtx.MentionInbox.PublishReply(l.ctx, mentioninboxdomain.PublishReplyRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ AccountID: req.ID,
+ MediaID: req.MediaID,
+ Text: req.Text,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &types.PublishMentionReplyData{
+ MediaID: result.MediaID,
+ Permalink: result.Permalink,
+ }, nil
+}
diff --git a/backend/internal/logic/threads_account/resume_publish_guard_logic.go b/backend/internal/logic/threads_account/resume_publish_guard_logic.go
new file mode 100644
index 0000000..8a52f9d
--- /dev/null
+++ b/backend/internal/logic/threads_account/resume_publish_guard_logic.go
@@ -0,0 +1,39 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ResumePublishGuardLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewResumePublishGuardLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ResumePublishGuardLogic {
+ return &ResumePublishGuardLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *ResumePublishGuardLogic) ResumePublishGuard(req *types.ThreadsAccountPath) (resp *types.PublishGuardPolicyData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ item, err := l.svcCtx.PublishGuard.Resume(l.ctx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+ return toGuardPolicyData(item), nil
+}
diff --git a/backend/internal/logic/threads_account/retry_publish_queue_item_logic.go b/backend/internal/logic/threads_account/retry_publish_queue_item_logic.go
new file mode 100644
index 0000000..bd0b8e1
--- /dev/null
+++ b/backend/internal/logic/threads_account/retry_publish_queue_item_logic.go
@@ -0,0 +1,39 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type RetryPublishQueueItemLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewRetryPublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RetryPublishQueueItemLogic {
+ return &RetryPublishQueueItemLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *RetryPublishQueueItemLogic) RetryPublishQueueItem(req *types.PublishQueueItemPath) (resp *types.PublishQueueItemData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ item, err := l.svcCtx.PublishQueue.Retry(l.ctx, tenantID, uid, req.ID, req.QID)
+ if err != nil {
+ return nil, err
+ }
+ return toPublishQueueItemData(item), nil
+}
diff --git a/backend/internal/logic/threads_account/run_threads_api_playground_logic.go b/backend/internal/logic/threads_account/run_threads_api_playground_logic.go
new file mode 100644
index 0000000..af9017f
--- /dev/null
+++ b/backend/internal/logic/threads_account/run_threads_api_playground_logic.go
@@ -0,0 +1,44 @@
+package threads_account
+
+import (
+ "context"
+ domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type RunThreadsAPIPlaygroundLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewRunThreadsAPIPlaygroundLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RunThreadsAPIPlaygroundLogic {
+ return &RunThreadsAPIPlaygroundLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *RunThreadsAPIPlaygroundLogic) RunThreadsAPIPlayground(
+ req *types.RunThreadsAPIPlaygroundHandlerReq,
+) (*types.ThreadsAPIPlaygroundData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ body := req.RunThreadsAPIPlaygroundReq
+ result, err := l.svcCtx.ThreadsAccount.RunAPIPlayground(l.ctx, tenantID, uid, req.ID, domusecase.APIPlaygroundRequest{
+ Action: body.Action, Query: body.Query, Username: body.Username,
+ MediaID: body.MediaID, ReplyID: body.ReplyID, ReplyToID: body.ReplyToID,
+ Text: body.Text, Permalink: body.Permalink, HintText: body.HintText, SkipPrime: body.SkipPrime,
+ LocationQuery: body.LocationQuery, Limit: body.Limit,
+ Hide: body.Hide, SearchType: body.SearchType,
+ })
+ if err != nil {
+ return nil, err
+ }
+ data := ""
+ if len(result.Data) > 0 {
+ data = string(result.Data)
+ }
+ return &types.ThreadsAPIPlaygroundData{
+ Action: result.Action, Ok: result.Ok, Message: result.Message, Data: data,
+ }, nil
+}
diff --git a/backend/internal/logic/threads_account/run_threads_api_smoke_test_logic.go b/backend/internal/logic/threads_account/run_threads_api_smoke_test_logic.go
new file mode 100644
index 0000000..f91d22e
--- /dev/null
+++ b/backend/internal/logic/threads_account/run_threads_api_smoke_test_logic.go
@@ -0,0 +1,51 @@
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type RunThreadsAPISmokeTestLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewRunThreadsAPISmokeTestLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RunThreadsAPISmokeTestLogic {
+ return &RunThreadsAPISmokeTestLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *RunThreadsAPISmokeTestLogic) RunThreadsAPISmokeTest(req *types.ThreadsAccountPath) (*types.RunThreadsAPISmokeTestData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ items, err := l.svcCtx.ThreadsAccount.RunAPISmokeTest(l.ctx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+ list := make([]types.ThreadsAPISmokeTestItem, 0, len(items))
+ var passed, failed, skipped int
+ for _, item := range items {
+ list = append(list, types.ThreadsAPISmokeTestItem{
+ Scope: item.Scope, Name: item.Name, Status: item.Status,
+ Message: item.Message, Count: item.Count,
+ })
+ switch item.Status {
+ case "ok":
+ passed++
+ case "error":
+ failed++
+ default:
+ skipped++
+ }
+ }
+ return &types.RunThreadsAPISmokeTestData{
+ List: list,
+ Passed: passed,
+ Failed: failed,
+ Skipped: skipped,
+ Total: len(list),
+ }, nil
+}
diff --git a/backend/internal/logic/threads_account/search_content_formula_posts_logic.go b/backend/internal/logic/threads_account/search_content_formula_posts_logic.go
new file mode 100644
index 0000000..7b8c99b
--- /dev/null
+++ b/backend/internal/logic/threads_account/search_content_formula_posts_logic.go
@@ -0,0 +1,80 @@
+package threads_account
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ libthreads "haixun-backend/internal/library/threadsapi"
+ libviral "haixun-backend/internal/library/viral"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type SearchContentFormulaPostsLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewSearchContentFormulaPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SearchContentFormulaPostsLogic {
+ return &SearchContentFormulaPostsLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *SearchContentFormulaPostsLogic) SearchContentFormulaPosts(req *types.SearchContentFormulaPostsHandlerReq) (*types.SearchContentFormulaPostsData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ keyword := strings.TrimSpace(req.Keyword)
+ if keyword == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("keyword is required")
+ }
+ if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID); err != nil {
+ return nil, err
+ }
+ token, err := l.svcCtx.ThreadsAccount.ResolveAccountAPIAccessToken(l.ctx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+ client := libthreads.NewClient(token)
+ if !client.Enabled() {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API 未連線")
+ }
+ limit := req.Limit
+ if limit <= 0 {
+ limit = 10
+ }
+ searchType := strings.TrimSpace(req.SearchType)
+ if searchType == "" {
+ searchType = "TOP"
+ }
+ items, err := client.KeywordSearch(l.ctx, libthreads.KeywordSearchOptions{
+ Query: keyword, Limit: limit, SearchType: searchType,
+ })
+ if err != nil {
+ return nil, app.For(code.ThreadsAccount).SvcThirdParty("keyword_search 失敗:" + err.Error())
+ }
+ list := make([]types.ContentFormulaSearchPostData, 0, len(items))
+ for _, item := range items {
+ text := strings.TrimSpace(item.Text)
+ if text == "" {
+ continue
+ }
+ author := strings.TrimSpace(item.Username)
+ list = append(list, types.ContentFormulaSearchPostData{
+ Text: text,
+ Author: author,
+ Permalink: item.Permalink,
+ MediaID: item.ID,
+ LikeCount: item.LikeCount,
+ ReplyCount: item.ReplyCount,
+ EngagementScore: libviral.ScorePost(item.LikeCount, item.ReplyCount),
+ })
+ }
+ return &types.SearchContentFormulaPostsData{
+ List: list,
+ Message: fmt.Sprintf("找到 %d 篇相似貼文", len(list)),
+ }, nil
+}
diff --git a/backend/internal/logic/threads_account/start_publish_inventory_refill_job_logic.go b/backend/internal/logic/threads_account/start_publish_inventory_refill_job_logic.go
new file mode 100644
index 0000000..dcda058
--- /dev/null
+++ b/backend/internal/logic/threads_account/start_publish_inventory_refill_job_logic.go
@@ -0,0 +1,61 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "context"
+
+ jobdom "haixun-backend/internal/model/job/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type StartPublishInventoryRefillJobLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewStartPublishInventoryRefillJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPublishInventoryRefillJobLogic {
+ return &StartPublishInventoryRefillJobLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *StartPublishInventoryRefillJobLogic) StartPublishInventoryRefillJob(req *types.StartPublishInventoryRefillJobHandlerReq) (resp *types.StartPublishInventoryRefillJobData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID); err != nil {
+ return nil, err
+ }
+ count := req.Count
+ if count <= 0 {
+ count = 3
+ }
+ run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
+ TemplateType: "refill-publish-inventory",
+ Scope: "threads_account",
+ ScopeID: req.ID,
+ TenantID: tenantID,
+ OwnerUID: uid,
+ Payload: map[string]any{
+ "tenant_id": tenantID,
+ "owner_uid": uid,
+ "account_id": req.ID,
+ "count": count,
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &types.StartPublishInventoryRefillJobData{
+ JobID: run.ID.Hex(), Status: string(run.Status), Message: "補庫存任務已建立",
+ }, nil
+}
diff --git a/backend/internal/logic/threads_account/start_threads_oauth_logic.go b/backend/internal/logic/threads_account/start_threads_oauth_logic.go
new file mode 100644
index 0000000..94a5b73
--- /dev/null
+++ b/backend/internal/logic/threads_account/start_threads_oauth_logic.go
@@ -0,0 +1,37 @@
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type StartThreadsOAuthLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewStartThreadsOAuthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartThreadsOAuthLogic {
+ return &StartThreadsOAuthLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *StartThreadsOAuthLogic) StartThreadsOAuth(req *types.StartThreadsOAuthQuery) (*types.StartThreadsOAuthData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ accountID := ""
+ if req != nil {
+ accountID = req.AccountID
+ }
+ result, err := l.svcCtx.ThreadsAccount.StartThreadsOAuth(l.ctx, tenantID, uid, accountID)
+ if err != nil {
+ return nil, err
+ }
+ return &types.StartThreadsOAuthData{
+ AuthorizeURL: result.AuthorizeURL,
+ State: result.State,
+ AccountID: result.AccountID,
+ }, nil
+}
diff --git a/backend/internal/logic/threads_account/sync_mention_inbox_logic.go b/backend/internal/logic/threads_account/sync_mention_inbox_logic.go
new file mode 100644
index 0000000..3ed8f70
--- /dev/null
+++ b/backend/internal/logic/threads_account/sync_mention_inbox_logic.go
@@ -0,0 +1,47 @@
+package threads_account
+
+import (
+ "context"
+
+ mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type SyncMentionInboxLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewSyncMentionInboxLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SyncMentionInboxLogic {
+ return &SyncMentionInboxLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *SyncMentionInboxLogic) SyncMentionInbox(req *types.SyncMentionInboxHandlerReq) (*types.SyncMentionInboxData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, err := l.svcCtx.MentionInbox.SyncFromAPI(l.ctx, mentioninboxdomain.SyncRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ AccountID: req.ID,
+ Limit: req.Limit,
+ MaxPages: req.MaxPages,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &types.SyncMentionInboxData{
+ Synced: result.Synced,
+ Ready: result.Ready,
+ Total: result.Total,
+ }, nil
+}
diff --git a/backend/internal/logic/threads_account/threads_oauth_callback_logic.go b/backend/internal/logic/threads_account/threads_oauth_callback_logic.go
new file mode 100644
index 0000000..2215fdf
--- /dev/null
+++ b/backend/internal/logic/threads_account/threads_oauth_callback_logic.go
@@ -0,0 +1,60 @@
+package threads_account
+
+import (
+ "context"
+ "net/url"
+ "strings"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type ThreadsOAuthCallbackLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewThreadsOAuthCallbackLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ThreadsOAuthCallbackLogic {
+ return &ThreadsOAuthCallbackLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ThreadsOAuthCallbackLogic) ThreadsOAuthCallback(req *types.ThreadsOAuthCallbackQuery) (string, error) {
+ code := ""
+ state := ""
+ if req != nil {
+ code = req.Code
+ state = req.State
+ if strings.TrimSpace(req.Error) != "" {
+ msg := strings.TrimSpace(req.Error)
+ if reason := strings.TrimSpace(req.ErrorReason); reason != "" {
+ msg += " (" + reason + ")"
+ }
+ if desc := strings.TrimSpace(req.ErrorDescription); desc != "" {
+ msg += ": " + desc
+ }
+ l.svcCtx.ThreadsAccount.RecordOAuthDebug("error", "meta_denied", "", msg)
+ return failureRedirect(l.svcCtx.Config.ThreadsOAuth.SuccessRedirect, msg), nil
+ }
+ }
+ result, err := l.svcCtx.ThreadsAccount.CompleteThreadsOAuthCallback(l.ctx, code, state)
+ if err != nil {
+ return failureRedirect(l.svcCtx.Config.ThreadsOAuth.SuccessRedirect, err.Error()), nil
+ }
+ return result.RedirectURL, nil
+}
+
+func failureRedirect(base, message string) string {
+ base = strings.TrimSpace(base)
+ if base == "" {
+ base = "/"
+ }
+ sep := "?"
+ if strings.Contains(base, "?") {
+ sep = "&"
+ }
+ return base + sep + "threads_oauth=error&message=" + url.QueryEscape(message)
+}
+
+func OAuthFailureRedirect(successRedirect, message string) string {
+ return failureRedirect(successRedirect, message)
+}
diff --git a/backend/internal/logic/threads_account/upsert_publish_guard_policy_logic.go b/backend/internal/logic/threads_account/upsert_publish_guard_policy_logic.go
new file mode 100644
index 0000000..8eb83ad
--- /dev/null
+++ b/backend/internal/logic/threads_account/upsert_publish_guard_policy_logic.go
@@ -0,0 +1,45 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "context"
+
+ guarddomain "haixun-backend/internal/model/publish_guard/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type UpsertPublishGuardPolicyLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewUpsertPublishGuardPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertPublishGuardPolicyLogic {
+ return &UpsertPublishGuardPolicyLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *UpsertPublishGuardPolicyLogic) UpsertPublishGuardPolicy(req *types.UpsertPublishGuardPolicyHandlerReq) (resp *types.PublishGuardPolicyData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ item, err := l.svcCtx.PublishGuard.Upsert(l.ctx, guarddomain.UpsertRequest{
+ TenantID: tenantID, OwnerUID: uid, AccountID: req.ID, Enabled: req.Enabled,
+ MaxDailyPosts: req.MaxDailyPosts, MinIntervalMinutes: req.MinIntervalMinutes,
+ ConsecutiveFailurePauseLimit: req.ConsecutiveFailurePauseLimit,
+ Paused: req.Paused, PausedReason: req.PausedReason,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return toGuardPolicyData(item), nil
+}
diff --git a/backend/internal/logic/threads_account/upsert_publish_inventory_policy_logic.go b/backend/internal/logic/threads_account/upsert_publish_inventory_policy_logic.go
new file mode 100644
index 0000000..a46c585
--- /dev/null
+++ b/backend/internal/logic/threads_account/upsert_publish_inventory_policy_logic.go
@@ -0,0 +1,55 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package threads_account
+
+import (
+ "context"
+
+ inventorydomain "haixun-backend/internal/model/publish_inventory/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type UpsertPublishInventoryPolicyLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewUpsertPublishInventoryPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertPublishInventoryPolicyLogic {
+ return &UpsertPublishInventoryPolicyLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *UpsertPublishInventoryPolicyLogic) UpsertPublishInventoryPolicy(req *types.UpsertPublishInventoryPolicyHandlerReq) (resp *types.PublishInventoryPolicyData, err error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ slots := make([]inventorydomain.Slot, 0, len(req.Slots))
+ for _, slot := range req.Slots {
+ slots = append(slots, inventorydomain.Slot{Weekday: slot.Weekday, Time: slot.Time})
+ }
+ refs := make([]inventorydomain.SourceRef, 0, len(req.SourceRefs))
+ for _, ref := range req.SourceRefs {
+ refs = append(refs, inventorydomain.SourceRef{
+ Type: ref.Type, PersonaID: ref.PersonaID, CopyMissionID: ref.CopyMissionID,
+ ScanPostID: ref.ScanPostID, ManualSeed: ref.ManualSeed,
+ })
+ }
+ item, err := l.svcCtx.PublishInventory.Upsert(l.ctx, inventorydomain.UpsertRequest{
+ TenantID: tenantID, OwnerUID: uid, AccountID: req.ID, Enabled: req.Enabled,
+ TargetDailyCount: req.TargetDailyCount, LowStockThreshold: req.LowStockThreshold,
+ LookaheadDays: req.LookaheadDays, Timezone: req.Timezone, Slots: slots, SourceRefs: refs,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return toInventoryPolicyData(item), nil
+}
diff --git a/backend/internal/model/brand/domain/entity/product.go b/backend/internal/model/brand/domain/entity/product.go
index 573e57b..d08361b 100644
--- a/backend/internal/model/brand/domain/entity/product.go
+++ b/backend/internal/model/brand/domain/entity/product.go
@@ -4,6 +4,7 @@ type Product struct {
ID string `bson:"id"`
Label string `bson:"label"`
ProductContext string `bson:"product_context"`
+ PlacementURL string `bson:"placement_url,omitempty"`
MatchTags []string `bson:"match_tags,omitempty"`
CreateAt int64 `bson:"create_at"`
UpdateAt int64 `bson:"update_at"`
diff --git a/backend/internal/model/brand/domain/usecase/usecase.go b/backend/internal/model/brand/domain/usecase/usecase.go
index f9affa0..0e7cf4d 100644
--- a/backend/internal/model/brand/domain/usecase/usecase.go
+++ b/backend/internal/model/brand/domain/usecase/usecase.go
@@ -10,6 +10,7 @@ type ProductSummary struct {
ID string
Label string
ProductContext string
+ PlacementURL string
MatchTags []string
CreateAt int64
UpdateAt int64
@@ -89,18 +90,21 @@ type CreateProductRequest struct {
BrandID string
Label string
ProductContext string
+ PlacementURL string
MatchTags []string
}
type UpdateProductRequest struct {
- TenantID string
- OwnerUID string
- BrandID string
- ProductID string
- Label *string
- ProductContext *string
- MatchTags []string
- MatchTagsSet bool
+ TenantID string
+ OwnerUID string
+ BrandID string
+ ProductID string
+ Label *string
+ ProductContext *string
+ PlacementURL *string
+ PlacementURLSet bool
+ MatchTags []string
+ MatchTagsSet bool
}
type UseCase interface {
diff --git a/backend/internal/model/brand/usecase/products.go b/backend/internal/model/brand/usecase/products.go
index 03dc6de..8270001 100644
--- a/backend/internal/model/brand/usecase/products.go
+++ b/backend/internal/model/brand/usecase/products.go
@@ -41,6 +41,7 @@ func (u *brandUseCase) CreateProduct(ctx context.Context, req domusecase.CreateP
ID: uuid.NewString(),
Label: label,
ProductContext: contextRaw,
+ PlacementURL: strings.TrimSpace(req.PlacementURL),
MatchTags: normalizeMatchTags(req.MatchTags),
}
created, err := u.repo.PushProduct(ctx, req.TenantID, req.OwnerUID, req.BrandID, product)
@@ -77,6 +78,9 @@ func (u *brandUseCase) UpdateProduct(ctx context.Context, req domusecase.UpdateP
if req.MatchTagsSet {
patch["products.$.match_tags"] = normalizeMatchTags(req.MatchTags)
}
+ if req.PlacementURLSet {
+ patch["products.$.placement_url"] = strings.TrimSpace(ptrString(req.PlacementURL))
+ }
if len(patch) == 0 {
return nil, app.For(code.Brand).InputMissingRequired("product patch is empty")
}
@@ -134,12 +138,20 @@ func toProductSummary(item entity.Product) domusecase.ProductSummary {
ID: item.ID,
Label: item.Label,
ProductContext: item.ProductContext,
+ PlacementURL: item.PlacementURL,
MatchTags: append([]string(nil), item.MatchTags...),
CreateAt: item.CreateAt,
UpdateAt: item.UpdateAt,
}
}
+func ptrString(v *string) string {
+ if v == nil {
+ return ""
+ }
+ return *v
+}
+
func normalizeMatchTags(tags []string) []string {
if len(tags) == 0 {
return nil
diff --git a/backend/internal/model/brand/usecase/products_placement_url_test.go b/backend/internal/model/brand/usecase/products_placement_url_test.go
new file mode 100644
index 0000000..a3f4155
--- /dev/null
+++ b/backend/internal/model/brand/usecase/products_placement_url_test.go
@@ -0,0 +1,115 @@
+package usecase
+
+import (
+ "context"
+ "testing"
+
+ "haixun-backend/internal/model/brand/domain/entity"
+ domusecase "haixun-backend/internal/model/brand/domain/usecase"
+)
+
+type memoryBrandRepo struct {
+ brand *entity.Brand
+}
+
+func (m *memoryBrandRepo) Create(ctx context.Context, brand *entity.Brand) (*entity.Brand, error) {
+ m.brand = brand
+ return brand, nil
+}
+
+func (m *memoryBrandRepo) FindByID(ctx context.Context, tenantID, ownerUID, brandID string) (*entity.Brand, error) {
+ return m.brand, nil
+}
+
+func (m *memoryBrandRepo) ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Brand, error) {
+ return nil, nil
+}
+
+func (m *memoryBrandRepo) Update(ctx context.Context, tenantID, ownerUID, brandID string, patch map[string]interface{}) (*entity.Brand, error) {
+ return m.brand, nil
+}
+
+func (m *memoryBrandRepo) SoftDelete(ctx context.Context, tenantID, ownerUID, brandID string) error {
+ return nil
+}
+
+func (m *memoryBrandRepo) PushProduct(ctx context.Context, tenantID, ownerUID, brandID string, product entity.Product) (*entity.Product, error) {
+ m.brand.Products = append(m.brand.Products, product)
+ return &product, nil
+}
+
+func (m *memoryBrandRepo) UpdateProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string, patch map[string]interface{}) (*entity.Product, error) {
+ for i := range m.brand.Products {
+ if m.brand.Products[i].ID != productID {
+ continue
+ }
+ if v, ok := patch["products.$.label"].(string); ok {
+ m.brand.Products[i].Label = v
+ }
+ if v, ok := patch["products.$.product_context"].(string); ok {
+ m.brand.Products[i].ProductContext = v
+ }
+ if v, ok := patch["products.$.placement_url"].(string); ok {
+ m.brand.Products[i].PlacementURL = v
+ }
+ item := m.brand.Products[i]
+ return &item, nil
+ }
+ return nil, nil
+}
+
+func (m *memoryBrandRepo) PullProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string) error {
+ return nil
+}
+
+func (m *memoryBrandRepo) EnsureIndexes(ctx context.Context) error {
+ return nil
+}
+
+func TestCreateAndUpdateProductPlacementURL(t *testing.T) {
+ repo := &memoryBrandRepo{
+ brand: &entity.Brand{
+ ID: "brand-1",
+ TenantID: "tenant-1",
+ OwnerUID: "user-1",
+ Status: entity.StatusOpen,
+ },
+ }
+ uc := NewUseCase(repo)
+ ctx := context.Background()
+
+ created, err := uc.CreateProduct(ctx, domusecase.CreateProductRequest{
+ TenantID: "tenant-1",
+ OwnerUID: "user-1",
+ BrandID: "brand-1",
+ Label: "主力商品",
+ ProductContext: "賣點描述",
+ PlacementURL: "https://example.com/product",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if created.PlacementURL != "https://example.com/product" {
+ t.Fatalf("create placement_url = %q", created.PlacementURL)
+ }
+
+ label := "主力商品"
+ contextRaw := "賣點描述"
+ placementURL := "https://example.com/updated"
+ updated, err := uc.UpdateProduct(ctx, domusecase.UpdateProductRequest{
+ TenantID: "tenant-1",
+ OwnerUID: "user-1",
+ BrandID: "brand-1",
+ ProductID: created.ID,
+ Label: &label,
+ ProductContext: &contextRaw,
+ PlacementURL: &placementURL,
+ PlacementURLSet: true,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updated.PlacementURL != placementURL {
+ t.Fatalf("update placement_url = %q, want %q", updated.PlacementURL, placementURL)
+ }
+}
diff --git a/backend/internal/model/brand/usecase/usecase.go b/backend/internal/model/brand/usecase/usecase.go
index 57059f1..1b0edba 100644
--- a/backend/internal/model/brand/usecase/usecase.go
+++ b/backend/internal/model/brand/usecase/usecase.go
@@ -100,6 +100,9 @@ func (u *brandUseCase) Update(ctx context.Context, req domusecase.UpdateRequest)
return nil, err
}
patch := patchToMap(req.Patch)
+ if len(patch) == 0 {
+ return nil, app.For(code.Brand).InputMissingRequired("brand patch is empty")
+ }
if req.Patch.ProductID != nil {
snapshot := placement.ResolveBrandProductContext(*brand, strings.TrimSpace(*req.Patch.ProductID), "")
patch["product_context"] = snapshot
diff --git a/backend/internal/model/content_formula/domain/entity/formula.go b/backend/internal/model/content_formula/domain/entity/formula.go
new file mode 100644
index 0000000..0eff018
--- /dev/null
+++ b/backend/internal/model/content_formula/domain/entity/formula.go
@@ -0,0 +1,45 @@
+package entity
+
+const CollectionName = "content_formulas"
+
+const (
+ SourcePaste = "paste"
+ SourceOwnPost = "own_post"
+ SourceScanPost = "scan_post"
+ SourceKeywordSearch = "keyword_search"
+)
+
+type SourceMetrics struct {
+ LikeCount int `bson:"like_count,omitempty"`
+ ReplyCount int `bson:"reply_count,omitempty"`
+ Views int `bson:"views,omitempty"`
+ RepostCount int `bson:"repost_count,omitempty"`
+ QuoteCount int `bson:"quote_count,omitempty"`
+ Shares int `bson:"shares,omitempty"`
+}
+
+type ContentFormula struct {
+ ID string `bson:"_id"`
+ TenantID string `bson:"tenant_id"`
+ OwnerUID string `bson:"owner_uid"`
+ AccountID string `bson:"account_id"`
+ Label string `bson:"label"`
+ SourceType string `bson:"source_type"`
+ SourceRef string `bson:"source_ref,omitempty"`
+ SourcePostText string `bson:"source_post_text,omitempty"`
+ SourceAuthor string `bson:"source_author,omitempty"`
+ SourcePermalink string `bson:"source_permalink,omitempty"`
+ SourceMetrics *SourceMetrics `bson:"source_metrics,omitempty"`
+ Summary string `bson:"summary"`
+ Wins []string `bson:"wins,omitempty"`
+ Improvements []string `bson:"improvements,omitempty"`
+ Formula string `bson:"formula"`
+ PostTemplate string `bson:"post_template"`
+ HookPattern string `bson:"hook_pattern"`
+ Structure string `bson:"structure"`
+ ReplicationTips []string `bson:"replication_tips,omitempty"`
+ Avoid []string `bson:"avoid,omitempty"`
+ Tags []string `bson:"tags,omitempty"`
+ CreateAt int64 `bson:"create_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
diff --git a/backend/internal/model/content_formula/domain/repository/repository.go b/backend/internal/model/content_formula/domain/repository/repository.go
new file mode 100644
index 0000000..3ad8962
--- /dev/null
+++ b/backend/internal/model/content_formula/domain/repository/repository.go
@@ -0,0 +1,31 @@
+package repository
+
+import (
+ "context"
+
+ "haixun-backend/internal/model/content_formula/domain/entity"
+)
+
+type ListFilter struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ SourceType string
+ Tag string
+ Page int
+ PageSize int
+}
+
+type ListResult struct {
+ Items []entity.ContentFormula
+ Total int64
+}
+
+type Repository interface {
+ EnsureIndexes(ctx context.Context) error
+ Create(ctx context.Context, formula *entity.ContentFormula) error
+ Get(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) (*entity.ContentFormula, error)
+ Update(ctx context.Context, tenantID, ownerUID, accountID, formulaID string, patch map[string]interface{}) (*entity.ContentFormula, error)
+ Delete(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) error
+ List(ctx context.Context, filter ListFilter) (*ListResult, error)
+}
diff --git a/backend/internal/model/content_formula/domain/usecase/usecase.go b/backend/internal/model/content_formula/domain/usecase/usecase.go
new file mode 100644
index 0000000..3969de1
--- /dev/null
+++ b/backend/internal/model/content_formula/domain/usecase/usecase.go
@@ -0,0 +1,90 @@
+package usecase
+
+import (
+ "context"
+
+ "haixun-backend/internal/model/content_formula/domain/entity"
+)
+
+type FormulaSummary struct {
+ ID string
+ AccountID string
+ Label string
+ SourceType string
+ SourceRef string
+ SourcePostText string
+ SourceAuthor string
+ SourcePermalink string
+ SourceMetrics *entity.SourceMetrics
+ Summary string
+ Wins []string
+ Improvements []string
+ Formula string
+ PostTemplate string
+ HookPattern string
+ Structure string
+ ReplicationTips []string
+ Avoid []string
+ Tags []string
+ CreateAt int64
+ UpdateAt int64
+}
+
+type CreateRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ Label string
+ SourceType string
+ SourceRef string
+ SourcePostText string
+ SourceAuthor string
+ SourcePermalink string
+ SourceMetrics *entity.SourceMetrics
+ Summary string
+ Wins []string
+ Improvements []string
+ Formula string
+ PostTemplate string
+ HookPattern string
+ Structure string
+ ReplicationTips []string
+ Avoid []string
+ Tags []string
+}
+
+type UpdateRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ FormulaID string
+ Label *string
+ Formula *string
+ PostTemplate *string
+ HookPattern *string
+ Structure *string
+ Tags []string
+}
+
+type ListRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ SourceType string
+ Tag string
+ Page int
+ PageSize int
+}
+
+type ListResult struct {
+ Items []FormulaSummary
+ Total int64
+}
+
+type UseCase interface {
+ Create(ctx context.Context, req CreateRequest) (*FormulaSummary, error)
+ Get(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) (*FormulaSummary, error)
+ Update(ctx context.Context, req UpdateRequest) (*FormulaSummary, error)
+ Delete(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) error
+ List(ctx context.Context, req ListRequest) (*ListResult, error)
+}
diff --git a/backend/internal/model/content_formula/repository/mongo.go b/backend/internal/model/content_formula/repository/mongo.go
new file mode 100644
index 0000000..b7336e3
--- /dev/null
+++ b/backend/internal/model/content_formula/repository/mongo.go
@@ -0,0 +1,165 @@
+package repository
+
+import (
+ "context"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/content_formula/domain/entity"
+ domrepo "haixun-backend/internal/model/content_formula/domain/repository"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+type mongoRepository struct {
+ collection *mongo.Collection
+}
+
+func NewMongoRepository(db *mongo.Database) domrepo.Repository {
+ if db == nil {
+ return &mongoRepository{}
+ }
+ return &mongoRepository{collection: db.Collection(entity.CollectionName)}
+}
+
+func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
+ if r.collection == nil {
+ return nil
+ }
+ _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
+ {
+ Keys: bson.D{
+ {Key: "tenant_id", Value: 1},
+ {Key: "owner_uid", Value: 1},
+ {Key: "account_id", Value: 1},
+ {Key: "update_at", Value: -1},
+ },
+ },
+ })
+ return err
+}
+
+func actorFilter(tenantID, ownerUID, accountID string) bson.M {
+ return bson.M{
+ "tenant_id": strings.TrimSpace(tenantID),
+ "owner_uid": strings.TrimSpace(ownerUID),
+ "account_id": strings.TrimSpace(accountID),
+ }
+}
+
+func normalizePage(page, pageSize int) (int, int) {
+ if page <= 0 {
+ page = 1
+ }
+ if pageSize <= 0 {
+ pageSize = 20
+ }
+ if pageSize > 100 {
+ pageSize = 100
+ }
+ return page, pageSize
+}
+
+func (r *mongoRepository) Create(ctx context.Context, formula *entity.ContentFormula) error {
+ if r.collection == nil {
+ return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ if formula == nil {
+ return app.For(code.ThreadsAccount).InputMissingRequired("formula is required")
+ }
+ _, err := r.collection.InsertOne(ctx, formula)
+ return err
+}
+
+func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) (*entity.ContentFormula, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ formulaID = strings.TrimSpace(formulaID)
+ if formulaID == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("formula_id is required")
+ }
+ filter := actorFilter(tenantID, ownerUID, accountID)
+ filter["_id"] = formulaID
+ var out entity.ContentFormula
+ err := r.collection.FindOne(ctx, filter).Decode(&out)
+ if err != nil {
+ if err == mongo.ErrNoDocuments {
+ return nil, app.For(code.ThreadsAccount).ResNotFound("content formula not found")
+ }
+ return nil, err
+ }
+ return &out, nil
+}
+
+func (r *mongoRepository) Update(ctx context.Context, tenantID, ownerUID, accountID, formulaID string, patch map[string]interface{}) (*entity.ContentFormula, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ if len(patch) == 0 {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("patch is required")
+ }
+ filter := actorFilter(tenantID, ownerUID, accountID)
+ filter["_id"] = strings.TrimSpace(formulaID)
+ opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
+ var out entity.ContentFormula
+ err := r.collection.FindOneAndUpdate(ctx, filter, bson.M{"$set": patch}, opts).Decode(&out)
+ if err != nil {
+ if err == mongo.ErrNoDocuments {
+ return nil, app.For(code.ThreadsAccount).ResNotFound("content formula not found")
+ }
+ return nil, err
+ }
+ return &out, nil
+}
+
+func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) error {
+ if r.collection == nil {
+ return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ filter := actorFilter(tenantID, ownerUID, accountID)
+ filter["_id"] = strings.TrimSpace(formulaID)
+ res, err := r.collection.DeleteOne(ctx, filter)
+ if err != nil {
+ return err
+ }
+ if res.DeletedCount == 0 {
+ return app.For(code.ThreadsAccount).ResNotFound("content formula not found")
+ }
+ return nil
+}
+
+func (r *mongoRepository) List(ctx context.Context, filter domrepo.ListFilter) (*domrepo.ListResult, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ page, pageSize := normalizePage(filter.Page, filter.PageSize)
+ mongoFilter := actorFilter(filter.TenantID, filter.OwnerUID, filter.AccountID)
+ if sourceType := strings.TrimSpace(filter.SourceType); sourceType != "" {
+ mongoFilter["source_type"] = sourceType
+ }
+ if tag := strings.TrimSpace(filter.Tag); tag != "" {
+ mongoFilter["tags"] = tag
+ }
+ total, err := r.collection.CountDocuments(ctx, mongoFilter)
+ if err != nil {
+ return nil, err
+ }
+ opts := options.Find().
+ SetSort(bson.D{{Key: "update_at", Value: -1}}).
+ SetSkip(int64((page - 1) * pageSize)).
+ SetLimit(int64(pageSize))
+ cur, err := r.collection.Find(ctx, mongoFilter, opts)
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ var items []entity.ContentFormula
+ if err := cur.All(ctx, &items); err != nil {
+ return nil, err
+ }
+ return &domrepo.ListResult{Items: items, Total: total}, nil
+}
diff --git a/backend/internal/model/content_formula/usecase/usecase.go b/backend/internal/model/content_formula/usecase/usecase.go
new file mode 100644
index 0000000..9cd4eb9
--- /dev/null
+++ b/backend/internal/model/content_formula/usecase/usecase.go
@@ -0,0 +1,184 @@
+package usecase
+
+import (
+ "context"
+ "strings"
+
+ "haixun-backend/internal/library/clock"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/content_formula/domain/entity"
+ domrepo "haixun-backend/internal/model/content_formula/domain/repository"
+ domusecase "haixun-backend/internal/model/content_formula/domain/usecase"
+
+ "github.com/google/uuid"
+)
+
+type useCase struct {
+ repo domrepo.Repository
+}
+
+func NewUseCase(repo domrepo.Repository) domusecase.UseCase {
+ return &useCase{repo: repo}
+}
+
+func (u *useCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.FormulaSummary, error) {
+ if err := requireActor(req.TenantID, req.OwnerUID, req.AccountID); err != nil {
+ return nil, err
+ }
+ label := strings.TrimSpace(req.Label)
+ if label == "" {
+ label = "未命名公式"
+ }
+ sourceType := strings.TrimSpace(req.SourceType)
+ if sourceType == "" {
+ sourceType = entity.SourcePaste
+ }
+ formulaText := strings.TrimSpace(req.Formula)
+ if formulaText == "" && strings.TrimSpace(req.PostTemplate) == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("formula or post_template is required")
+ }
+ now := clock.NowUnixNano()
+ item := &entity.ContentFormula{
+ ID: uuid.NewString(),
+ TenantID: req.TenantID,
+ OwnerUID: req.OwnerUID,
+ AccountID: req.AccountID,
+ Label: label,
+ SourceType: sourceType,
+ SourceRef: strings.TrimSpace(req.SourceRef),
+ SourcePostText: strings.TrimSpace(req.SourcePostText),
+ SourceAuthor: strings.TrimSpace(req.SourceAuthor),
+ SourcePermalink: strings.TrimSpace(req.SourcePermalink),
+ SourceMetrics: req.SourceMetrics,
+ Summary: strings.TrimSpace(req.Summary),
+ Wins: req.Wins,
+ Improvements: req.Improvements,
+ Formula: formulaText,
+ PostTemplate: strings.TrimSpace(req.PostTemplate),
+ HookPattern: strings.TrimSpace(req.HookPattern),
+ Structure: strings.TrimSpace(req.Structure),
+ ReplicationTips: req.ReplicationTips,
+ Avoid: req.Avoid,
+ Tags: req.Tags,
+ CreateAt: now,
+ UpdateAt: now,
+ }
+ if err := u.repo.Create(ctx, item); err != nil {
+ return nil, err
+ }
+ summary := toSummary(*item)
+ return &summary, nil
+}
+
+func (u *useCase) Get(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) (*domusecase.FormulaSummary, error) {
+ if err := requireActor(tenantID, ownerUID, accountID); err != nil {
+ return nil, err
+ }
+ item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, formulaID)
+ if err != nil {
+ return nil, err
+ }
+ summary := toSummary(*item)
+ return &summary, nil
+}
+
+func (u *useCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.FormulaSummary, error) {
+ if err := requireActor(req.TenantID, req.OwnerUID, req.AccountID); err != nil {
+ return nil, err
+ }
+ patch := map[string]interface{}{"update_at": clock.NowUnixNano()}
+ if req.Label != nil {
+ patch["label"] = strings.TrimSpace(*req.Label)
+ }
+ if req.Formula != nil {
+ patch["formula"] = strings.TrimSpace(*req.Formula)
+ }
+ if req.PostTemplate != nil {
+ patch["post_template"] = strings.TrimSpace(*req.PostTemplate)
+ }
+ if req.HookPattern != nil {
+ patch["hook_pattern"] = strings.TrimSpace(*req.HookPattern)
+ }
+ if req.Structure != nil {
+ patch["structure"] = strings.TrimSpace(*req.Structure)
+ }
+ if req.Tags != nil {
+ patch["tags"] = req.Tags
+ }
+ if len(patch) == 1 {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("no fields to update")
+ }
+ item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.AccountID, req.FormulaID, patch)
+ if err != nil {
+ return nil, err
+ }
+ summary := toSummary(*item)
+ return &summary, nil
+}
+
+func (u *useCase) Delete(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) error {
+ if err := requireActor(tenantID, ownerUID, accountID); err != nil {
+ return err
+ }
+ return u.repo.Delete(ctx, tenantID, ownerUID, accountID, formulaID)
+}
+
+func (u *useCase) List(ctx context.Context, req domusecase.ListRequest) (*domusecase.ListResult, error) {
+ if err := requireActor(req.TenantID, req.OwnerUID, req.AccountID); err != nil {
+ return nil, err
+ }
+ result, err := u.repo.List(ctx, domrepo.ListFilter{
+ TenantID: req.TenantID,
+ OwnerUID: req.OwnerUID,
+ AccountID: req.AccountID,
+ SourceType: req.SourceType,
+ Tag: req.Tag,
+ Page: req.Page,
+ PageSize: req.PageSize,
+ })
+ if err != nil {
+ return nil, err
+ }
+ out := make([]domusecase.FormulaSummary, 0, len(result.Items))
+ for _, item := range result.Items {
+ out = append(out, toSummary(item))
+ }
+ return &domusecase.ListResult{Items: out, Total: result.Total}, nil
+}
+
+func requireActor(tenantID, ownerUID, accountID string) error {
+ if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
+ return app.For(code.Auth).AuthUnauthorized("missing actor")
+ }
+ if strings.TrimSpace(accountID) == "" {
+ return app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
+ }
+ return nil
+}
+
+func toSummary(item entity.ContentFormula) domusecase.FormulaSummary {
+ return domusecase.FormulaSummary{
+ ID: item.ID,
+ AccountID: item.AccountID,
+ Label: item.Label,
+ SourceType: item.SourceType,
+ SourceRef: item.SourceRef,
+ SourcePostText: item.SourcePostText,
+ SourceAuthor: item.SourceAuthor,
+ SourcePermalink: item.SourcePermalink,
+ SourceMetrics: item.SourceMetrics,
+ Summary: item.Summary,
+ Wins: item.Wins,
+ Improvements: item.Improvements,
+ Formula: item.Formula,
+ PostTemplate: item.PostTemplate,
+ HookPattern: item.HookPattern,
+ Structure: item.Structure,
+ ReplicationTips: item.ReplicationTips,
+ Avoid: item.Avoid,
+ Tags: item.Tags,
+ CreateAt: item.CreateAt,
+ UpdateAt: item.UpdateAt,
+ }
+}
diff --git a/backend/internal/model/content_ops/domain/entity/entity.go b/backend/internal/model/content_ops/domain/entity/entity.go
new file mode 100644
index 0000000..5bafaf7
--- /dev/null
+++ b/backend/internal/model/content_ops/domain/entity/entity.go
@@ -0,0 +1,112 @@
+package entity
+
+const (
+ TopicCandidateCollectionName = "topic_candidates"
+ ContentPlanCollectionName = "content_plans"
+ FeedbackEventCollectionName = "feedback_events"
+ KnowledgeSourceCollectionName = "knowledge_sources"
+ KnowledgeChunkCollectionName = "knowledge_chunks"
+ FormulaPoolCollectionName = "formula_pools"
+)
+
+type TopicCandidate struct {
+ ID string `bson:"_id"`
+ TenantID string `bson:"tenant_id"`
+ OwnerUID string `bson:"owner_uid"`
+ PersonaID string `bson:"persona_id"`
+ Name string `bson:"name"`
+ Source string `bson:"source,omitempty"`
+ Category string `bson:"category,omitempty"`
+ SeedQuery string `bson:"seed_query,omitempty"`
+ TrendReason string `bson:"trend_reason,omitempty"`
+ TrendKeywords []string `bson:"trend_keywords,omitempty"`
+ HeatScore int `bson:"heat_score,omitempty"`
+ FitScore int `bson:"fit_score,omitempty"`
+ InteractionScore int `bson:"interaction_score,omitempty"`
+ ExtendScore int `bson:"extend_score,omitempty"`
+ RiskScore int `bson:"risk_score,omitempty"`
+ FinalScore float64 `bson:"final_score,omitempty"`
+ RecommendedMissions []string `bson:"recommended_missions,omitempty"`
+ Status string `bson:"status,omitempty"`
+ CreateAt int64 `bson:"create_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
+
+type KnowledgeSource struct {
+ ID string `bson:"_id"`
+ TenantID string `bson:"tenant_id"`
+ OwnerUID string `bson:"owner_uid"`
+ PersonaID string `bson:"persona_id"`
+ SourceType string `bson:"source_type"`
+ Title string `bson:"title,omitempty"`
+ Filename string `bson:"filename,omitempty"`
+ RawContent string `bson:"raw_content,omitempty"`
+ ParsedStatus string `bson:"parsed_status,omitempty"`
+ ChunkCount int `bson:"chunk_count,omitempty"`
+ CreateAt int64 `bson:"create_at"`
+}
+
+type KnowledgeChunk struct {
+ ID string `bson:"_id"`
+ TenantID string `bson:"tenant_id"`
+ OwnerUID string `bson:"owner_uid"`
+ PersonaID string `bson:"persona_id"`
+ SourceID string `bson:"source_id"`
+ Content string `bson:"content"`
+ Topics []string `bson:"topics,omitempty"`
+ StyleTags []string `bson:"style_tags,omitempty"`
+ RiskLevel string `bson:"risk_level,omitempty"`
+ CreateAt int64 `bson:"create_at"`
+}
+
+type FormulaPool struct {
+ ID string `bson:"_id"`
+ TenantID string `bson:"tenant_id"`
+ OwnerUID string `bson:"owner_uid"`
+ PersonaID string `bson:"persona_id"`
+ Type string `bson:"type"`
+ Name string `bson:"name"`
+ Pattern string `bson:"pattern,omitempty"`
+ UseCases []string `bson:"use_cases,omitempty"`
+ Avoid []string `bson:"avoid,omitempty"`
+ Weight int `bson:"weight,omitempty"`
+ CreateAt int64 `bson:"create_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
+
+type ContentPlan struct {
+ ID string `bson:"_id"`
+ TenantID string `bson:"tenant_id"`
+ OwnerUID string `bson:"owner_uid"`
+ PersonaID string `bson:"persona_id"`
+ TopicCandidateID string `bson:"topic_candidate_id,omitempty"`
+ Topic string `bson:"topic"`
+ Mission string `bson:"mission"`
+ TargetAudience string `bson:"target_audience,omitempty"`
+ Angle string `bson:"angle,omitempty"`
+ OpeningType string `bson:"opening_type,omitempty"`
+ BodyType string `bson:"body_type,omitempty"`
+ Emotion string `bson:"emotion,omitempty"`
+ EndingType string `bson:"ending_type,omitempty"`
+ CtaType string `bson:"cta_type,omitempty"`
+ RiskLevel string `bson:"risk_level,omitempty"`
+ RequiresHumanReview bool `bson:"requires_human_review,omitempty"`
+ Avoid []string `bson:"avoid,omitempty"`
+ SelectedKnowledge []string `bson:"selected_knowledge,omitempty"`
+ Status string `bson:"status,omitempty"`
+ CreateAt int64 `bson:"create_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
+
+type FeedbackEvent struct {
+ ID string `bson:"_id"`
+ TenantID string `bson:"tenant_id"`
+ OwnerUID string `bson:"owner_uid"`
+ PersonaID string `bson:"persona_id"`
+ ContentPlanID string `bson:"content_plan_id,omitempty"`
+ DraftID string `bson:"draft_id,omitempty"`
+ Decision string `bson:"decision"`
+ Note string `bson:"note,omitempty"`
+ Snapshot string `bson:"snapshot,omitempty"`
+ CreateAt int64 `bson:"create_at"`
+}
diff --git a/backend/internal/model/content_ops/domain/repository/repository.go b/backend/internal/model/content_ops/domain/repository/repository.go
new file mode 100644
index 0000000..768f9b8
--- /dev/null
+++ b/backend/internal/model/content_ops/domain/repository/repository.go
@@ -0,0 +1,24 @@
+package repository
+
+import (
+ "context"
+
+ "haixun-backend/internal/model/content_ops/domain/entity"
+)
+
+type Repository interface {
+ EnsureIndexes(ctx context.Context) error
+ CreateTopicCandidate(ctx context.Context, item *entity.TopicCandidate) error
+ ListTopicCandidates(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.TopicCandidate, error)
+ CreateContentPlan(ctx context.Context, item *entity.ContentPlan) error
+ UpdateContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string, patch map[string]any) (*entity.ContentPlan, error)
+ GetContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string) (*entity.ContentPlan, error)
+ ListContentPlans(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.ContentPlan, error)
+ CreateFeedbackEvent(ctx context.Context, item *entity.FeedbackEvent) error
+ ListFeedbackEvents(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.FeedbackEvent, error)
+ CreateKnowledgeSource(ctx context.Context, item *entity.KnowledgeSource, chunks []*entity.KnowledgeChunk) error
+ ListKnowledgeSources(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.KnowledgeSource, error)
+ ListKnowledgeChunks(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.KnowledgeChunk, error)
+ CreateFormulaPool(ctx context.Context, item *entity.FormulaPool) error
+ ListFormulaPools(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.FormulaPool, error)
+}
diff --git a/backend/internal/model/content_ops/domain/usecase/usecase.go b/backend/internal/model/content_ops/domain/usecase/usecase.go
new file mode 100644
index 0000000..a715fa4
--- /dev/null
+++ b/backend/internal/model/content_ops/domain/usecase/usecase.go
@@ -0,0 +1,163 @@
+package usecase
+
+import "context"
+
+type TopicCandidateSummary struct {
+ ID string
+ PersonaID string
+ Name string
+ Source string
+ Category string
+ SeedQuery string
+ TrendReason string
+ TrendKeywords []string
+ HeatScore int
+ FitScore int
+ InteractionScore int
+ ExtendScore int
+ RiskScore int
+ FinalScore float64
+ RecommendedMissions []string
+ Status string
+ CreateAt int64
+ UpdateAt int64
+}
+
+type ContentPlanSummary struct {
+ ID string
+ PersonaID string
+ TopicCandidateID string
+ Topic string
+ Mission string
+ TargetAudience string
+ Angle string
+ OpeningType string
+ BodyType string
+ Emotion string
+ EndingType string
+ CtaType string
+ RiskLevel string
+ RequiresHumanReview bool
+ Avoid []string
+ SelectedKnowledge []string
+ Status string
+ CreateAt int64
+ UpdateAt int64
+}
+
+type FeedbackEventSummary struct {
+ ID string
+ PersonaID string
+ ContentPlanID string
+ DraftID string
+ Decision string
+ Note string
+ Snapshot string
+ CreateAt int64
+}
+
+type KnowledgeSourceSummary struct {
+ ID, PersonaID, SourceType, Title, Filename, ParsedStatus string
+ ChunkCount int
+ CreateAt int64
+}
+type KnowledgeChunkSummary struct {
+ ID, PersonaID, SourceID, Content, RiskLevel string
+ Topics, StyleTags []string
+ CreateAt int64
+}
+type FormulaPoolSummary struct {
+ ID, PersonaID, Type, Name, Pattern string
+ UseCases, Avoid []string
+ Weight int
+ CreateAt, UpdateAt int64
+}
+
+type KnowledgeSourceInput struct{ TenantID, OwnerUID, PersonaID, SourceType, Title, Filename, Content, ContentBase64 string }
+type FormulaPoolInput struct {
+ TenantID, OwnerUID, PersonaID, Type, Name, Pattern string
+ UseCases, Avoid []string
+ Weight int
+}
+
+type TopicCandidateInput struct {
+ TenantID string
+ OwnerUID string
+ PersonaID string
+ Name string
+ Source string
+ Category string
+ SeedQuery string
+ TrendReason string
+ TrendKeywords []string
+ HeatScore int
+ FitScore int
+ InteractionScore int
+ ExtendScore int
+ RiskScore int
+ RecommendedMissions []string
+}
+
+type ContentPlanInput struct {
+ TenantID string
+ OwnerUID string
+ PersonaID string
+ TopicCandidateID string
+ Topic string
+ Mission string
+ TargetAudience string
+ Angle string
+ OpeningType string
+ BodyType string
+ Emotion string
+ EndingType string
+ CtaType string
+ RiskLevel string
+ RequiresHumanReview bool
+ Avoid []string
+ SelectedKnowledge []string
+}
+
+type ContentPlanPatch struct {
+ Topic *string
+ Mission *string
+ TargetAudience *string
+ Angle *string
+ OpeningType *string
+ BodyType *string
+ Emotion *string
+ EndingType *string
+ CtaType *string
+ RiskLevel *string
+ RequiresHumanReview *bool
+ Avoid []string
+ SelectedKnowledge []string
+ Status *string
+}
+
+type FeedbackEventInput struct {
+ TenantID string
+ OwnerUID string
+ PersonaID string
+ ContentPlanID string
+ DraftID string
+ Decision string
+ Note string
+ Snapshot string
+}
+
+type UseCase interface {
+ CreateTopicCandidate(ctx context.Context, req TopicCandidateInput) (*TopicCandidateSummary, error)
+ ListTopicCandidates(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]TopicCandidateSummary, error)
+ CreateContentPlan(ctx context.Context, req ContentPlanInput) (*ContentPlanSummary, error)
+ UpdateContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string, patch ContentPlanPatch) (*ContentPlanSummary, error)
+ GetContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string) (*ContentPlanSummary, error)
+ ListContentPlans(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]ContentPlanSummary, error)
+ CreateFeedbackEvent(ctx context.Context, req FeedbackEventInput) (*FeedbackEventSummary, error)
+ ListFeedbackEvents(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]FeedbackEventSummary, error)
+ CreateKnowledgeSource(ctx context.Context, req KnowledgeSourceInput) (*KnowledgeSourceSummary, error)
+ ListKnowledgeSources(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]KnowledgeSourceSummary, error)
+ ListKnowledgeChunks(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]KnowledgeChunkSummary, error)
+ CreateFormulaPool(ctx context.Context, req FormulaPoolInput) (*FormulaPoolSummary, error)
+ ListFormulaPools(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]FormulaPoolSummary, error)
+}
diff --git a/backend/internal/model/content_ops/repository/mongo.go b/backend/internal/model/content_ops/repository/mongo.go
new file mode 100644
index 0000000..9444d99
--- /dev/null
+++ b/backend/internal/model/content_ops/repository/mongo.go
@@ -0,0 +1,232 @@
+package repository
+
+import (
+ "context"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/content_ops/domain/entity"
+ domrepo "haixun-backend/internal/model/content_ops/domain/repository"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+type mongoRepository struct {
+ topics *mongo.Collection
+ plans *mongo.Collection
+ feedback *mongo.Collection
+ sources *mongo.Collection
+ chunks *mongo.Collection
+ formulas *mongo.Collection
+}
+
+func NewMongoRepository(db *mongo.Database) domrepo.Repository {
+ if db == nil {
+ return &mongoRepository{}
+ }
+ return &mongoRepository{
+ topics: db.Collection(entity.TopicCandidateCollectionName),
+ plans: db.Collection(entity.ContentPlanCollectionName),
+ feedback: db.Collection(entity.FeedbackEventCollectionName),
+ sources: db.Collection(entity.KnowledgeSourceCollectionName),
+ chunks: db.Collection(entity.KnowledgeChunkCollectionName),
+ formulas: db.Collection(entity.FormulaPoolCollectionName),
+ }
+}
+
+func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
+ if r.topics == nil || r.plans == nil || r.feedback == nil || r.sources == nil || r.chunks == nil || r.formulas == nil {
+ return nil
+ }
+ if _, err := r.topics.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}}); err != nil {
+ return err
+ }
+ if _, err := r.plans.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}, {Keys: bson.D{{Key: "topic_candidate_id", Value: 1}}}}); err != nil {
+ return err
+ }
+ _, err := r.feedback.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}, {Keys: bson.D{{Key: "content_plan_id", Value: 1}}}, {Keys: bson.D{{Key: "draft_id", Value: 1}}}})
+ if err != nil {
+ return err
+ }
+ if _, err := r.sources.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}}); err != nil {
+ return err
+ }
+ if _, err := r.chunks.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}, {Keys: bson.D{{Key: "source_id", Value: 1}}}}); err != nil {
+ return err
+ }
+ _, err = r.formulas.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "type", Value: 1}}}})
+ return err
+}
+
+func personaFilter(tenantID, ownerUID, personaID string) bson.M {
+ return bson.M{"tenant_id": strings.TrimSpace(tenantID), "owner_uid": strings.TrimSpace(ownerUID), "persona_id": strings.TrimSpace(personaID)}
+}
+
+func normalizeLimit(limit int) int64 {
+ if limit <= 0 {
+ limit = 50
+ }
+ if limit > 200 {
+ limit = 200
+ }
+ return int64(limit)
+}
+
+func (r *mongoRepository) CreateTopicCandidate(ctx context.Context, item *entity.TopicCandidate) error {
+ if r.topics == nil {
+ return app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ _, err := r.topics.InsertOne(ctx, item)
+ return err
+}
+
+func (r *mongoRepository) ListTopicCandidates(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.TopicCandidate, error) {
+ if r.topics == nil {
+ return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ cur, err := r.topics.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit)))
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ var out []entity.TopicCandidate
+ return out, cur.All(ctx, &out)
+}
+
+func (r *mongoRepository) CreateContentPlan(ctx context.Context, item *entity.ContentPlan) error {
+ if r.plans == nil {
+ return app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ _, err := r.plans.InsertOne(ctx, item)
+ return err
+}
+
+func (r *mongoRepository) UpdateContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string, patch map[string]any) (*entity.ContentPlan, error) {
+ if r.plans == nil {
+ return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ filter := personaFilter(tenantID, ownerUID, personaID)
+ filter["_id"] = strings.TrimSpace(id)
+ var out entity.ContentPlan
+ err := r.plans.FindOneAndUpdate(ctx, filter, bson.M{"$set": patch}, options.FindOneAndUpdate().SetReturnDocument(options.After)).Decode(&out)
+ if err == mongo.ErrNoDocuments {
+ return nil, app.For(code.Persona).ResNotFound("找不到內容計畫")
+ }
+ return &out, err
+}
+
+func (r *mongoRepository) GetContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string) (*entity.ContentPlan, error) {
+ if r.plans == nil {
+ return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ filter := personaFilter(tenantID, ownerUID, personaID)
+ filter["_id"] = strings.TrimSpace(id)
+ var out entity.ContentPlan
+ err := r.plans.FindOne(ctx, filter).Decode(&out)
+ if err == mongo.ErrNoDocuments {
+ return nil, app.For(code.Persona).ResNotFound("找不到內容計畫")
+ }
+ return &out, err
+}
+
+func (r *mongoRepository) ListContentPlans(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.ContentPlan, error) {
+ if r.plans == nil {
+ return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ cur, err := r.plans.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit)))
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ var out []entity.ContentPlan
+ return out, cur.All(ctx, &out)
+}
+
+func (r *mongoRepository) CreateFeedbackEvent(ctx context.Context, item *entity.FeedbackEvent) error {
+ if r.feedback == nil {
+ return app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ _, err := r.feedback.InsertOne(ctx, item)
+ return err
+}
+
+func (r *mongoRepository) ListFeedbackEvents(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.FeedbackEvent, error) {
+ if r.feedback == nil {
+ return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ cur, err := r.feedback.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit)))
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ var out []entity.FeedbackEvent
+ return out, cur.All(ctx, &out)
+}
+
+func (r *mongoRepository) CreateKnowledgeSource(ctx context.Context, item *entity.KnowledgeSource, chunks []*entity.KnowledgeChunk) error {
+ if r.sources == nil || r.chunks == nil {
+ return app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ if _, err := r.sources.InsertOne(ctx, item); err != nil {
+ return err
+ }
+ if len(chunks) == 0 {
+ return nil
+ }
+ docs := make([]any, 0, len(chunks))
+ for _, c := range chunks {
+ docs = append(docs, c)
+ }
+ _, err := r.chunks.InsertMany(ctx, docs)
+ return err
+}
+
+func (r *mongoRepository) ListKnowledgeSources(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.KnowledgeSource, error) {
+ if r.sources == nil {
+ return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ cur, err := r.sources.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit)))
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ var out []entity.KnowledgeSource
+ return out, cur.All(ctx, &out)
+}
+
+func (r *mongoRepository) ListKnowledgeChunks(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.KnowledgeChunk, error) {
+ if r.chunks == nil {
+ return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ cur, err := r.chunks.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit)))
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ var out []entity.KnowledgeChunk
+ return out, cur.All(ctx, &out)
+}
+
+func (r *mongoRepository) CreateFormulaPool(ctx context.Context, item *entity.FormulaPool) error {
+ if r.formulas == nil {
+ return app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ _, err := r.formulas.InsertOne(ctx, item)
+ return err
+}
+
+func (r *mongoRepository) ListFormulaPools(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.FormulaPool, error) {
+ if r.formulas == nil {
+ return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ cur, err := r.formulas.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "weight", Value: -1}, {Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit)))
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ var out []entity.FormulaPool
+ return out, cur.All(ctx, &out)
+}
diff --git a/backend/internal/model/content_ops/usecase/usecase.go b/backend/internal/model/content_ops/usecase/usecase.go
new file mode 100644
index 0000000..898c0b8
--- /dev/null
+++ b/backend/internal/model/content_ops/usecase/usecase.go
@@ -0,0 +1,415 @@
+package usecase
+
+import (
+ "archive/zip"
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/xml"
+ "math"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "haixun-backend/internal/library/clock"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/content_ops/domain/entity"
+ domrepo "haixun-backend/internal/model/content_ops/domain/repository"
+ domusecase "haixun-backend/internal/model/content_ops/domain/usecase"
+
+ "github.com/google/uuid"
+)
+
+type useCase struct{ repo domrepo.Repository }
+
+func NewUseCase(repo domrepo.Repository) domusecase.UseCase { return &useCase{repo: repo} }
+
+func requireActor(tenantID, ownerUID, personaID string) error {
+ if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" || strings.TrimSpace(personaID) == "" {
+ return app.For(code.Persona).InputMissingRequired("tenant_id, owner_uid, and persona_id are required")
+ }
+ return nil
+}
+
+func clampScore(n int) int {
+ if n < 0 {
+ return 0
+ }
+ if n > 100 {
+ return 100
+ }
+ return n
+}
+
+func finalScore(heat, fit, interaction, extend, risk int) float64 {
+ score := float64(clampScore(heat))*0.25 + float64(clampScore(fit))*0.30 + float64(clampScore(interaction))*0.15 + float64(clampScore(extend))*0.15 + 70*0.10 - float64(clampScore(risk))*0.20
+ return math.Round(score*10) / 10
+}
+
+func (u *useCase) CreateTopicCandidate(ctx context.Context, req domusecase.TopicCandidateInput) (*domusecase.TopicCandidateSummary, error) {
+ if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
+ return nil, err
+ }
+ name := strings.TrimSpace(req.Name)
+ if name == "" {
+ return nil, app.For(code.Persona).InputMissingRequired("topic name is required")
+ }
+ now := clock.NowUnixNano()
+ item := &entity.TopicCandidate{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, Name: name, Source: strings.TrimSpace(req.Source), Category: strings.TrimSpace(req.Category), SeedQuery: strings.TrimSpace(req.SeedQuery), TrendReason: strings.TrimSpace(req.TrendReason), TrendKeywords: req.TrendKeywords, HeatScore: clampScore(req.HeatScore), FitScore: clampScore(req.FitScore), InteractionScore: clampScore(req.InteractionScore), ExtendScore: clampScore(req.ExtendScore), RiskScore: clampScore(req.RiskScore), RecommendedMissions: req.RecommendedMissions, Status: "candidate", CreateAt: now, UpdateAt: now}
+ item.FinalScore = finalScore(item.HeatScore, item.FitScore, item.InteractionScore, item.ExtendScore, item.RiskScore)
+ if err := u.repo.CreateTopicCandidate(ctx, item); err != nil {
+ return nil, err
+ }
+ out := topicSummary(*item)
+ return &out, nil
+}
+
+func (u *useCase) ListTopicCandidates(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.TopicCandidateSummary, error) {
+ items, err := u.repo.ListTopicCandidates(ctx, tenantID, ownerUID, personaID, limit)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]domusecase.TopicCandidateSummary, 0, len(items))
+ for _, item := range items {
+ out = append(out, topicSummary(item))
+ }
+ return out, nil
+}
+
+func (u *useCase) CreateContentPlan(ctx context.Context, req domusecase.ContentPlanInput) (*domusecase.ContentPlanSummary, error) {
+ if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
+ return nil, err
+ }
+ if strings.TrimSpace(req.Topic) == "" || strings.TrimSpace(req.Mission) == "" {
+ return nil, app.For(code.Persona).InputMissingRequired("topic and mission are required")
+ }
+ now := clock.NowUnixNano()
+ item := &entity.ContentPlan{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, TopicCandidateID: strings.TrimSpace(req.TopicCandidateID), Topic: strings.TrimSpace(req.Topic), Mission: strings.TrimSpace(req.Mission), TargetAudience: strings.TrimSpace(req.TargetAudience), Angle: strings.TrimSpace(req.Angle), OpeningType: strings.TrimSpace(req.OpeningType), BodyType: strings.TrimSpace(req.BodyType), Emotion: strings.TrimSpace(req.Emotion), EndingType: strings.TrimSpace(req.EndingType), CtaType: strings.TrimSpace(req.CtaType), RiskLevel: normalizeRisk(req.RiskLevel), RequiresHumanReview: req.RequiresHumanReview || normalizeRisk(req.RiskLevel) != "low", Avoid: req.Avoid, SelectedKnowledge: req.SelectedKnowledge, Status: "planned", CreateAt: now, UpdateAt: now}
+ if err := u.repo.CreateContentPlan(ctx, item); err != nil {
+ return nil, err
+ }
+ out := planSummary(*item)
+ return &out, nil
+}
+
+func (u *useCase) UpdateContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string, patch domusecase.ContentPlanPatch) (*domusecase.ContentPlanSummary, error) {
+ m := map[string]any{"update_at": clock.NowUnixNano()}
+ setString := func(key string, value *string) {
+ if value != nil {
+ m[key] = strings.TrimSpace(*value)
+ }
+ }
+ setString("topic", patch.Topic)
+ setString("mission", patch.Mission)
+ setString("target_audience", patch.TargetAudience)
+ setString("angle", patch.Angle)
+ setString("opening_type", patch.OpeningType)
+ setString("body_type", patch.BodyType)
+ setString("emotion", patch.Emotion)
+ setString("ending_type", patch.EndingType)
+ setString("cta_type", patch.CtaType)
+ setString("status", patch.Status)
+ if patch.RiskLevel != nil {
+ m["risk_level"] = normalizeRisk(*patch.RiskLevel)
+ }
+ if patch.RequiresHumanReview != nil {
+ m["requires_human_review"] = *patch.RequiresHumanReview
+ }
+ if patch.Avoid != nil {
+ m["avoid"] = patch.Avoid
+ }
+ if patch.SelectedKnowledge != nil {
+ m["selected_knowledge"] = patch.SelectedKnowledge
+ }
+ item, err := u.repo.UpdateContentPlan(ctx, tenantID, ownerUID, personaID, id, m)
+ if err != nil {
+ return nil, err
+ }
+ out := planSummary(*item)
+ return &out, nil
+}
+
+func (u *useCase) GetContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string) (*domusecase.ContentPlanSummary, error) {
+ item, err := u.repo.GetContentPlan(ctx, tenantID, ownerUID, personaID, id)
+ if err != nil {
+ return nil, err
+ }
+ out := planSummary(*item)
+ return &out, nil
+}
+
+func (u *useCase) ListContentPlans(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.ContentPlanSummary, error) {
+ items, err := u.repo.ListContentPlans(ctx, tenantID, ownerUID, personaID, limit)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]domusecase.ContentPlanSummary, 0, len(items))
+ for _, item := range items {
+ out = append(out, planSummary(item))
+ }
+ return out, nil
+}
+
+func (u *useCase) CreateFeedbackEvent(ctx context.Context, req domusecase.FeedbackEventInput) (*domusecase.FeedbackEventSummary, error) {
+ if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
+ return nil, err
+ }
+ decision := strings.TrimSpace(req.Decision)
+ if decision == "" {
+ return nil, app.For(code.Persona).InputMissingRequired("decision is required")
+ }
+ item := &entity.FeedbackEvent{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, ContentPlanID: strings.TrimSpace(req.ContentPlanID), DraftID: strings.TrimSpace(req.DraftID), Decision: decision, Note: strings.TrimSpace(req.Note), Snapshot: strings.TrimSpace(req.Snapshot), CreateAt: clock.NowUnixNano()}
+ if err := u.repo.CreateFeedbackEvent(ctx, item); err != nil {
+ return nil, err
+ }
+ out := feedbackSummary(*item)
+ return &out, nil
+}
+
+func (u *useCase) ListFeedbackEvents(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.FeedbackEventSummary, error) {
+ items, err := u.repo.ListFeedbackEvents(ctx, tenantID, ownerUID, personaID, limit)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]domusecase.FeedbackEventSummary, 0, len(items))
+ for _, item := range items {
+ out = append(out, feedbackSummary(item))
+ }
+ return out, nil
+}
+
+func (u *useCase) CreateKnowledgeSource(ctx context.Context, req domusecase.KnowledgeSourceInput) (*domusecase.KnowledgeSourceSummary, error) {
+ if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
+ return nil, err
+ }
+ raw, err := decodeSourceContent(req)
+ if err != nil {
+ return nil, app.For(code.Persona).InputInvalidFormat(err.Error())
+ }
+ chunksText := splitChunks(raw, 900)
+ now := clock.NowUnixNano()
+ sourceID := uuid.NewString()
+ item := &entity.KnowledgeSource{ID: sourceID, TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, SourceType: strings.TrimSpace(req.SourceType), Title: strings.TrimSpace(req.Title), Filename: strings.TrimSpace(req.Filename), RawContent: raw, ParsedStatus: "parsed", ChunkCount: len(chunksText), CreateAt: now}
+ chunks := make([]*entity.KnowledgeChunk, 0, len(chunksText))
+ for _, text := range chunksText {
+ chunks = append(chunks, &entity.KnowledgeChunk{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, SourceID: sourceID, Content: text, Topics: inferTopics(text), StyleTags: inferStyleTags(text), RiskLevel: inferRisk(text), CreateAt: now})
+ }
+ if err := u.repo.CreateKnowledgeSource(ctx, item, chunks); err != nil {
+ return nil, err
+ }
+ out := sourceSummary(*item)
+ return &out, nil
+}
+
+func (u *useCase) ListKnowledgeSources(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.KnowledgeSourceSummary, error) {
+ items, err := u.repo.ListKnowledgeSources(ctx, tenantID, ownerUID, personaID, limit)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]domusecase.KnowledgeSourceSummary, 0, len(items))
+ for _, item := range items {
+ out = append(out, sourceSummary(item))
+ }
+ return out, nil
+}
+
+func (u *useCase) ListKnowledgeChunks(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.KnowledgeChunkSummary, error) {
+ items, err := u.repo.ListKnowledgeChunks(ctx, tenantID, ownerUID, personaID, limit)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]domusecase.KnowledgeChunkSummary, 0, len(items))
+ for _, item := range items {
+ out = append(out, chunkSummary(item))
+ }
+ return out, nil
+}
+
+func (u *useCase) CreateFormulaPool(ctx context.Context, req domusecase.FormulaPoolInput) (*domusecase.FormulaPoolSummary, error) {
+ if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
+ return nil, err
+ }
+ if strings.TrimSpace(req.Type) == "" || strings.TrimSpace(req.Name) == "" {
+ return nil, app.For(code.Persona).InputMissingRequired("type and name are required")
+ }
+ now := clock.NowUnixNano()
+ weight := req.Weight
+ if weight <= 0 {
+ weight = 50
+ }
+ item := &entity.FormulaPool{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, Type: strings.TrimSpace(req.Type), Name: strings.TrimSpace(req.Name), Pattern: strings.TrimSpace(req.Pattern), UseCases: req.UseCases, Avoid: req.Avoid, Weight: weight, CreateAt: now, UpdateAt: now}
+ if err := u.repo.CreateFormulaPool(ctx, item); err != nil {
+ return nil, err
+ }
+ out := formulaSummary(*item)
+ return &out, nil
+}
+
+func (u *useCase) ListFormulaPools(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.FormulaPoolSummary, error) {
+ items, err := u.repo.ListFormulaPools(ctx, tenantID, ownerUID, personaID, limit)
+ if err != nil {
+ return nil, err
+ }
+ if len(items) == 0 {
+ _ = u.seedDefaultFormulas(ctx, tenantID, ownerUID, personaID)
+ items, _ = u.repo.ListFormulaPools(ctx, tenantID, ownerUID, personaID, limit)
+ }
+ out := make([]domusecase.FormulaPoolSummary, 0, len(items))
+ for _, item := range items {
+ out = append(out, formulaSummary(item))
+ }
+ return out, nil
+}
+
+func (u *useCase) seedDefaultFormulas(ctx context.Context, tenantID, ownerUID, personaID string) error {
+ defs := []domusecase.FormulaPoolInput{{Type: "opening", Name: "問題開場", Pattern: "用讀者正在問的具體問題開場", UseCases: []string{"互動文"}, Weight: 70}, {Type: "body", Name: "先共鳴再整理", Pattern: "先接住情緒,再整理 2-3 個可執行觀點", UseCases: []string{"共鳴文", "知識文"}, Weight: 75}, {Type: "emotion", Name: "被理解感", Pattern: "講出讀者不一定說出口的焦慮或委屈", UseCases: []string{"情緒共鳴文"}, Weight: 70}, {Type: "cta", Name: "經驗募集型", Pattern: "邀請讀者補充自己的經驗,不硬導購", UseCases: []string{"互動文"}, Weight: 65}}
+ for _, def := range defs {
+ def.TenantID = tenantID
+ def.OwnerUID = ownerUID
+ def.PersonaID = personaID
+ if _, err := u.CreateFormulaPool(ctx, def); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func decodeSourceContent(req domusecase.KnowledgeSourceInput) (string, error) {
+ if strings.TrimSpace(req.ContentBase64) != "" {
+ data, err := base64.StdEncoding.DecodeString(req.ContentBase64)
+ if err != nil {
+ return "", err
+ }
+ if strings.EqualFold(filepath.Ext(req.Filename), ".xlsx") {
+ return parseXLSX(data)
+ }
+ return string(data), nil
+ }
+ return strings.TrimSpace(req.Content), nil
+}
+
+type xlsxText struct {
+ T string `xml:",chardata"`
+}
+
+func parseXLSX(data []byte) (string, error) {
+ zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
+ if err != nil {
+ return "", err
+ }
+ var b strings.Builder
+ for _, f := range zr.File {
+ if !strings.HasPrefix(f.Name, "xl/sharedStrings") && !strings.HasPrefix(f.Name, "xl/worksheets") {
+ continue
+ }
+ rc, err := f.Open()
+ if err != nil {
+ continue
+ }
+ var texts []xlsxText
+ _ = xml.NewDecoder(rc).Decode(&texts)
+ _ = rc.Close()
+ for _, t := range texts {
+ if s := strings.TrimSpace(t.T); s != "" {
+ b.WriteString(s)
+ b.WriteString("\n")
+ }
+ }
+ }
+ text := strings.TrimSpace(b.String())
+ if text == "" {
+ return "", nil
+ }
+ return text, nil
+}
+
+func splitChunks(raw string, max int) []string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil
+ }
+ paras := strings.Split(raw, "\n")
+ var out []string
+ var b strings.Builder
+ for _, p := range paras {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ if b.Len()+len([]rune(p)) > max && b.Len() > 0 {
+ out = append(out, b.String())
+ b.Reset()
+ }
+ if b.Len() > 0 {
+ b.WriteString("\n")
+ }
+ b.WriteString(p)
+ }
+ if b.Len() > 0 {
+ out = append(out, b.String())
+ }
+ return out
+}
+
+var topicRE = regexp.MustCompile(`[A-Za-z0-9_\p{Han}]{2,12}`)
+
+func inferTopics(text string) []string {
+ seen := map[string]bool{}
+ var out []string
+ for _, m := range topicRE.FindAllString(text, 20) {
+ if !seen[m] && len(out) < 8 {
+ seen[m] = true
+ out = append(out, m)
+ }
+ }
+ return out
+}
+func inferStyleTags(text string) []string {
+ var out []string
+ if strings.Contains(text, "我") {
+ out = append(out, "第一人稱")
+ }
+ if strings.Contains(text, "?") || strings.Contains(text, "?") {
+ out = append(out, "提問")
+ }
+ if strings.Contains(text, "。") {
+ out = append(out, "自然標點")
+ }
+ return out
+}
+func inferRisk(text string) string {
+ lower := strings.ToLower(text)
+ for _, k := range []string{"醫", "藥", "amh", "投資", "法律"} {
+ if strings.Contains(lower, k) {
+ return "medium"
+ }
+ }
+ return "low"
+}
+
+func normalizeRisk(raw string) string {
+ raw = strings.ToLower(strings.TrimSpace(raw))
+ if raw == "high" || raw == "low" {
+ return raw
+ }
+ return "medium"
+}
+func topicSummary(item entity.TopicCandidate) domusecase.TopicCandidateSummary {
+ return domusecase.TopicCandidateSummary{ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Source: item.Source, Category: item.Category, SeedQuery: item.SeedQuery, TrendReason: item.TrendReason, TrendKeywords: item.TrendKeywords, HeatScore: item.HeatScore, FitScore: item.FitScore, InteractionScore: item.InteractionScore, ExtendScore: item.ExtendScore, RiskScore: item.RiskScore, FinalScore: item.FinalScore, RecommendedMissions: item.RecommendedMissions, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt}
+}
+func planSummary(item entity.ContentPlan) domusecase.ContentPlanSummary {
+ return domusecase.ContentPlanSummary{ID: item.ID, PersonaID: item.PersonaID, TopicCandidateID: item.TopicCandidateID, Topic: item.Topic, Mission: item.Mission, TargetAudience: item.TargetAudience, Angle: item.Angle, OpeningType: item.OpeningType, BodyType: item.BodyType, Emotion: item.Emotion, EndingType: item.EndingType, CtaType: item.CtaType, RiskLevel: item.RiskLevel, RequiresHumanReview: item.RequiresHumanReview, Avoid: item.Avoid, SelectedKnowledge: item.SelectedKnowledge, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt}
+}
+func feedbackSummary(item entity.FeedbackEvent) domusecase.FeedbackEventSummary {
+ return domusecase.FeedbackEventSummary{ID: item.ID, PersonaID: item.PersonaID, ContentPlanID: item.ContentPlanID, DraftID: item.DraftID, Decision: item.Decision, Note: item.Note, Snapshot: item.Snapshot, CreateAt: item.CreateAt}
+}
+func sourceSummary(item entity.KnowledgeSource) domusecase.KnowledgeSourceSummary {
+ return domusecase.KnowledgeSourceSummary{ID: item.ID, PersonaID: item.PersonaID, SourceType: item.SourceType, Title: item.Title, Filename: item.Filename, ParsedStatus: item.ParsedStatus, ChunkCount: item.ChunkCount, CreateAt: item.CreateAt}
+}
+func chunkSummary(item entity.KnowledgeChunk) domusecase.KnowledgeChunkSummary {
+ return domusecase.KnowledgeChunkSummary{ID: item.ID, PersonaID: item.PersonaID, SourceID: item.SourceID, Content: item.Content, Topics: item.Topics, StyleTags: item.StyleTags, RiskLevel: item.RiskLevel, CreateAt: item.CreateAt}
+}
+func formulaSummary(item entity.FormulaPool) domusecase.FormulaPoolSummary {
+ return domusecase.FormulaPoolSummary{ID: item.ID, PersonaID: item.PersonaID, Type: item.Type, Name: item.Name, Pattern: item.Pattern, UseCases: item.UseCases, Avoid: item.Avoid, Weight: item.Weight, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt}
+}
diff --git a/backend/internal/model/copy_draft/domain/entity/draft.go b/backend/internal/model/copy_draft/domain/entity/draft.go
index 75e6eb8..2fb9a47 100644
--- a/backend/internal/model/copy_draft/domain/entity/draft.go
+++ b/backend/internal/model/copy_draft/domain/entity/draft.go
@@ -6,27 +6,40 @@ const (
DraftTypeViralReplica = "viral-replica"
DraftTypeReplicate = "replicate"
DraftTypeMatrix = "matrix"
+ DraftTypeFormula = "formula"
)
type CopyDraft struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- PersonaID string `bson:"persona_id"`
- CopyMissionID string `bson:"copy_mission_id,omitempty"`
- ScanPostID string `bson:"scan_post_id,omitempty"`
- DraftType string `bson:"draft_type"`
- MatrixBatchID string `bson:"matrix_batch_id,omitempty"`
- SortOrder int `bson:"sort_order,omitempty"`
- Text string `bson:"text"`
- Angle string `bson:"angle,omitempty"`
- Hook string `bson:"hook,omitempty"`
- Rationale string `bson:"rationale,omitempty"`
- ReferenceNotes string `bson:"reference_notes,omitempty"`
- Sources []string `bson:"sources,omitempty"`
- Status string `bson:"status,omitempty"`
- PublishedMediaID string `bson:"published_media_id,omitempty"`
- PublishedPermalink string `bson:"published_permalink,omitempty"`
- PublishedAt int64 `bson:"published_at,omitempty"`
- CreateAt int64 `bson:"create_at"`
+ ID string `bson:"_id"`
+ TenantID string `bson:"tenant_id"`
+ OwnerUID string `bson:"owner_uid"`
+ PersonaID string `bson:"persona_id"`
+ ContentPlanID string `bson:"content_plan_id,omitempty"`
+ CopyMissionID string `bson:"copy_mission_id,omitempty"`
+ ScanPostID string `bson:"scan_post_id,omitempty"`
+ FormulaID string `bson:"formula_id,omitempty"`
+ DraftType string `bson:"draft_type"`
+ MatrixBatchID string `bson:"matrix_batch_id,omitempty"`
+ SortOrder int `bson:"sort_order,omitempty"`
+ Text string `bson:"text"`
+ TopicTag string `bson:"topic_tag,omitempty"`
+ Angle string `bson:"angle,omitempty"`
+ Hook string `bson:"hook,omitempty"`
+ Rationale string `bson:"rationale,omitempty"`
+ ReferenceNotes string `bson:"reference_notes,omitempty"`
+ Sources []string `bson:"sources,omitempty"`
+ AiScore int `bson:"ai_score,omitempty"`
+ FormulaScore int `bson:"formula_score,omitempty"`
+ BrandFitScore int `bson:"brand_fit_score,omitempty"`
+ RiskScore int `bson:"risk_score,omitempty"`
+ SimilarityScore int `bson:"similarity_score,omitempty"`
+ EngagementPotential int `bson:"engagement_potential,omitempty"`
+ FreshnessScore int `bson:"freshness_score,omitempty"`
+ ReviewSuggestion string `bson:"review_suggestion,omitempty"`
+ Status string `bson:"status,omitempty"`
+ PublishQueueID string `bson:"publish_queue_id,omitempty"`
+ PublishedMediaID string `bson:"published_media_id,omitempty"`
+ PublishedPermalink string `bson:"published_permalink,omitempty"`
+ PublishedAt int64 `bson:"published_at,omitempty"`
+ CreateAt int64 `bson:"create_at"`
}
diff --git a/backend/internal/model/copy_draft/domain/repository/repository.go b/backend/internal/model/copy_draft/domain/repository/repository.go
index 2dc27af..61d35ce 100644
--- a/backend/internal/model/copy_draft/domain/repository/repository.go
+++ b/backend/internal/model/copy_draft/domain/repository/repository.go
@@ -6,6 +6,22 @@ import (
"haixun-backend/internal/model/copy_draft/domain/entity"
)
+type InboxFilter struct {
+ TenantID string
+ OwnerUID string
+ PersonaID string
+ Status string
+ RangeStart int64
+ RangeEnd int64
+ Page int
+ PageSize int
+}
+
+type InboxListResult struct {
+ Items []entity.CopyDraft
+ Total int64
+}
+
type Repository interface {
EnsureIndexes(ctx context.Context) error
Create(ctx context.Context, draft *entity.CopyDraft) error
@@ -17,10 +33,13 @@ type Repository interface {
DeleteByMissionAndType(ctx context.Context, tenantID, ownerUID, personaID, missionID, draftType string) error
DeleteByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error
List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.CopyDraft, error)
+ ListInbox(ctx context.Context, filter InboxFilter) (*InboxListResult, error)
ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]entity.CopyDraft, error)
ReplaceMissionMatrix(
ctx context.Context,
tenantID, ownerUID, personaID, missionID string,
drafts []*entity.CopyDraft,
) error
+ // PruneOldestPendingBeyond deletes oldest idle pending drafts (no mission / queue / publish) beyond keep.
+ PruneOldestPendingBeyond(ctx context.Context, tenantID, ownerUID, personaID string, keep int) (int64, error)
}
diff --git a/backend/internal/model/copy_draft/domain/usecase/usecase.go b/backend/internal/model/copy_draft/domain/usecase/usecase.go
index 386601f..ac717cd 100644
--- a/backend/internal/model/copy_draft/domain/usecase/usecase.go
+++ b/backend/internal/model/copy_draft/domain/usecase/usecase.go
@@ -5,23 +5,35 @@ import (
)
type CopyDraftSummary struct {
- ID string
- PersonaID string
- CopyMissionID string
- ScanPostID string
- DraftType string
- SortOrder int
- Text string
- Angle string
- Hook string
- Rationale string
- ReferenceNotes string
- Sources []string
- Status string
- PublishedMediaID string
- PublishedPermalink string
- PublishedAt int64
- CreateAt int64
+ ID string
+ PersonaID string
+ ContentPlanID string
+ CopyMissionID string
+ ScanPostID string
+ FormulaID string
+ DraftType string
+ SortOrder int
+ Text string
+ TopicTag string
+ Angle string
+ Hook string
+ Rationale string
+ ReferenceNotes string
+ Sources []string
+ AiScore int
+ FormulaScore int
+ BrandFitScore int
+ RiskScore int
+ SimilarityScore int
+ EngagementPotential int
+ FreshnessScore int
+ ReviewSuggestion string
+ Status string
+ PublishQueueID string
+ PublishedMediaID string
+ PublishedPermalink string
+ PublishedAt int64
+ CreateAt int64
}
type MarkPublishedRequest struct {
@@ -33,20 +45,39 @@ type MarkPublishedRequest struct {
Permalink string
}
+type MarkScheduledRequest struct {
+ TenantID string
+ OwnerUID string
+ PersonaID string
+ DraftID string
+ QueueID string
+}
+
type CreateRequest struct {
- TenantID string
- OwnerUID string
- PersonaID string
- CopyMissionID string
- ScanPostID string
- DraftType string
- SortOrder int
- Text string
- Angle string
- Hook string
- Rationale string
- ReferenceNotes string
- Sources []string
+ TenantID string
+ OwnerUID string
+ PersonaID string
+ ContentPlanID string
+ CopyMissionID string
+ ScanPostID string
+ FormulaID string
+ DraftType string
+ SortOrder int
+ Text string
+ TopicTag string
+ Angle string
+ Hook string
+ Rationale string
+ ReferenceNotes string
+ Sources []string
+ AiScore int
+ FormulaScore int
+ BrandFitScore int
+ RiskScore int
+ SimilarityScore int
+ EngagementPotential int
+ FreshnessScore int
+ ReviewSuggestion string
}
type CreateManyRequest struct {
@@ -57,10 +88,12 @@ type CreateManyRequest struct {
}
type CopyDraftPatch struct {
- Text *string
- Hook *string
- Angle *string
- Status *string
+ Text *string
+ TopicTag *string
+ Hook *string
+ Angle *string
+ Rationale *string
+ Status *string
}
type UpdateRequest struct {
@@ -71,6 +104,22 @@ type UpdateRequest struct {
Patch CopyDraftPatch
}
+type InboxListRequest struct {
+ TenantID string
+ OwnerUID string
+ PersonaID string
+ Status string
+ RangeStart int64
+ RangeEnd int64
+ Page int
+ PageSize int
+}
+
+type InboxListResult struct {
+ Items []CopyDraftSummary
+ Total int64
+}
+
type DeleteMissionMatrixDraftsRequest struct {
TenantID string
OwnerUID string
@@ -86,7 +135,10 @@ type UseCase interface {
Get(ctx context.Context, tenantID, ownerUID, personaID, draftID string) (*CopyDraftSummary, error)
Update(ctx context.Context, req UpdateRequest) (*CopyDraftSummary, error)
MarkPublished(ctx context.Context, req MarkPublishedRequest) (*CopyDraftSummary, error)
+ MarkScheduled(ctx context.Context, req MarkScheduledRequest) (*CopyDraftSummary, error)
List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]CopyDraftSummary, error)
+ ListInbox(ctx context.Context, req InboxListRequest) (*InboxListResult, error)
+ PruneIdleDrafts(ctx context.Context, tenantID, ownerUID, personaID string, keep int) (int64, error)
ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]CopyDraftSummary, error)
Delete(ctx context.Context, tenantID, ownerUID, personaID, draftID string) error
DeleteMissionMatrixDrafts(ctx context.Context, req DeleteMissionMatrixDraftsRequest) (int, error)
diff --git a/backend/internal/model/copy_draft/repository/mongo.go b/backend/internal/model/copy_draft/repository/mongo.go
index 24929e5..aa15a4d 100644
--- a/backend/internal/model/copy_draft/repository/mongo.go
+++ b/backend/internal/model/copy_draft/repository/mongo.go
@@ -39,6 +39,15 @@ func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
{Key: "create_at", Value: -1},
},
},
+ {
+ Keys: bson.D{
+ {Key: "tenant_id", Value: 1},
+ {Key: "owner_uid", Value: 1},
+ {Key: "persona_id", Value: 1},
+ {Key: "content_plan_id", Value: 1},
+ {Key: "create_at", Value: -1},
+ },
+ },
{
Keys: bson.D{
{Key: "tenant_id", Value: 1},
@@ -92,6 +101,97 @@ func (r *mongoRepository) Create(ctx context.Context, draft *entity.CopyDraft) e
return err
}
+func inboxStatusFilter(status string) bson.M {
+ switch strings.TrimSpace(status) {
+ case "draft":
+ return bson.M{
+ "$and": []bson.M{
+ {"$or": []bson.M{{"publish_queue_id": ""}, {"publish_queue_id": bson.M{"$exists": false}}}},
+ {"$or": []bson.M{{"published_at": 0}, {"published_at": bson.M{"$exists": false}}}},
+ },
+ }
+ case "scheduled":
+ return bson.M{
+ "publish_queue_id": bson.M{"$ne": ""},
+ "$or": []bson.M{{"published_at": 0}, {"published_at": bson.M{"$exists": false}}},
+ }
+ case "published":
+ return bson.M{"published_at": bson.M{"$gt": 0}}
+ default:
+ return bson.M{}
+ }
+}
+
+func normalizeInboxPage(page, pageSize int) (int, int) {
+ if page <= 0 {
+ page = 1
+ }
+ if pageSize <= 0 {
+ pageSize = 12
+ }
+ if pageSize > 100 {
+ pageSize = 100
+ }
+ return page, pageSize
+}
+
+func (r *mongoRepository) ListInbox(ctx context.Context, filter domrepo.InboxFilter) (*domrepo.InboxListResult, error) {
+ if r.collection == nil {
+ return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ page, pageSize := normalizeInboxPage(filter.Page, filter.PageSize)
+ mongoFilter := personaFilter(filter.TenantID, filter.OwnerUID, filter.PersonaID)
+ if extra := inboxStatusFilter(filter.Status); len(extra) > 0 {
+ for k, v := range extra {
+ mongoFilter[k] = v
+ }
+ }
+ if filter.RangeStart > 0 || filter.RangeEnd > 0 {
+ rangeClauses := make([]bson.M, 0, 3)
+ createRange := bson.M{}
+ if filter.RangeStart > 0 {
+ createRange["$gte"] = filter.RangeStart
+ }
+ if filter.RangeEnd > 0 {
+ createRange["$lte"] = filter.RangeEnd
+ }
+ if len(createRange) > 0 {
+ rangeClauses = append(rangeClauses, bson.M{"create_at": createRange})
+ }
+ publishedRange := bson.M{}
+ if filter.RangeStart > 0 {
+ publishedRange["$gte"] = filter.RangeStart
+ }
+ if filter.RangeEnd > 0 {
+ publishedRange["$lte"] = filter.RangeEnd
+ }
+ if len(publishedRange) > 0 {
+ rangeClauses = append(rangeClauses, bson.M{"published_at": publishedRange})
+ }
+ if len(rangeClauses) > 0 {
+ mongoFilter["$or"] = rangeClauses
+ }
+ }
+ total, err := r.collection.CountDocuments(ctx, mongoFilter)
+ if err != nil {
+ return nil, err
+ }
+ opts := options.Find().
+ SetSort(bson.D{{Key: "create_at", Value: -1}}).
+ SetSkip(int64((page - 1) * pageSize)).
+ SetLimit(int64(pageSize))
+ cur, err := r.collection.Find(ctx, mongoFilter, opts)
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ var out []entity.CopyDraft
+ if err := cur.All(ctx, &out); err != nil {
+ return nil, err
+ }
+ return &domrepo.InboxListResult{Items: out, Total: total}, nil
+}
+
func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.CopyDraft, error) {
if r.collection == nil {
return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
@@ -338,6 +438,68 @@ func (r *mongoRepository) replaceMissionMatrixInsertFirst(
return err
}
+func pendingIdleDraftFilter(tenantID, ownerUID, personaID string) bson.M {
+ filter := personaFilter(tenantID, ownerUID, personaID)
+ filter["$and"] = []bson.M{
+ {"$or": []bson.M{{"publish_queue_id": ""}, {"publish_queue_id": bson.M{"$exists": false}}}},
+ {"$or": []bson.M{{"published_at": 0}, {"published_at": bson.M{"$exists": false}}}},
+ {"$or": []bson.M{{"copy_mission_id": ""}, {"copy_mission_id": bson.M{"$exists": false}}}},
+ }
+ return filter
+}
+
+func (r *mongoRepository) PruneOldestPendingBeyond(ctx context.Context, tenantID, ownerUID, personaID string, keep int) (int64, error) {
+ if r.collection == nil {
+ return 0, app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ if keep <= 0 {
+ keep = 40
+ }
+ filter := pendingIdleDraftFilter(tenantID, ownerUID, personaID)
+ total, err := r.collection.CountDocuments(ctx, filter)
+ if err != nil {
+ return 0, err
+ }
+ excess := total - int64(keep)
+ if excess <= 0 {
+ return 0, nil
+ }
+ opts := options.Find().
+ SetSort(bson.D{{Key: "create_at", Value: 1}}).
+ SetLimit(excess).
+ SetProjection(bson.M{"_id": 1})
+ cur, err := r.collection.Find(ctx, filter, opts)
+ if err != nil {
+ return 0, err
+ }
+ defer cur.Close(ctx)
+ ids := make([]string, 0, excess)
+ for cur.Next(ctx) {
+ var row struct {
+ ID string `bson:"_id"`
+ }
+ if err := cur.Decode(&row); err != nil {
+ return 0, err
+ }
+ if row.ID != "" {
+ ids = append(ids, row.ID)
+ }
+ }
+ if err := cur.Err(); err != nil {
+ return 0, err
+ }
+ if len(ids) == 0 {
+ return 0, nil
+ }
+ deleteFilter := personaFilter(tenantID, ownerUID, personaID)
+ deleteFilter["_id"] = bson.M{"$in": ids}
+ res, err := r.collection.DeleteMany(ctx, deleteFilter)
+ if err != nil {
+ return 0, err
+ }
+ return res.DeletedCount, nil
+}
+
func (r *mongoRepository) ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]entity.CopyDraft, error) {
if r.collection == nil {
return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
diff --git a/backend/internal/model/copy_draft/usecase/usecase.go b/backend/internal/model/copy_draft/usecase/usecase.go
index 8a65d96..e757cef 100644
--- a/backend/internal/model/copy_draft/usecase/usecase.go
+++ b/backend/internal/model/copy_draft/usecase/usecase.go
@@ -8,14 +8,17 @@ import (
libcopy "haixun-backend/internal/library/copymission"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/library/threadspost"
"haixun-backend/internal/model/copy_draft/domain/entity"
domrepo "haixun-backend/internal/model/copy_draft/domain/repository"
domusecase "haixun-backend/internal/model/copy_draft/domain/usecase"
- goredis "github.com/redis/go-redis/v9"
"github.com/google/uuid"
+ goredis "github.com/redis/go-redis/v9"
)
+const defaultIdleDraftKeep = 40
+
type copyDraftUseCase struct {
repo domrepo.Repository
redis *goredis.Client
@@ -29,7 +32,7 @@ func (u *copyDraftUseCase) Create(ctx context.Context, req domusecase.CreateRequ
if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
return nil, err
}
- text := strings.TrimSpace(req.Text)
+ text := threadspost.FormatDraftText(req.Text)
if text == "" {
return nil, app.For(code.Persona).InputMissingRequired("draft text is required")
}
@@ -38,26 +41,38 @@ func (u *copyDraftUseCase) Create(ctx context.Context, req domusecase.CreateRequ
draftType = entity.DraftTypeViralReplica
}
item := &entity.CopyDraft{
- ID: uuid.NewString(),
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- PersonaID: req.PersonaID,
- CopyMissionID: strings.TrimSpace(req.CopyMissionID),
- ScanPostID: strings.TrimSpace(req.ScanPostID),
- DraftType: draftType,
- SortOrder: req.SortOrder,
- Text: text,
- Angle: strings.TrimSpace(req.Angle),
- Hook: strings.TrimSpace(req.Hook),
- Rationale: strings.TrimSpace(req.Rationale),
- ReferenceNotes: strings.TrimSpace(req.ReferenceNotes),
- Sources: req.Sources,
- Status: "pending",
- CreateAt: clock.NowUnixNano(),
+ ID: uuid.NewString(),
+ TenantID: req.TenantID,
+ OwnerUID: req.OwnerUID,
+ PersonaID: req.PersonaID,
+ ContentPlanID: strings.TrimSpace(req.ContentPlanID),
+ CopyMissionID: strings.TrimSpace(req.CopyMissionID),
+ ScanPostID: strings.TrimSpace(req.ScanPostID),
+ FormulaID: strings.TrimSpace(req.FormulaID),
+ DraftType: draftType,
+ SortOrder: req.SortOrder,
+ Text: text,
+ TopicTag: normalizeTopicTag(req.TopicTag),
+ Angle: strings.TrimSpace(req.Angle),
+ Hook: strings.TrimSpace(req.Hook),
+ Rationale: strings.TrimSpace(req.Rationale),
+ ReferenceNotes: strings.TrimSpace(req.ReferenceNotes),
+ Sources: req.Sources,
+ AiScore: clampScore(req.AiScore),
+ FormulaScore: clampScore(req.FormulaScore),
+ BrandFitScore: clampScore(req.BrandFitScore),
+ RiskScore: clampScore(req.RiskScore),
+ SimilarityScore: clampScore(req.SimilarityScore),
+ EngagementPotential: clampScore(req.EngagementPotential),
+ FreshnessScore: clampScore(req.FreshnessScore),
+ ReviewSuggestion: strings.TrimSpace(req.ReviewSuggestion),
+ Status: "pending",
+ CreateAt: clock.NowUnixNano(),
}
if err := u.repo.Create(ctx, item); err != nil {
return nil, err
}
+ u.pruneIdleDraftsBestEffort(ctx, req.TenantID, req.OwnerUID, req.PersonaID)
summary := toSummary(*item)
return &summary, nil
}
@@ -71,7 +86,7 @@ func (u *copyDraftUseCase) CreateMany(ctx context.Context, req domusecase.Create
}
items := make([]*entity.CopyDraft, 0, len(req.Drafts))
for idx, draftReq := range req.Drafts {
- text := strings.TrimSpace(draftReq.Text)
+ text := threadspost.FormatDraftText(draftReq.Text)
if text == "" {
continue
}
@@ -84,22 +99,32 @@ func (u *copyDraftUseCase) CreateMany(ctx context.Context, req domusecase.Create
sortOrder = idx + 1
}
items = append(items, &entity.CopyDraft{
- ID: uuid.NewString(),
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- PersonaID: req.PersonaID,
- CopyMissionID: strings.TrimSpace(draftReq.CopyMissionID),
- ScanPostID: strings.TrimSpace(draftReq.ScanPostID),
- DraftType: draftType,
- SortOrder: sortOrder,
- Text: text,
- Angle: strings.TrimSpace(draftReq.Angle),
- Hook: strings.TrimSpace(draftReq.Hook),
- Rationale: strings.TrimSpace(draftReq.Rationale),
- ReferenceNotes: strings.TrimSpace(draftReq.ReferenceNotes),
- Sources: draftReq.Sources,
- Status: "pending",
- CreateAt: clock.NowUnixNano(),
+ ID: uuid.NewString(),
+ TenantID: req.TenantID,
+ OwnerUID: req.OwnerUID,
+ PersonaID: req.PersonaID,
+ ContentPlanID: strings.TrimSpace(draftReq.ContentPlanID),
+ CopyMissionID: strings.TrimSpace(draftReq.CopyMissionID),
+ ScanPostID: strings.TrimSpace(draftReq.ScanPostID),
+ DraftType: draftType,
+ SortOrder: sortOrder,
+ Text: text,
+ TopicTag: normalizeTopicTag(draftReq.TopicTag),
+ Angle: strings.TrimSpace(draftReq.Angle),
+ Hook: strings.TrimSpace(draftReq.Hook),
+ Rationale: strings.TrimSpace(draftReq.Rationale),
+ ReferenceNotes: strings.TrimSpace(draftReq.ReferenceNotes),
+ Sources: draftReq.Sources,
+ AiScore: clampScore(draftReq.AiScore),
+ FormulaScore: clampScore(draftReq.FormulaScore),
+ BrandFitScore: clampScore(draftReq.BrandFitScore),
+ RiskScore: clampScore(draftReq.RiskScore),
+ SimilarityScore: clampScore(draftReq.SimilarityScore),
+ EngagementPotential: clampScore(draftReq.EngagementPotential),
+ FreshnessScore: clampScore(draftReq.FreshnessScore),
+ ReviewSuggestion: strings.TrimSpace(draftReq.ReviewSuggestion),
+ Status: "pending",
+ CreateAt: clock.NowUnixNano(),
})
}
if len(items) == 0 {
@@ -108,6 +133,7 @@ func (u *copyDraftUseCase) CreateMany(ctx context.Context, req domusecase.Create
if err := u.repo.CreateMany(ctx, items); err != nil {
return nil, err
}
+ u.pruneIdleDraftsBestEffort(ctx, req.TenantID, req.OwnerUID, req.PersonaID)
out := make([]domusecase.CopyDraftSummary, 0, len(items))
for _, item := range items {
out = append(out, toSummary(*item))
@@ -143,16 +169,22 @@ func (u *copyDraftUseCase) Update(ctx context.Context, req domusecase.UpdateRequ
}
patch["text"] = text
}
+ if req.Patch.TopicTag != nil {
+ patch["topic_tag"] = normalizeTopicTag(*req.Patch.TopicTag)
+ }
if req.Patch.Hook != nil {
patch["hook"] = strings.TrimSpace(*req.Patch.Hook)
}
if req.Patch.Angle != nil {
patch["angle"] = strings.TrimSpace(*req.Patch.Angle)
}
+ if req.Patch.Rationale != nil {
+ patch["rationale"] = strings.TrimSpace(*req.Patch.Rationale)
+ }
if req.Patch.Status != nil {
status := strings.TrimSpace(*req.Patch.Status)
- if status != "" && status != "pending" && status != "ready" {
- return nil, app.For(code.Persona).InputMissingRequired("status must be pending or ready")
+ if status != "" && status != "pending" && status != "ready" && status != "rejected" && status != "scheduled" {
+ return nil, app.For(code.Persona).InputMissingRequired("status must be pending, ready, rejected or scheduled")
}
if status != "" {
patch["status"] = status
@@ -181,6 +213,7 @@ func (u *copyDraftUseCase) MarkPublished(ctx context.Context, req domusecase.Mar
now := clock.NowUnixNano()
patch := map[string]interface{}{
"status": "published",
+ "publish_queue_id": "",
"published_media_id": mediaID,
"published_permalink": strings.TrimSpace(req.Permalink),
"published_at": now,
@@ -193,6 +226,26 @@ func (u *copyDraftUseCase) MarkPublished(ctx context.Context, req domusecase.Mar
return &summary, nil
}
+func (u *copyDraftUseCase) MarkScheduled(ctx context.Context, req domusecase.MarkScheduledRequest) (*domusecase.CopyDraftSummary, error) {
+ if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
+ return nil, err
+ }
+ draftID := strings.TrimSpace(req.DraftID)
+ queueID := strings.TrimSpace(req.QueueID)
+ if draftID == "" || queueID == "" {
+ return nil, app.For(code.Persona).InputMissingRequired("draft_id and queue_id are required")
+ }
+ item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, draftID, map[string]interface{}{
+ "status": "scheduled",
+ "publish_queue_id": queueID,
+ })
+ if err != nil {
+ return nil, err
+ }
+ summary := toSummary(*item)
+ return &summary, nil
+}
+
func (u *copyDraftUseCase) ReplaceMissionMatrix(
ctx context.Context,
tenantID, ownerUID, personaID, missionID string,
@@ -237,7 +290,7 @@ func buildMatrixDraftEntities(
now := clock.NowUnixNano()
items := make([]*entity.CopyDraft, 0, len(drafts))
for idx, draftReq := range drafts {
- text := strings.TrimSpace(draftReq.Text)
+ text := threadspost.FormatDraftText(draftReq.Text)
if text == "" {
continue
}
@@ -256,6 +309,7 @@ func buildMatrixDraftEntities(
MatrixBatchID: batchID,
SortOrder: sortOrder,
Text: text,
+ TopicTag: normalizeTopicTag(draftReq.TopicTag),
Angle: strings.TrimSpace(draftReq.Angle),
Hook: strings.TrimSpace(draftReq.Hook),
Rationale: strings.TrimSpace(draftReq.Rationale),
@@ -363,6 +417,47 @@ func (u *copyDraftUseCase) List(ctx context.Context, tenantID, ownerUID, persona
return out, nil
}
+func (u *copyDraftUseCase) PruneIdleDrafts(ctx context.Context, tenantID, ownerUID, personaID string, keep int) (int64, error) {
+ if err := requireActor(tenantID, ownerUID, personaID); err != nil {
+ return 0, err
+ }
+ if keep <= 0 {
+ keep = defaultIdleDraftKeep
+ }
+ if keep > 200 {
+ keep = 200
+ }
+ return u.repo.PruneOldestPendingBeyond(ctx, tenantID, ownerUID, personaID, keep)
+}
+
+func (u *copyDraftUseCase) pruneIdleDraftsBestEffort(ctx context.Context, tenantID, ownerUID, personaID string) {
+ _, _ = u.repo.PruneOldestPendingBeyond(ctx, tenantID, ownerUID, personaID, defaultIdleDraftKeep)
+}
+
+func (u *copyDraftUseCase) ListInbox(ctx context.Context, req domusecase.InboxListRequest) (*domusecase.InboxListResult, error) {
+ if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
+ return nil, err
+ }
+ result, err := u.repo.ListInbox(ctx, domrepo.InboxFilter{
+ TenantID: req.TenantID,
+ OwnerUID: req.OwnerUID,
+ PersonaID: req.PersonaID,
+ Status: req.Status,
+ RangeStart: req.RangeStart,
+ RangeEnd: req.RangeEnd,
+ Page: req.Page,
+ PageSize: req.PageSize,
+ })
+ if err != nil {
+ return nil, err
+ }
+ out := make([]domusecase.CopyDraftSummary, 0, len(result.Items))
+ for _, item := range result.Items {
+ out = append(out, toSummary(item))
+ }
+ return &domusecase.InboxListResult{Items: out, Total: result.Total}, nil
+}
+
func requireActor(tenantID, ownerUID, personaID string) error {
if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
return app.For(code.Auth).AuthUnauthorized("missing actor")
@@ -373,24 +468,57 @@ func requireActor(tenantID, ownerUID, personaID string) error {
return nil
}
+func clampScore(score int) int {
+ if score < 0 {
+ return 0
+ }
+ if score > 100 {
+ return 100
+ }
+ return score
+}
+
func toSummary(item entity.CopyDraft) domusecase.CopyDraftSummary {
return domusecase.CopyDraftSummary{
- ID: item.ID,
- PersonaID: item.PersonaID,
- CopyMissionID: item.CopyMissionID,
- ScanPostID: item.ScanPostID,
- DraftType: item.DraftType,
- SortOrder: item.SortOrder,
- Text: item.Text,
- Angle: item.Angle,
- Hook: item.Hook,
- Rationale: item.Rationale,
- ReferenceNotes: item.ReferenceNotes,
- Sources: item.Sources,
- Status: item.Status,
- PublishedMediaID: item.PublishedMediaID,
- PublishedPermalink: item.PublishedPermalink,
- PublishedAt: item.PublishedAt,
- CreateAt: item.CreateAt,
+ ID: item.ID,
+ PersonaID: item.PersonaID,
+ ContentPlanID: item.ContentPlanID,
+ CopyMissionID: item.CopyMissionID,
+ ScanPostID: item.ScanPostID,
+ FormulaID: item.FormulaID,
+ DraftType: item.DraftType,
+ SortOrder: item.SortOrder,
+ Text: item.Text,
+ TopicTag: item.TopicTag,
+ Angle: item.Angle,
+ Hook: item.Hook,
+ Rationale: item.Rationale,
+ ReferenceNotes: item.ReferenceNotes,
+ Sources: item.Sources,
+ AiScore: item.AiScore,
+ FormulaScore: item.FormulaScore,
+ BrandFitScore: item.BrandFitScore,
+ RiskScore: item.RiskScore,
+ SimilarityScore: item.SimilarityScore,
+ EngagementPotential: item.EngagementPotential,
+ FreshnessScore: item.FreshnessScore,
+ ReviewSuggestion: item.ReviewSuggestion,
+ Status: item.Status,
+ PublishQueueID: item.PublishQueueID,
+ PublishedMediaID: item.PublishedMediaID,
+ PublishedPermalink: item.PublishedPermalink,
+ PublishedAt: item.PublishedAt,
+ CreateAt: item.CreateAt,
}
}
+
+func normalizeTopicTag(value string) string {
+ value = strings.TrimSpace(value)
+ value = strings.TrimPrefix(value, "#")
+ value = strings.TrimSpace(value)
+ if len([]rune(value)) > 40 {
+ runes := []rune(value)
+ value = string(runes[:40])
+ }
+ return value
+}
diff --git a/backend/internal/model/job/domain/usecase/job.go b/backend/internal/model/job/domain/usecase/job.go
index 5d1113c..ccb3b88 100644
--- a/backend/internal/model/job/domain/usecase/job.go
+++ b/backend/internal/model/job/domain/usecase/job.go
@@ -14,6 +14,7 @@ type CreateRunRequest struct {
TenantID string
OwnerUID string
Payload map[string]any
+ ScheduledAt *int64
}
type CancelRunRequest struct {
@@ -99,8 +100,13 @@ type UseCase interface {
EnsureExpandCopyMissionGraphTemplate(ctx context.Context) error
EnsureGenerateCopyMatrixTemplate(ctx context.Context) error
EnsureGenerateCopyDraftTemplate(ctx context.Context) error
+ EnsureGenerateOutreachDraftTemplate(ctx context.Context) error
+ EnsureGenerateTopicMatrixTemplate(ctx context.Context) error
+ EnsureGenerateFormulaDraftTemplate(ctx context.Context) error
+ EnsureGenerateRewriteDraftTemplate(ctx context.Context) error
EnsureRefreshThreadsTokenTemplate(ctx context.Context) error
EnsurePublishAnalyticsTemplate(ctx context.Context) error
+ EnsureRefillPublishInventoryTemplate(ctx context.Context) error
CreateRun(ctx context.Context, req CreateRunRequest) (*entity.Run, error)
GetRun(ctx context.Context, jobID string) (*entity.Run, error)
@@ -130,9 +136,9 @@ type UseCase interface {
}
type MaintenanceResult struct {
- EnqueuedPending int
- ReapedCancelGrace int
- ReapedExpiredLocks int
- RepairedStuckClaims int
- ReapedOrphanedLocks int
+ EnqueuedPending int
+ ReapedCancelGrace int
+ ReapedExpiredLocks int
+ RepairedStuckClaims int
+ ReapedOrphanedLocks int
}
diff --git a/backend/internal/model/job/usecase/cancel_test.go b/backend/internal/model/job/usecase/cancel_test.go
index 36faa96..ed54696 100644
--- a/backend/internal/model/job/usecase/cancel_test.go
+++ b/backend/internal/model/job/usecase/cancel_test.go
@@ -56,3 +56,53 @@ func TestRequestCancel_RunningSetsRedisCancelSignal(t *testing.T) {
}
_ = client.Del(ctx, "jobs:cancel:"+jobID)
}
+
+// A pending/queued run has no worker executing it yet, so cancelling it is
+// always safe. This must succeed even for templates like publish-analytics
+// that opt out of *cooperative* cancel (CancelPolicy.Supported = false),
+// otherwise a not-yet-started job can never be removed from the queue.
+func TestRequestCancel_PendingAllowedEvenWhenTemplateDoesNotSupportCancel(t *testing.T) {
+ ctx := context.Background()
+ template := publishAnalyticsTemplate()
+ if template.CancelPolicy.Supported {
+ t.Fatal("test setup assumes publish-analytics does not support cooperative cancel")
+ }
+ runs := newMemoryRunRepo(&entity.Run{
+ TemplateType: template.Type,
+ Status: enum.RunStatusPending,
+ WorkerType: template.Steps[0].WorkerType,
+ })
+ uc := testUseCaseFull(template, runs, nil, newMemoryQueueRepo())
+
+ updated, err := uc.RequestCancel(ctx, domusecase.CancelRunRequest{
+ JobID: "507f1f77bcf86cd799439011",
+ Reason: "user requested cancel",
+ })
+ if err != nil {
+ t.Fatalf("RequestCancel() error = %v", err)
+ }
+ if updated.Status != enum.RunStatusCancelled {
+ t.Fatalf("status = %s, want cancelled", updated.Status)
+ }
+}
+
+// Cooperative cancel (interrupting an already-running worker) must still
+// respect the template's CancelPolicy.
+func TestRequestCancel_RunningRejectedWhenTemplateDoesNotSupportCancel(t *testing.T) {
+ ctx := context.Background()
+ template := publishAnalyticsTemplate()
+ runs := newMemoryRunRepo(&entity.Run{
+ TemplateType: template.Type,
+ Status: enum.RunStatusRunning,
+ WorkerType: template.Steps[0].WorkerType,
+ })
+ uc := testUseCaseFull(template, runs, nil, newMemoryQueueRepo())
+
+ _, err := uc.RequestCancel(ctx, domusecase.CancelRunRequest{
+ JobID: "507f1f77bcf86cd799439011",
+ Reason: "user requested cancel",
+ })
+ if err == nil {
+ t.Fatal("expected error cancelling a running job whose template does not support cooperative cancel")
+ }
+}
diff --git a/backend/internal/model/job/usecase/dedupe_test.go b/backend/internal/model/job/usecase/dedupe_test.go
index b43c6a4..8c4162c 100644
--- a/backend/internal/model/job/usecase/dedupe_test.go
+++ b/backend/internal/model/job/usecase/dedupe_test.go
@@ -72,6 +72,18 @@ func TestCreateRun_DedupeLeaseBlocksParallel(t *testing.T) {
}
}
+func TestBuildDedupeKey_IncludesScanPostID(t *testing.T) {
+ template := &entity.Template{
+ Type: "generate-outreach-draft",
+ DedupeKeys: []string{"scan_post_id"},
+ }
+ key1 := buildDedupeKey(template, "placement_topic", "topic-a", map[string]any{"scan_post_id": "post-1"})
+ key2 := buildDedupeKey(template, "placement_topic", "topic-a", map[string]any{"scan_post_id": "post-2"})
+ if key1 == "" || key2 == "" || key1 == key2 {
+ t.Fatalf("dedupe keys should differ by scan_post_id: %q vs %q", key1, key2)
+ }
+}
+
func TestBuildDedupeKey_IncludesScopeID(t *testing.T) {
template := &entity.Template{
Type: "demo",
diff --git a/backend/internal/model/job/usecase/enqueue_run_test.go b/backend/internal/model/job/usecase/enqueue_run_test.go
index 567d9ab..777a68c 100644
--- a/backend/internal/model/job/usecase/enqueue_run_test.go
+++ b/backend/internal/model/job/usecase/enqueue_run_test.go
@@ -4,9 +4,9 @@ import (
"context"
"testing"
+ "go.mongodb.org/mongo-driver/bson/primitive"
"haixun-backend/internal/model/job/domain/entity"
"haixun-backend/internal/model/job/domain/enum"
- "go.mongodb.org/mongo-driver/bson/primitive"
)
func TestEnqueueRun_DoesNotOverwriteRunningClaim(t *testing.T) {
@@ -41,4 +41,4 @@ func TestEnqueueRun_DoesNotOverwriteRunningClaim(t *testing.T) {
if len(queue.queues["go"]) != 0 {
t.Fatalf("queue = %v, want duplicate enqueue removed", queue.queues["go"])
}
-}
\ No newline at end of file
+}
diff --git a/backend/internal/model/job/usecase/helpers.go b/backend/internal/model/job/usecase/helpers.go
index 7a35576..222e8be 100644
--- a/backend/internal/model/job/usecase/helpers.go
+++ b/backend/internal/model/job/usecase/helpers.go
@@ -47,17 +47,13 @@ func buildDedupeKey(template *entity.Template, scope, scopeID string, payload ma
switch key {
case "scope_id":
parts = append(parts, scopeID)
- case "target":
- if payload != nil {
- if target, ok := payload["target"].(string); ok {
- parts = append(parts, target)
- }
+ default:
+ if payload == nil {
+ continue
}
- case "benchmark_username":
- if payload != nil {
- if username, ok := payload["benchmark_username"].(string); ok {
- parts = append(parts, username)
- }
+ value := dedupePayloadValue(payload, key)
+ if value != "" {
+ parts = append(parts, value)
}
}
}
@@ -66,6 +62,24 @@ func buildDedupeKey(template *entity.Template, scope, scopeID string, payload ma
return hex.EncodeToString(sum[:])
}
+func dedupePayloadValue(payload map[string]any, key string) string {
+ if payload == nil || key == "" {
+ return ""
+ }
+ if value := stringFromAny(payload[key]); value != "" {
+ return value
+ }
+ // Legacy aliases used by older payloads.
+ switch key {
+ case "target":
+ return stringFromAny(payload["target"])
+ case "benchmark_username":
+ return stringFromAny(payload["benchmark_username"])
+ default:
+ return ""
+ }
+}
+
func firstPhaseFromTemplateSteps(steps []entity.StepProgress) string {
if len(steps) == 0 {
return ""
diff --git a/backend/internal/model/job/usecase/maintenance.go b/backend/internal/model/job/usecase/maintenance.go
index eef3e1f..0784929 100644
--- a/backend/internal/model/job/usecase/maintenance.go
+++ b/backend/internal/model/job/usecase/maintenance.go
@@ -36,18 +36,18 @@ func (u *jobUseCase) RunMaintenance(ctx context.Context) (domusecase.Maintenance
continue
}
jobID := run.ID.Hex()
- fromStatus := string(run.Status)
+ fromStatus := run.Status
run.Status = enum.RunStatusCancelled
run.CompletedAt = &now
run.Progress.Summary = "cancelled after grace timeout"
- updated, err := u.runs.Update(ctx, run)
+ updated, err := u.runs.UpdateIfStatus(ctx, run, []enum.RunStatus{fromStatus})
if err != nil {
continue
}
_ = u.queue.ReleaseLock(ctx, jobID, run.LockedBy)
_ = u.queue.ClearCancelSignal(ctx, jobID)
u.releaseDedupe(ctx, updated)
- _ = u.appendEvent(ctx, jobID, "status_changed", fromStatus, string(enum.RunStatusCancelled), "cancel grace timeout", nil)
+ _ = u.appendEvent(ctx, jobID, "status_changed", string(fromStatus), string(enum.RunStatusCancelled), "cancel grace timeout", nil)
result.ReapedCancelGrace++
}
@@ -104,11 +104,12 @@ func (u *jobUseCase) RunMaintenance(ctx context.Context) (domusecase.Maintenance
}
for _, run := range expiredRuns {
jobID := run.ID.Hex()
- fromStatus := string(run.Status)
+ fromStatusEnum := run.Status
+ fromStatus := string(fromStatusEnum)
run.Status = enum.RunStatusExpired
run.CompletedAt = &now
run.Progress.Summary = "job lock expired"
- updated, err := u.runs.Update(ctx, run)
+ updated, err := u.runs.UpdateIfStatus(ctx, run, []enum.RunStatus{fromStatusEnum})
if err != nil {
continue
}
diff --git a/backend/internal/model/job/usecase/maintenance_stuck_test.go b/backend/internal/model/job/usecase/maintenance_stuck_test.go
index 6e9d97f..668e737 100644
--- a/backend/internal/model/job/usecase/maintenance_stuck_test.go
+++ b/backend/internal/model/job/usecase/maintenance_stuck_test.go
@@ -37,4 +37,4 @@ func TestRunMaintenance_RepairsQueuedWithActiveLock(t *testing.T) {
if runs.run.Status != enum.RunStatusRunning {
t.Fatalf("status = %s, want running", runs.run.Status)
}
-}
\ No newline at end of file
+}
diff --git a/backend/internal/model/job/usecase/maintenance_test.go b/backend/internal/model/job/usecase/maintenance_test.go
index 631d184..7296729 100644
--- a/backend/internal/model/job/usecase/maintenance_test.go
+++ b/backend/internal/model/job/usecase/maintenance_test.go
@@ -63,6 +63,37 @@ func TestRunMaintenance_ReapCancelGrace(t *testing.T) {
}
}
+func TestRunMaintenance_ReapCancelGrace_SkipsIfAlreadyResolved(t *testing.T) {
+ ctx := context.Background()
+ template := demoTemplate()
+ template.CancelPolicy.GraceSeconds = 1
+ queue := newMemoryQueueRepo()
+ runs := newMemoryRunRepo(nil)
+ cancelAt := clock.Now().Add(-2 * time.Second).UnixNano()
+ runs.run = &entity.Run{
+ TemplateType: template.Type,
+ Status: enum.RunStatusCancelRequested,
+ CancelRequestedAt: &cancelAt,
+ DedupeKey: "dedupe-1",
+ }
+ uc := testUseCaseFull(template, runs, nil, queue)
+
+ // Simulate the worker acknowledging cancel (or completing/failing the job)
+ // concurrently, right after the reaper reads it but before it writes back.
+ runs.raceAfterFind = func() { runs.run.Status = enum.RunStatusSucceeded }
+
+ result, err := uc.RunMaintenance(ctx)
+ if err != nil {
+ t.Fatalf("RunMaintenance() error = %v", err)
+ }
+ if result.ReapedCancelGrace != 0 {
+ t.Fatalf("ReapedCancelGrace = %d, want 0 (must not clobber concurrently resolved status)", result.ReapedCancelGrace)
+ }
+ if runs.run.Status != enum.RunStatusSucceeded {
+ t.Fatalf("status = %s, want succeeded (unchanged)", runs.run.Status)
+ }
+}
+
func TestRunMaintenance_ReapExpiredLock(t *testing.T) {
ctx := context.Background()
template := demoTemplate()
@@ -88,3 +119,33 @@ func TestRunMaintenance_ReapExpiredLock(t *testing.T) {
t.Fatalf("status = %s, want expired", runs.run.Status)
}
}
+
+func TestRunMaintenance_ReapExpiredLock_SkipsIfAlreadyResolved(t *testing.T) {
+ ctx := context.Background()
+ template := demoTemplate()
+ queue := newMemoryQueueRepo()
+ runs := newMemoryRunRepo(nil)
+ lockedUntil := clock.Now().Add(-time.Second).UnixNano()
+ runs.run = &entity.Run{
+ TemplateType: template.Type,
+ Status: enum.RunStatusRunning,
+ LockedUntil: &lockedUntil,
+ DedupeKey: "dedupe-2",
+ }
+ uc := testUseCaseFull(template, runs, nil, queue)
+
+ // Simulate the worker completing the job right after the reaper reads it
+ // but before it writes back; the reaper must not overwrite it to expired.
+ runs.raceAfterFind = func() { runs.run.Status = enum.RunStatusSucceeded }
+
+ result, err := uc.RunMaintenance(ctx)
+ if err != nil {
+ t.Fatalf("RunMaintenance() error = %v", err)
+ }
+ if result.ReapedExpiredLocks != 0 {
+ t.Fatalf("ReapedExpiredLocks = %d, want 0 (must not clobber concurrently resolved status)", result.ReapedExpiredLocks)
+ }
+ if runs.run.Status != enum.RunStatusSucceeded {
+ t.Fatalf("status = %s, want succeeded (unchanged)", runs.run.Status)
+ }
+}
diff --git a/backend/internal/model/job/usecase/retry_max_attempts_test.go b/backend/internal/model/job/usecase/retry_max_attempts_test.go
new file mode 100644
index 0000000..8576049
--- /dev/null
+++ b/backend/internal/model/job/usecase/retry_max_attempts_test.go
@@ -0,0 +1,41 @@
+package usecase
+
+import (
+ "context"
+ "testing"
+
+ "haixun-backend/internal/model/job/domain/entity"
+ "haixun-backend/internal/model/job/domain/enum"
+
+ "go.mongodb.org/mongo-driver/bson/primitive"
+)
+
+func TestRetryRun_AllowsManualRetryWhenAttemptReachedMaxAttempts(t *testing.T) {
+ ctx := context.Background()
+ template := generateRewriteDraftTemplate()
+ jobID := primitive.NewObjectID()
+ runs := newMemoryRunRepo(&entity.Run{
+ ID: jobID,
+ TemplateType: template.Type,
+ Status: enum.RunStatusFailed,
+ WorkerType: workerTypeGenerateRewriteDraft,
+ Attempt: 1,
+ MaxAttempts: 1,
+ Progress: entity.RunProgress{
+ Steps: []entity.StepProgress{{ID: "rewrite_draft_generate", Status: enum.StepStatusFailed}},
+ },
+ })
+ queue := newMemoryQueueRepo()
+ uc := testUseCaseFull(template, runs, nil, queue)
+
+ updated, err := uc.RetryRun(ctx, jobID.Hex())
+ if err != nil {
+ t.Fatalf("RetryRun() error = %v", err)
+ }
+ if updated.Status != enum.RunStatusQueued && updated.Status != enum.RunStatusPending {
+ t.Fatalf("status = %s, want queued or pending", updated.Status)
+ }
+ if updated.Attempt != 2 {
+ t.Fatalf("attempt = %d, want 2", updated.Attempt)
+ }
+}
diff --git a/backend/internal/model/job/usecase/retry_test.go b/backend/internal/model/job/usecase/retry_test.go
index 2c3611b..554014e 100644
--- a/backend/internal/model/job/usecase/retry_test.go
+++ b/backend/internal/model/job/usecase/retry_test.go
@@ -50,3 +50,39 @@ func TestRetryRun_SchedulesBackoff(t *testing.T) {
t.Fatal("backoff retry should not enqueue immediately")
}
}
+
+func TestRetryRun_BackoffReschedule_DoesNotClobberConcurrentCancel(t *testing.T) {
+ ctx := context.Background()
+ template := demoTemplate()
+ template.RetryPolicy = entity.RetryPolicy{
+ MaxAttempts: 3,
+ BackoffSeconds: []int{30, 60},
+ }
+ jobID := primitive.NewObjectID()
+ runs := newMemoryRunRepo(&entity.Run{
+ ID: jobID,
+ TemplateType: template.Type,
+ Status: enum.RunStatusFailed,
+ WorkerType: "go",
+ Attempt: 1,
+ MaxAttempts: 3,
+ Progress: entity.RunProgress{
+ Steps: []entity.StepProgress{{ID: "prepare", Status: enum.StepStatusFailed}},
+ },
+ })
+ queue := newMemoryQueueRepo()
+ uc := testUseCaseFull(template, runs, nil, queue)
+
+ // Simulate a user cancelling the job right after RetryRun transitions it
+ // to "pending" but before the backoff reschedule writes ScheduledAt back.
+ // The reschedule write must not silently revive the cancelled job.
+ runs.raceAfterUpdateIfStatus = func() { runs.run.Status = enum.RunStatusCancelled }
+
+ _, err := uc.RetryRun(ctx, jobID.Hex())
+ if err == nil {
+ t.Fatal("expected error when backoff reschedule races with a concurrent cancel")
+ }
+ if runs.run.Status != enum.RunStatusCancelled {
+ t.Fatalf("status = %s, want cancelled (must not be revived to pending)", runs.run.Status)
+ }
+}
diff --git a/backend/internal/model/job/usecase/schedule_test.go b/backend/internal/model/job/usecase/schedule_test.go
index 05fb629..5a7e850 100644
--- a/backend/internal/model/job/usecase/schedule_test.go
+++ b/backend/internal/model/job/usecase/schedule_test.go
@@ -36,7 +36,7 @@ func TestRunSchedulerTick_CreatesRun(t *testing.T) {
if created != 1 {
t.Fatalf("created = %d, want 1", created)
}
- if len(queue.queued("go")) == 0 {
+ if len(queue.queued(workerTypeDemo)) == 0 {
t.Fatal("expected queued job from scheduler tick")
}
if runs.run == nil {
diff --git a/backend/internal/model/job/usecase/test_mocks.go b/backend/internal/model/job/usecase/test_mocks.go
index 91885c0..a383abd 100644
--- a/backend/internal/model/job/usecase/test_mocks.go
+++ b/backend/internal/model/job/usecase/test_mocks.go
@@ -38,6 +38,34 @@ type memoryRunRepo struct {
mu sync.Mutex
run *entity.Run
succeeded map[string]*entity.Run
+ // raceAfterFind, when set, fires exactly once right after a Find* method
+ // captures its result snapshot. It lets tests simulate another writer
+ // (worker ack, complete, fail...) mutating the stored run concurrently,
+ // between the reaper's read and its guarded write.
+ raceAfterFind func()
+ // raceAfterUpdateIfStatus, when set, fires exactly once right after a
+ // UpdateIfStatus call succeeds. It lets tests simulate another writer
+ // racing in between two sequential guarded updates on the same run
+ // (e.g. RetryRun's pending transition followed by its backoff reschedule).
+ raceAfterUpdateIfStatus func()
+}
+
+func (m *memoryRunRepo) fireRaceAfterFindLocked() {
+ if m.raceAfterFind == nil {
+ return
+ }
+ fn := m.raceAfterFind
+ m.raceAfterFind = nil
+ fn()
+}
+
+func (m *memoryRunRepo) fireRaceAfterUpdateIfStatusLocked() {
+ if m.raceAfterUpdateIfStatus == nil {
+ return
+ }
+ fn := m.raceAfterUpdateIfStatus
+ m.raceAfterUpdateIfStatus = nil
+ fn()
}
func newMemoryRunRepo(run *entity.Run) *memoryRunRepo {
@@ -87,6 +115,7 @@ func (m *memoryRunRepo) UpdateIfStatus(ctx context.Context, run *entity.Run, all
if run.Status == enum.RunStatusSucceeded && run.DedupeKey != "" {
m.succeeded[run.TemplateType+":"+run.DedupeKey] = run
}
+ m.fireRaceAfterUpdateIfStatusLocked()
return run, nil
}
func (m *memoryRunRepo) UpdateIfLocked(ctx context.Context, run *entity.Run, workerID string, allowed []enum.RunStatus) (*entity.Run, error) {
@@ -147,7 +176,7 @@ func (m *memoryRunRepo) FindPendingDue(_ context.Context, now int64, _ int64) ([
return nil, nil
}
if *m.run.ScheduledAt <= now {
- return []*entity.Run{m.run}, nil
+ return []*entity.Run{cloneRun(m.run)}, nil
}
return nil, nil
}
@@ -158,7 +187,9 @@ func (m *memoryRunRepo) FindCancelRequestedBefore(_ context.Context, before int6
return nil, nil
}
if *m.run.CancelRequestedAt <= before {
- return []*entity.Run{m.run}, nil
+ snapshot := cloneRun(m.run)
+ m.fireRaceAfterFindLocked()
+ return []*entity.Run{snapshot}, nil
}
return nil, nil
}
@@ -171,7 +202,7 @@ func (m *memoryRunRepo) FindQueuedWithLock(_ context.Context, _ int64) ([]*entit
if m.run.Status != enum.RunStatusPending && m.run.Status != enum.RunStatusQueued {
return nil, nil
}
- return []*entity.Run{m.run}, nil
+ return []*entity.Run{cloneRun(m.run)}, nil
}
func (m *memoryRunRepo) FindRunningTimedOut(_ context.Context, now int64, _ int64) ([]*entity.Run, error) {
@@ -184,7 +215,9 @@ func (m *memoryRunRepo) FindRunningTimedOut(_ context.Context, now int64, _ int6
return nil, nil
}
if *m.run.LockedUntil <= now {
- return []*entity.Run{m.run}, nil
+ snapshot := cloneRun(m.run)
+ m.fireRaceAfterFindLocked()
+ return []*entity.Run{snapshot}, nil
}
return nil, nil
}
diff --git a/backend/internal/model/job/usecase/usecase.go b/backend/internal/model/job/usecase/usecase.go
index 9ec57b8..75cb93a 100644
--- a/backend/internal/model/job/usecase/usecase.go
+++ b/backend/internal/model/job/usecase/usecase.go
@@ -14,18 +14,38 @@ import (
)
const (
- demoTemplateType = "demo_long_task"
- style8DTemplateType = "style-8d"
- expandGraphTemplateType = "expand-graph"
- placementScanTemplateType = "placement-scan"
- scanViralTemplateType = "scan-viral"
- analyzeCopyMissionTemplateType = "analyze-copy-mission"
- expandCopyMissionGraphTemplateType = "expand-copy-mission-graph"
- generateCopyMatrixTemplateType = "generate-copy-matrix"
- generateCopyDraftTemplateType = "generate-copy-draft"
- refreshThreadsTokenTemplateType = "refresh-threads-token"
- publishAnalyticsTemplateType = "publish-analytics"
- style8DWorkerType = "node"
+ demoTemplateType = "demo_long_task"
+ style8DTemplateType = "style-8d"
+ expandGraphTemplateType = "expand-graph"
+ placementScanTemplateType = "placement-scan"
+ scanViralTemplateType = "scan-viral"
+ analyzeCopyMissionTemplateType = "analyze-copy-mission"
+ expandCopyMissionGraphTemplateType = "expand-copy-mission-graph"
+ generateCopyMatrixTemplateType = "generate-copy-matrix"
+ generateCopyDraftTemplateType = "generate-copy-draft"
+ generateOutreachDraftTemplateType = "generate-outreach-draft"
+ generateTopicMatrixTemplateType = "generate-topic-matrix"
+ generateFormulaDraftTemplateType = "generate-formula-draft"
+ generateRewriteDraftTemplateType = "generate-rewrite-draft"
+ refreshThreadsTokenTemplateType = "refresh-threads-token"
+ publishAnalyticsTemplateType = "publish-analytics"
+ refillPublishInventoryTemplateType = "refill-publish-inventory"
+ workerTypeDemo = "go-demo"
+ workerTypeExpandGraph = "go-expand-graph"
+ workerTypePlacementScan = "go-placement-scan"
+ workerTypeScanViral = "go-scan-viral"
+ workerTypeAnalyzeCopyMission = "go-analyze-copy-mission"
+ workerTypeExpandCopyMissionGraph = "go-expand-copy-mission-graph"
+ workerTypeGenerateCopyMatrix = "go-generate-copy-matrix"
+ workerTypeGenerateCopyDraft = "go-generate-copy-draft"
+ workerTypeGenerateOutreachDraft = "go-generate-outreach-draft"
+ workerTypeGenerateTopicMatrix = "go-generate-topic-matrix"
+ workerTypeGenerateFormulaDraft = "go-generate-formula-draft"
+ workerTypeGenerateRewriteDraft = "go-generate-rewrite-draft"
+ workerTypeRefreshThreadsToken = "go-refresh-threads-token"
+ workerTypePublishAnalytics = "go-publish-analytics"
+ workerTypeRefillPublishInventory = "go-refill-publish-inventory"
+ style8DWorkerType = "node-style-8d"
)
type UseCase = domusecase.UseCase
@@ -107,6 +127,26 @@ func (u *jobUseCase) EnsureGenerateCopyDraftTemplate(ctx context.Context) error
return err
}
+func (u *jobUseCase) EnsureGenerateOutreachDraftTemplate(ctx context.Context) error {
+ _, err := u.templates.Upsert(ctx, generateOutreachDraftTemplate())
+ return err
+}
+
+func (u *jobUseCase) EnsureGenerateTopicMatrixTemplate(ctx context.Context) error {
+ _, err := u.templates.Upsert(ctx, generateTopicMatrixTemplate())
+ return err
+}
+
+func (u *jobUseCase) EnsureGenerateFormulaDraftTemplate(ctx context.Context) error {
+ _, err := u.templates.Upsert(ctx, generateFormulaDraftTemplate())
+ return err
+}
+
+func (u *jobUseCase) EnsureGenerateRewriteDraftTemplate(ctx context.Context) error {
+ _, err := u.templates.Upsert(ctx, generateRewriteDraftTemplate())
+ return err
+}
+
func (u *jobUseCase) EnsureRefreshThreadsTokenTemplate(ctx context.Context) error {
_, err := u.templates.Upsert(ctx, refreshThreadsTokenTemplate())
return err
@@ -117,6 +157,11 @@ func (u *jobUseCase) EnsurePublishAnalyticsTemplate(ctx context.Context) error {
return err
}
+func (u *jobUseCase) EnsureRefillPublishInventoryTemplate(ctx context.Context) error {
+ _, err := u.templates.Upsert(ctx, refillPublishInventoryTemplate())
+ return err
+}
+
func expandCopyMissionGraphTemplate() *entity.Template {
return &entity.Template{
Type: expandCopyMissionGraphTemplateType,
@@ -138,7 +183,7 @@ func expandCopyMissionGraphTemplate() *entity.Template {
BackoffSeconds: []int{},
},
Steps: []entity.TemplateStep{
- {ID: "expand_copy_knowledge", Name: "Expand copy mission knowledge", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 600, Cancelable: true},
+ {ID: "expand_copy_knowledge", Name: "Expand copy mission knowledge", WorkerType: workerTypeExpandCopyMissionGraph, TimeoutSeconds: 600, Cancelable: true},
},
}
}
@@ -164,7 +209,7 @@ func analyzeCopyMissionTemplate() *entity.Template {
BackoffSeconds: []int{},
},
Steps: []entity.TemplateStep{
- {ID: "copy_mission_map", Name: "Copy mission research map", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 300, Cancelable: true},
+ {ID: "copy_mission_map", Name: "Copy mission research map", WorkerType: workerTypeAnalyzeCopyMission, TimeoutSeconds: 300, Cancelable: true},
},
}
}
@@ -190,7 +235,111 @@ func generateCopyMatrixTemplate() *entity.Template {
BackoffSeconds: []int{},
},
Steps: []entity.TemplateStep{
- {ID: "copy_matrix_generate", Name: "Copy matrix generate", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 600, Cancelable: true},
+ {ID: "copy_matrix_generate", Name: "Copy matrix generate", WorkerType: workerTypeGenerateCopyMatrix, TimeoutSeconds: 600, Cancelable: true},
+ },
+ }
+}
+
+func generateTopicMatrixTemplate() *entity.Template {
+ return &entity.Template{
+ Type: generateTopicMatrixTemplateType,
+ Version: 1,
+ Name: "Generate Topic Matrix",
+ Description: "LLM topic matrix drafts for persona copy missions",
+ Enabled: true,
+ Repeatable: true,
+ ConcurrencyPolicy: string(enum.ConcurrencyRejectSameScope),
+ DedupeKeys: []string{"scope_id", "topic"},
+ TimeoutSeconds: 600,
+ CancelPolicy: entity.CancelPolicy{
+ Supported: true,
+ Mode: "cooperative",
+ GraceSeconds: 30,
+ },
+ RetryPolicy: entity.RetryPolicy{
+ MaxAttempts: 1,
+ BackoffSeconds: []int{},
+ },
+ Steps: []entity.TemplateStep{
+ {ID: "topic_matrix_generate", Name: "Topic matrix generate", WorkerType: workerTypeGenerateTopicMatrix, TimeoutSeconds: 600, Cancelable: true},
+ },
+ }
+}
+
+func generateFormulaDraftTemplate() *entity.Template {
+ return &entity.Template{
+ Type: generateFormulaDraftTemplateType,
+ Version: 1,
+ Name: "Generate Formula Draft",
+ Description: "LLM formula-based copy draft for persona rewrite flow",
+ Enabled: true,
+ Repeatable: true,
+ ConcurrencyPolicy: string(enum.ConcurrencyRejectSameScope),
+ DedupeKeys: []string{"scope_id", "formula_id", "topic"},
+ TimeoutSeconds: 600,
+ CancelPolicy: entity.CancelPolicy{
+ Supported: true,
+ Mode: "cooperative",
+ GraceSeconds: 30,
+ },
+ RetryPolicy: entity.RetryPolicy{
+ MaxAttempts: 1,
+ BackoffSeconds: []int{},
+ },
+ Steps: []entity.TemplateStep{
+ {ID: "formula_draft_generate", Name: "Formula draft generate", WorkerType: workerTypeGenerateFormulaDraft, TimeoutSeconds: 600, Cancelable: true},
+ },
+ }
+}
+
+func generateRewriteDraftTemplate() *entity.Template {
+ return &entity.Template{
+ Type: generateRewriteDraftTemplateType,
+ Version: 1,
+ Name: "Generate Rewrite Draft",
+ Description: "Analyze pasted reference post and generate persona rewrite draft",
+ Enabled: true,
+ Repeatable: true,
+ ConcurrencyPolicy: string(enum.ConcurrencyAllowParallel),
+ DedupeKeys: []string{},
+ TimeoutSeconds: 900,
+ CancelPolicy: entity.CancelPolicy{
+ Supported: true,
+ Mode: "cooperative",
+ GraceSeconds: 30,
+ },
+ RetryPolicy: entity.RetryPolicy{
+ MaxAttempts: 3,
+ BackoffSeconds: []int{},
+ },
+ Steps: []entity.TemplateStep{
+ {ID: "rewrite_draft_generate", Name: "Rewrite draft generate", WorkerType: workerTypeGenerateRewriteDraft, TimeoutSeconds: 900, Cancelable: true},
+ },
+ }
+}
+
+func generateOutreachDraftTemplate() *entity.Template {
+ return &entity.Template{
+ Type: generateOutreachDraftTemplateType,
+ Version: 1,
+ Name: "Generate Outreach Draft",
+ Description: "LLM outreach reply drafts for a placement scan post",
+ Enabled: true,
+ Repeatable: true,
+ ConcurrencyPolicy: string(enum.ConcurrencyAllowParallel),
+ DedupeKeys: []string{"scan_post_id"},
+ TimeoutSeconds: 600,
+ CancelPolicy: entity.CancelPolicy{
+ Supported: true,
+ Mode: "cooperative",
+ GraceSeconds: 30,
+ },
+ RetryPolicy: entity.RetryPolicy{
+ MaxAttempts: 1,
+ BackoffSeconds: []int{},
+ },
+ Steps: []entity.TemplateStep{
+ {ID: "outreach_draft_generate", Name: "Outreach draft generate", WorkerType: workerTypeGenerateOutreachDraft, TimeoutSeconds: 600, Cancelable: true},
},
}
}
@@ -216,7 +365,7 @@ func generateCopyDraftTemplate() *entity.Template {
BackoffSeconds: []int{},
},
Steps: []entity.TemplateStep{
- {ID: "copy_draft_generate", Name: "Copy draft generate", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 600, Cancelable: true},
+ {ID: "copy_draft_generate", Name: "Copy draft generate", WorkerType: workerTypeGenerateCopyDraft, TimeoutSeconds: 600, Cancelable: true},
},
}
}
@@ -240,7 +389,7 @@ func refreshThreadsTokenTemplate() *entity.Template {
BackoffSeconds: []int{30, 120, 300},
},
Steps: []entity.TemplateStep{
- {ID: "refresh", Name: "Refresh token", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 120, Cancelable: false},
+ {ID: "refresh", Name: "Refresh token", WorkerType: workerTypeRefreshThreadsToken, TimeoutSeconds: 120, Cancelable: false},
},
}
}
@@ -264,7 +413,33 @@ func publishAnalyticsTemplate() *entity.Template {
BackoffSeconds: []int{60},
},
Steps: []entity.TemplateStep{
- {ID: "check", Name: "Check analytics", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 120, Cancelable: false},
+ {ID: "check", Name: "Check analytics", WorkerType: workerTypePublishAnalytics, TimeoutSeconds: 120, Cancelable: false},
+ },
+ }
+}
+
+func refillPublishInventoryTemplate() *entity.Template {
+ return &entity.Template{
+ Type: refillPublishInventoryTemplateType,
+ Version: 1,
+ Name: "Refill Publish Inventory",
+ Description: "Generate and schedule drafts when Threads publish inventory is low",
+ Enabled: true,
+ Repeatable: true,
+ ConcurrencyPolicy: string(enum.ConcurrencyRejectSameScope),
+ DedupeKeys: []string{"account_id"},
+ TimeoutSeconds: 900,
+ CancelPolicy: entity.CancelPolicy{
+ Supported: true,
+ Mode: "cooperative",
+ GraceSeconds: 30,
+ },
+ RetryPolicy: entity.RetryPolicy{
+ MaxAttempts: 1,
+ BackoffSeconds: []int{},
+ },
+ Steps: []entity.TemplateStep{
+ {ID: "refill_publish_inventory", Name: "Refill publish inventory", WorkerType: workerTypeRefillPublishInventory, TimeoutSeconds: 900, Cancelable: true},
},
}
}
@@ -290,7 +465,7 @@ func scanViralTemplate() *entity.Template {
BackoffSeconds: []int{},
},
Steps: []entity.TemplateStep{
- {ID: "viral_crawl", Name: "Viral keyword crawl", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 600, Cancelable: true},
+ {ID: "viral_crawl", Name: "Viral keyword crawl", WorkerType: workerTypeScanViral, TimeoutSeconds: 600, Cancelable: true},
},
}
}
@@ -316,7 +491,7 @@ func placementScanTemplate() *entity.Template {
BackoffSeconds: []int{},
},
Steps: []entity.TemplateStep{
- {ID: "crawl", Name: "Dual-track crawl", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 7200, Cancelable: true},
+ {ID: "crawl", Name: "Dual-track crawl", WorkerType: workerTypePlacementScan, TimeoutSeconds: 7200, Cancelable: true},
},
}
}
@@ -342,7 +517,7 @@ func expandGraphTemplate() *entity.Template {
BackoffSeconds: []int{},
},
Steps: []entity.TemplateStep{
- {ID: "expand", Name: "Expand knowledge graph", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 600, Cancelable: true},
+ {ID: "expand", Name: "Expand knowledge graph", WorkerType: workerTypeExpandGraph, TimeoutSeconds: 600, Cancelable: true},
},
}
}
@@ -368,10 +543,10 @@ func style8DTemplate() *entity.Template {
BackoffSeconds: []int{},
},
Steps: []entity.TemplateStep{
- {ID: "session", Name: "Confirm Threads connection", WorkerType: style8DWorkerType, TimeoutSeconds: 60, Cancelable: true},
- {ID: "samples", Name: "Fetch recent posts", WorkerType: style8DWorkerType, TimeoutSeconds: 180, Cancelable: true},
- {ID: "style", Name: "AI 8D analysis", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 240, Cancelable: true},
- {ID: "store", Name: "Save persona strategy", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 60, Cancelable: false},
+ {ID: "session", Name: "Prepare public profile crawl", WorkerType: style8DWorkerType, TimeoutSeconds: 60, Cancelable: true},
+ {ID: "samples", Name: "Fetch public posts", WorkerType: style8DWorkerType, TimeoutSeconds: 180, Cancelable: true},
+ {ID: "style", Name: "AI 8D analysis", WorkerType: style8DWorkerType, TimeoutSeconds: 240, Cancelable: true},
+ {ID: "store", Name: "Save persona strategy", WorkerType: style8DWorkerType, TimeoutSeconds: 60, Cancelable: false},
},
}
}
@@ -397,9 +572,9 @@ func demoTemplate() *entity.Template {
BackoffSeconds: []int{30, 120},
},
Steps: []entity.TemplateStep{
- {ID: "prepare", Name: "Prepare data", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 60, Cancelable: true},
- {ID: "execute", Name: "Execute task", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 300, Cancelable: true},
- {ID: "finalize", Name: "Finalize result", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 30, Cancelable: false},
+ {ID: "prepare", Name: "Prepare data", WorkerType: workerTypeDemo, TimeoutSeconds: 60, Cancelable: true},
+ {ID: "execute", Name: "Execute task", WorkerType: workerTypeDemo, TimeoutSeconds: 300, Cancelable: true},
+ {ID: "finalize", Name: "Finalize result", WorkerType: workerTypeDemo, TimeoutSeconds: 30, Cancelable: false},
},
}
}
@@ -449,6 +624,7 @@ func (u *jobUseCase) CreateRun(ctx context.Context, req domusecase.CreateRunRequ
Attempt: 1,
MaxAttempts: maxAttemptsOrDefault(template.RetryPolicy.MaxAttempts),
DedupeKey: dedupeKey,
+ ScheduledAt: req.ScheduledAt,
}
created, err := u.runs.Create(ctx, run)
@@ -538,9 +714,6 @@ func (u *jobUseCase) RequestCancel(ctx context.Context, req domusecase.CancelRun
if err != nil {
return nil, err
}
- if !template.CancelPolicy.Supported {
- return nil, app.For(code.Job).ResInvalidState("job template does not support cancel")
- }
reason := strings.TrimSpace(req.Reason)
if reason == "" {
@@ -551,6 +724,13 @@ func (u *jobUseCase) RequestCancel(ctx context.Context, req domusecase.CancelRun
switch run.Status {
case enum.RunStatusPending, enum.RunStatusQueued:
+ // A pending/queued run has no worker executing it yet, so there is
+ // nothing to cooperatively interrupt: cancelling is always safe here
+ // regardless of the template's CancelPolicy (that policy only governs
+ // interrupting an in-flight worker below). Gating this on
+ // CancelPolicy.Supported used to make e.g. a not-yet-started
+ // publish-analytics checkpoint job permanently uncancellable, even
+ // though it was just sitting in the queue.
_ = u.queue.RemoveJob(ctx, run.WorkerType, jobID)
run.Status = enum.RunStatusCancelled
run.CancelReason = reason
@@ -567,6 +747,9 @@ func (u *jobUseCase) RequestCancel(ctx context.Context, req domusecase.CancelRun
return updated, nil
case enum.RunStatusRunning, enum.RunStatusWaitingWorker:
+ if !template.CancelPolicy.Supported {
+ return nil, app.For(code.Job).ResInvalidState("job template does not support cancel")
+ }
run.Status = enum.RunStatusCancelRequested
run.CancelRequestedAt = &now
run.CancelReason = reason
@@ -599,9 +782,6 @@ func (u *jobUseCase) RetryRun(ctx context.Context, jobID string) (*entity.Run, e
run.Status != enum.RunStatusExpired {
return nil, app.For(code.Job).ResInvalidState("only failed, cancelled, or expired jobs can be retried")
}
- if run.Attempt >= run.MaxAttempts {
- return nil, app.For(code.Job).ResInvalidState("job has reached max attempts")
- }
fromStatus := string(run.Status)
template, err := u.templates.FindByType(ctx, run.TemplateType)
@@ -642,11 +822,12 @@ func (u *jobUseCase) RetryRun(ctx context.Context, jobID string) (*entity.Run, e
if backoff > 0 {
scheduled := clock.AddSecondsFromNow(backoff)
updated.ScheduledAt = &scheduled
- updated, err = u.runs.Update(ctx, updated)
+ rescheduled, err := u.runs.UpdateIfStatus(ctx, updated, []enum.RunStatus{enum.RunStatusPending})
if err != nil {
u.releaseDedupe(ctx, updated)
return nil, err
}
+ updated = rescheduled
_ = u.appendEvent(ctx, jobID, "retried", fromStatus, string(enum.RunStatusPending), "job scheduled for retry", map[string]any{"attempt": updated.Attempt, "backoff_seconds": backoff})
return updated, nil
}
@@ -888,7 +1069,11 @@ func (u *jobUseCase) FailRun(ctx context.Context, req domusecase.FailRunRequest)
run.Phase = req.Phase
}
run.CompletedAt = &now
- run.Progress.Summary = "job failed"
+ if strings.TrimSpace(req.Error) != "" {
+ run.Progress.Summary = req.Error
+ } else {
+ run.Progress.Summary = "任務失敗"
+ }
run.Progress.Percentage = calcProgressPercentage(run.Progress.Steps)
run.LockedBy = ""
run.LockedUntil = nil
diff --git a/backend/internal/model/knowledge_graph/usecase/topic_graph.go b/backend/internal/model/knowledge_graph/usecase/topic_graph.go
new file mode 100644
index 0000000..93de925
--- /dev/null
+++ b/backend/internal/model/knowledge_graph/usecase/topic_graph.go
@@ -0,0 +1,57 @@
+package usecase
+
+import (
+ "context"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/knowledge_graph/domain/entity"
+)
+
+func isGraphNotFound(err error) bool {
+ if e := app.FromError(err); e != nil && e.Category() == code.ResNotFound {
+ return true
+ }
+ return false
+}
+
+// loadTopicGraph resolves a placement-topic graph, including legacy brand-scoped graphs.
+func (u *knowledgeGraphUseCase) loadTopicGraph(ctx context.Context, tenantID, ownerUID, topicID, brandID string) (*entity.Graph, error) {
+ topicID = strings.TrimSpace(topicID)
+ if topicID == "" {
+ return nil, app.For(code.Brand).InputMissingRequired("topic id is required")
+ }
+ item, err := u.repo.FindByTopic(ctx, tenantID, ownerUID, topicID)
+ if err != nil && !isGraphNotFound(err) {
+ return nil, err
+ }
+ if item != nil {
+ return item, nil
+ }
+ brandID = strings.TrimSpace(brandID)
+ if brandID == "" {
+ return nil, app.For(code.Brand).ResNotFound("knowledge graph not found")
+ }
+ legacy, legacyErr := u.repo.FindByBrand(ctx, tenantID, ownerUID, brandID)
+ if legacyErr != nil {
+ if isGraphNotFound(legacyErr) {
+ return nil, app.For(code.Brand).ResNotFound("knowledge graph not found")
+ }
+ return nil, legacyErr
+ }
+ if legacy == nil {
+ return nil, app.For(code.Brand).ResNotFound("knowledge graph not found")
+ }
+ legacyTopicID := strings.TrimSpace(legacy.TopicID)
+ if legacyTopicID != "" && legacyTopicID != topicID {
+ return nil, app.For(code.Brand).ResNotFound("knowledge graph not found")
+ }
+ if legacyTopicID == "" {
+ if err := u.repo.AttachTopicID(ctx, tenantID, ownerUID, brandID, topicID); err != nil {
+ return nil, err
+ }
+ legacy.TopicID = topicID
+ }
+ return legacy, nil
+}
diff --git a/backend/internal/model/knowledge_graph/usecase/usecase.go b/backend/internal/model/knowledge_graph/usecase/usecase.go
index 5680b2e..32063d7 100644
--- a/backend/internal/model/knowledge_graph/usecase/usecase.go
+++ b/backend/internal/model/knowledge_graph/usecase/usecase.go
@@ -49,31 +49,10 @@ func (u *knowledgeGraphUseCase) GetByTopic(ctx context.Context, tenantID, ownerU
if err := requireScope(tenantID, ownerUID, "", topicID, ""); err != nil {
return nil, err
}
- item, err := u.repo.FindByTopic(ctx, tenantID, ownerUID, topicID)
+ item, err := u.loadTopicGraph(ctx, tenantID, ownerUID, topicID, brandID)
if err != nil {
return nil, err
}
- if item == nil {
- brandID = strings.TrimSpace(brandID)
- if brandID != "" {
- legacy, legacyErr := u.repo.FindByBrand(ctx, tenantID, ownerUID, brandID)
- if legacyErr != nil {
- return nil, legacyErr
- }
- if legacy != nil {
- legacyTopicID := strings.TrimSpace(legacy.TopicID)
- if legacyTopicID == "" || legacyTopicID == topicID {
- if legacyTopicID == "" {
- if err := u.repo.AttachTopicID(ctx, tenantID, ownerUID, brandID, topicID); err != nil {
- return nil, err
- }
- legacy.TopicID = topicID
- }
- item = legacy
- }
- }
- }
- }
summary := toSummary(item)
return &summary, nil
}
@@ -140,7 +119,7 @@ func (u *knowledgeGraphUseCase) UpdateNodes(ctx context.Context, req domusecase.
case copyMissionID != "":
current, err = u.repo.FindByCopyMission(ctx, req.TenantID, req.OwnerUID, copyMissionID)
case topicID != "":
- current, err = u.repo.FindByTopic(ctx, req.TenantID, req.OwnerUID, topicID)
+ current, err = u.loadTopicGraph(ctx, req.TenantID, req.OwnerUID, topicID, brandID)
default:
current, err = u.repo.FindByBrand(ctx, req.TenantID, req.OwnerUID, brandID)
}
diff --git a/backend/internal/model/member/domain/repository/member.go b/backend/internal/model/member/domain/repository/member.go
index e4734f7..d01a182 100644
--- a/backend/internal/model/member/domain/repository/member.go
+++ b/backend/internal/model/member/domain/repository/member.go
@@ -22,4 +22,5 @@ type Repository interface {
UpdateProfile(ctx context.Context, tenantID, uid string, update ProfileUpdate) (*entity.Member, error)
SetRoles(ctx context.Context, tenantID, uid string, roles []string) error
SetActiveThreadsAccountID(ctx context.Context, tenantID, uid, accountID string) error
+ UpdatePassword(ctx context.Context, tenantID, uid, passwordHash string) error
}
diff --git a/backend/internal/model/member/repository/mongo.go b/backend/internal/model/member/repository/mongo.go
index d28ef09..162ddd2 100644
--- a/backend/internal/model/member/repository/mongo.go
+++ b/backend/internal/model/member/repository/mongo.go
@@ -148,6 +148,27 @@ func (r *mongoRepository) UpdateProfile(ctx context.Context, tenantID, uid strin
return &out, err
}
+func (r *mongoRepository) UpdatePassword(ctx context.Context, tenantID, uid, passwordHash string) error {
+ if r.collection == nil {
+ return app.For(code.Member).DBUnavailable("Mongo is not configured")
+ }
+ if tenantID == "" || uid == "" {
+ return app.For(code.Member).InputMissingRequired("tenant_id and uid are required")
+ }
+ res, err := r.collection.UpdateOne(
+ ctx,
+ bson.M{"tenant_id": tenantID, "uid": uid},
+ bson.M{"$set": bson.M{"password_hash": passwordHash, "update_at": clock.NowUnixNano()}},
+ )
+ if err != nil {
+ return err
+ }
+ if res.MatchedCount == 0 {
+ return app.For(code.Member).ResNotFound("member not found")
+ }
+ return nil
+}
+
func (r *mongoRepository) findOne(ctx context.Context, filter bson.M) (*entity.Member, error) {
if r.collection == nil {
return nil, app.For(code.Member).DBUnavailable("Mongo is not configured")
diff --git a/backend/internal/model/mention_inbox/domain/entity/mention.go b/backend/internal/model/mention_inbox/domain/entity/mention.go
new file mode 100644
index 0000000..71042fb
--- /dev/null
+++ b/backend/internal/model/mention_inbox/domain/entity/mention.go
@@ -0,0 +1,32 @@
+package entity
+
+const CollectionName = "threads_mention_inbox"
+
+type Mention struct {
+ ID string `bson:"_id"`
+ TenantID string `bson:"tenant_id"`
+ OwnerUID string `bson:"owner_uid"`
+ AccountID string `bson:"account_id"`
+ MediaID string `bson:"media_id"`
+ AuthorUsername string `bson:"author_username"`
+ Text string `bson:"text"`
+ Permalink string `bson:"permalink"`
+ Shortcode string `bson:"shortcode,omitempty"`
+ Timestamp string `bson:"timestamp,omitempty"`
+ MediaType string `bson:"media_type,omitempty"`
+ IsReply bool `bson:"is_reply"`
+ IsQuotePost bool `bson:"is_quote_post"`
+ HasReplies bool `bson:"has_replies"`
+ RootPostID string `bson:"root_post_id,omitempty"`
+ ParentID string `bson:"parent_id,omitempty"`
+ ThreadPostText string `bson:"thread_post_text,omitempty"`
+ ParentReplyText string `bson:"parent_reply_text,omitempty"`
+ ReplyReady bool `bson:"reply_ready"`
+ ReplyReadyMsg string `bson:"reply_ready_msg,omitempty"`
+ SyncedAt int64 `bson:"synced_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
+
+func DocID(accountID, mediaID string) string {
+ return accountID + ":" + mediaID
+}
diff --git a/backend/internal/model/mention_inbox/domain/repository/repository.go b/backend/internal/model/mention_inbox/domain/repository/repository.go
new file mode 100644
index 0000000..902a05f
--- /dev/null
+++ b/backend/internal/model/mention_inbox/domain/repository/repository.go
@@ -0,0 +1,23 @@
+package repository
+
+import (
+ "context"
+
+ "haixun-backend/internal/model/mention_inbox/domain/entity"
+)
+
+type ListResult struct {
+ Items []entity.Mention
+ Total int64
+ ReadyTotal int64
+ NewestSyncedAt int64
+}
+
+type Repository interface {
+ EnsureIndexes(ctx context.Context) error
+ Upsert(ctx context.Context, item *entity.Mention) (*entity.Mention, error)
+ Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Mention, error)
+ CountByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error)
+ CountReplyReadyByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error)
+ ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*ListResult, error)
+}
diff --git a/backend/internal/model/mention_inbox/domain/usecase/usecase.go b/backend/internal/model/mention_inbox/domain/usecase/usecase.go
new file mode 100644
index 0000000..a5a8be4
--- /dev/null
+++ b/backend/internal/model/mention_inbox/domain/usecase/usecase.go
@@ -0,0 +1,78 @@
+package usecase
+
+import "context"
+
+type MentionSummary struct {
+ MediaID string
+ AuthorUsername string
+ Text string
+ Permalink string
+ Shortcode string
+ Timestamp string
+ MediaType string
+ IsReply bool
+ IsQuotePost bool
+ HasReplies bool
+ RootPostID string
+ ParentID string
+ ThreadPostText string
+ ParentReplyText string
+ ReplyReady bool
+ ReplyReadyMsg string
+ SyncedAt int64
+}
+
+type Pagination struct {
+ Total int64
+ ReadyTotal int64
+ Page int64
+ PageSize int64
+ TotalPages int64
+ NewestSyncedAt int64
+}
+
+type SyncRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ Limit int
+ MaxPages int
+}
+
+type SyncResult struct {
+ Synced int
+ Ready int
+ Total int64
+}
+
+type ListRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ Page int
+ PageSize int
+}
+
+type ListResult struct {
+ List []MentionSummary
+ Pagination Pagination
+}
+
+type PublishReplyRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ MediaID string
+ Text string
+}
+
+type PublishReplyResult struct {
+ MediaID string
+ Permalink string
+}
+
+type UseCase interface {
+ SyncFromAPI(ctx context.Context, req SyncRequest) (*SyncResult, error)
+ List(ctx context.Context, req ListRequest) (*ListResult, error)
+ PublishReply(ctx context.Context, req PublishReplyRequest) (*PublishReplyResult, error)
+}
diff --git a/backend/internal/model/mention_inbox/repository/mongo.go b/backend/internal/model/mention_inbox/repository/mongo.go
new file mode 100644
index 0000000..a639a80
--- /dev/null
+++ b/backend/internal/model/mention_inbox/repository/mongo.go
@@ -0,0 +1,184 @@
+package repository
+
+import (
+ "context"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/mention_inbox/domain/entity"
+ domrepo "haixun-backend/internal/model/mention_inbox/domain/repository"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+type mongoRepository struct {
+ collection *mongo.Collection
+}
+
+func NewMongoRepository(db *mongo.Database) domrepo.Repository {
+ if db == nil {
+ return &mongoRepository{}
+ }
+ return &mongoRepository{collection: db.Collection(entity.CollectionName)}
+}
+
+func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
+ if r.collection == nil {
+ return nil
+ }
+ _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
+ {
+ Keys: bson.D{
+ {Key: "tenant_id", Value: 1},
+ {Key: "owner_uid", Value: 1},
+ {Key: "account_id", Value: 1},
+ {Key: "media_id", Value: 1},
+ },
+ Options: options.Index().SetUnique(true),
+ },
+ {
+ Keys: bson.D{
+ {Key: "tenant_id", Value: 1},
+ {Key: "owner_uid", Value: 1},
+ {Key: "account_id", Value: 1},
+ {Key: "synced_at", Value: -1},
+ },
+ },
+ {
+ Keys: bson.D{
+ {Key: "tenant_id", Value: 1},
+ {Key: "owner_uid", Value: 1},
+ {Key: "account_id", Value: 1},
+ {Key: "timestamp", Value: -1},
+ },
+ },
+ })
+ return err
+}
+
+func (r *mongoRepository) Upsert(ctx context.Context, item *entity.Mention) (*entity.Mention, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ if item == nil {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("mention is required")
+ }
+ filter := actorMediaFilter(item.TenantID, item.OwnerUID, item.AccountID, item.MediaID)
+ opts := options.Replace().SetUpsert(true)
+ if _, err := r.collection.ReplaceOne(ctx, filter, item, opts); err != nil {
+ return nil, err
+ }
+ return item, nil
+}
+
+func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Mention, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ var out entity.Mention
+ err := r.collection.FindOne(ctx, actorMediaFilter(tenantID, ownerUID, accountID, mediaID)).Decode(&out)
+ if err != nil {
+ if err == mongo.ErrNoDocuments {
+ return nil, app.For(code.ThreadsAccount).ResNotFound("mention inbox item not found")
+ }
+ return nil, err
+ }
+ return &out, nil
+}
+
+func normalizeMentionPage(page, pageSize int) (int, int) {
+ if page <= 0 {
+ page = 1
+ }
+ if pageSize <= 0 {
+ pageSize = 10
+ }
+ if pageSize > 50 {
+ pageSize = 50
+ }
+ return page, pageSize
+}
+
+func accountFilter(tenantID, ownerUID, accountID string) bson.M {
+ return bson.M{
+ "tenant_id": tenantID,
+ "owner_uid": ownerUID,
+ "account_id": accountID,
+ }
+}
+
+func (r *mongoRepository) CountByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) {
+ if r.collection == nil {
+ return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ return r.collection.CountDocuments(ctx, accountFilter(tenantID, ownerUID, accountID))
+}
+
+func (r *mongoRepository) CountReplyReadyByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) {
+ if r.collection == nil {
+ return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ filter := accountFilter(tenantID, ownerUID, accountID)
+ filter["reply_ready"] = true
+ return r.collection.CountDocuments(ctx, filter)
+}
+
+func (r *mongoRepository) ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*domrepo.ListResult, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ page, pageSize = normalizeMentionPage(page, pageSize)
+ filter := accountFilter(tenantID, ownerUID, accountID)
+ total, err := r.collection.CountDocuments(ctx, filter)
+ if err != nil {
+ return nil, err
+ }
+ sort := bson.D{{Key: "timestamp", Value: -1}, {Key: "synced_at", Value: -1}}
+ opts := options.Find().
+ SetSort(sort).
+ SetSkip(int64((page - 1) * pageSize)).
+ SetLimit(int64(pageSize))
+ cur, err := r.collection.Find(ctx, filter, opts)
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ out := make([]entity.Mention, 0, pageSize)
+ for cur.Next(ctx) {
+ var item entity.Mention
+ if err := cur.Decode(&item); err != nil {
+ return nil, err
+ }
+ out = append(out, item)
+ }
+ if err := cur.Err(); err != nil {
+ return nil, err
+ }
+ var newest entity.Mention
+ newestErr := r.collection.FindOne(ctx, filter, options.FindOne().SetSort(bson.D{{Key: "synced_at", Value: -1}})).Decode(&newest)
+ newestSyncedAt := int64(0)
+ if newestErr == nil {
+ newestSyncedAt = newest.SyncedAt
+ }
+ readyTotal, err := r.CountReplyReadyByAccount(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ return &domrepo.ListResult{
+ Items: out,
+ Total: total,
+ ReadyTotal: readyTotal,
+ NewestSyncedAt: newestSyncedAt,
+ }, nil
+}
+
+func actorMediaFilter(tenantID, ownerUID, accountID, mediaID string) bson.M {
+ return bson.M{
+ "tenant_id": tenantID,
+ "owner_uid": ownerUID,
+ "account_id": accountID,
+ "media_id": mediaID,
+ }
+}
diff --git a/backend/internal/model/mention_inbox/usecase/usecase.go b/backend/internal/model/mention_inbox/usecase/usecase.go
new file mode 100644
index 0000000..8ae69c0
--- /dev/null
+++ b/backend/internal/model/mention_inbox/usecase/usecase.go
@@ -0,0 +1,347 @@
+package usecase
+
+import (
+ "context"
+ "strings"
+
+ "haixun-backend/internal/library/clock"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ libthreads "haixun-backend/internal/library/threadsapi"
+ "haixun-backend/internal/model/mention_inbox/domain/entity"
+ domrepo "haixun-backend/internal/model/mention_inbox/domain/repository"
+ domusecase "haixun-backend/internal/model/mention_inbox/domain/usecase"
+ threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
+)
+
+type mentionInboxUseCase struct {
+ repo domrepo.Repository
+ threadsAccounts threadsaccountdomain.UseCase
+}
+
+func NewUseCase(repo domrepo.Repository, threadsAccounts threadsaccountdomain.UseCase) domusecase.UseCase {
+ return &mentionInboxUseCase{repo: repo, threadsAccounts: threadsAccounts}
+}
+
+func (u *mentionInboxUseCase) SyncFromAPI(ctx context.Context, req domusecase.SyncRequest) (*domusecase.SyncResult, error) {
+ tenantID := strings.TrimSpace(req.TenantID)
+ ownerUID := strings.TrimSpace(req.OwnerUID)
+ accountID := strings.TrimSpace(req.AccountID)
+ if tenantID == "" || ownerUID == "" {
+ return nil, app.For(code.Auth).AuthUnauthorized("missing actor")
+ }
+ if accountID == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
+ }
+
+ account, err := u.threadsAccounts.Get(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ token, err := u.threadsAccounts.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ client := libthreads.NewClient(token)
+ if !client.Enabled() {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API 未連線")
+ }
+
+ userID := strings.TrimSpace(account.ThreadsUserID)
+ if userID == "" {
+ if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil {
+ userID = profile.ID.String()
+ }
+ }
+ if userID == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("缺少 threads_user_id")
+ }
+
+ perPage := req.Limit
+ if perPage <= 0 {
+ perPage = 25
+ }
+ if perPage > 100 {
+ perPage = 100
+ }
+ maxPages := req.MaxPages
+ if maxPages <= 0 {
+ maxPages = 4
+ }
+ if maxPages > 20 {
+ maxPages = 20
+ }
+
+ now := clock.NowUnixNano()
+ synced := 0
+ ready := 0
+ after := ""
+ for page := 0; page < maxPages; page++ {
+ mentions, nextAfter, err := client.UserMentionsPage(ctx, userID, perPage, after)
+ if err != nil {
+ return nil, err
+ }
+ for _, hit := range mentions {
+ item := u.buildMentionEntity(tenantID, ownerUID, accountID, hit, now)
+ u.fillContext(ctx, client, hit, item)
+ u.primeReplyTarget(ctx, client, hit, item)
+ if _, err := u.repo.Upsert(ctx, item); err != nil {
+ return nil, err
+ }
+ synced++
+ if item.ReplyReady {
+ ready++
+ }
+ }
+ if nextAfter == "" || len(mentions) == 0 {
+ break
+ }
+ after = nextAfter
+ }
+
+ total, err := u.repo.CountByAccount(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+
+ return &domusecase.SyncResult{
+ Synced: synced,
+ Ready: ready,
+ Total: total,
+ }, nil
+}
+
+func (u *mentionInboxUseCase) List(ctx context.Context, req domusecase.ListRequest) (*domusecase.ListResult, error) {
+ tenantID := strings.TrimSpace(req.TenantID)
+ ownerUID := strings.TrimSpace(req.OwnerUID)
+ accountID := strings.TrimSpace(req.AccountID)
+ if tenantID == "" || ownerUID == "" {
+ return nil, app.For(code.Auth).AuthUnauthorized("missing actor")
+ }
+ if accountID == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
+ }
+ if _, err := u.threadsAccounts.Get(ctx, tenantID, ownerUID, accountID); err != nil {
+ return nil, err
+ }
+ result, err := u.repo.ListByAccount(ctx, tenantID, ownerUID, accountID, req.Page, req.PageSize)
+ if err != nil {
+ return nil, err
+ }
+ page, pageSize := normalizeListPage(req.Page, req.PageSize)
+ return &domusecase.ListResult{
+ List: toSummaries(result.Items),
+ Pagination: domusecase.Pagination{
+ Total: result.Total,
+ ReadyTotal: result.ReadyTotal,
+ Page: int64(page),
+ PageSize: int64(pageSize),
+ TotalPages: mentionTotalPages(result.Total, pageSize),
+ NewestSyncedAt: result.NewestSyncedAt,
+ },
+ }, nil
+}
+
+func normalizeListPage(page, pageSize int) (int, int) {
+ if page <= 0 {
+ page = 1
+ }
+ if pageSize <= 0 {
+ pageSize = 10
+ }
+ if pageSize > 50 {
+ pageSize = 50
+ }
+ return page, pageSize
+}
+
+func mentionTotalPages(total int64, pageSize int) int64 {
+ if total <= 0 {
+ return 0
+ }
+ return (total + int64(pageSize) - 1) / int64(pageSize)
+}
+
+func (u *mentionInboxUseCase) PublishReply(ctx context.Context, req domusecase.PublishReplyRequest) (*domusecase.PublishReplyResult, error) {
+ tenantID := strings.TrimSpace(req.TenantID)
+ ownerUID := strings.TrimSpace(req.OwnerUID)
+ accountID := strings.TrimSpace(req.AccountID)
+ mediaID := strings.TrimSpace(req.MediaID)
+ text := strings.TrimSpace(req.Text)
+ if tenantID == "" || ownerUID == "" {
+ return nil, app.For(code.Auth).AuthUnauthorized("missing actor")
+ }
+ if accountID == "" || mediaID == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id and media_id are required")
+ }
+ if text == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("text is required")
+ }
+
+ item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, mediaID)
+ if err != nil {
+ return nil, err
+ }
+ if !item.ReplyReady {
+ msg := strings.TrimSpace(item.ReplyReadyMsg)
+ if msg == "" {
+ msg = "此提及尚未完成回覆註冊,請先重新同步提及清單"
+ }
+ return nil, app.For(code.ThreadsAccount).InputInvalidFormat(msg)
+ }
+
+ account, err := u.threadsAccounts.Get(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ token, err := u.threadsAccounts.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ userID := strings.TrimSpace(account.ThreadsUserID)
+ if userID == "" {
+ if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil {
+ userID = profile.ID.String()
+ }
+ }
+ if userID == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("缺少 threads_user_id")
+ }
+
+ result, err := libthreads.PublishReply(ctx, libthreads.PublishReplyInput{
+ ThreadsUserID: userID,
+ AccessToken: token,
+ ReplyToID: mediaID,
+ Text: text,
+ })
+ if err != nil {
+ return nil, err
+ }
+ out := &domusecase.PublishReplyResult{}
+ if result != nil {
+ out.MediaID = strings.TrimSpace(result.MediaID)
+ out.Permalink = strings.TrimSpace(result.Permalink)
+ }
+ return out, nil
+}
+
+func (u *mentionInboxUseCase) buildMentionEntity(
+ tenantID, ownerUID, accountID string,
+ hit libthreads.MentionItem,
+ now int64,
+) *entity.Mention {
+ mediaID := strings.TrimSpace(hit.ID)
+ return &entity.Mention{
+ ID: entity.DocID(accountID, mediaID),
+ TenantID: tenantID,
+ OwnerUID: ownerUID,
+ AccountID: accountID,
+ MediaID: mediaID,
+ AuthorUsername: strings.TrimSpace(hit.Username),
+ Text: strings.TrimSpace(hit.Text),
+ Permalink: strings.TrimSpace(hit.Permalink),
+ Shortcode: shortcodeFromPermalink(hit.Permalink),
+ Timestamp: strings.TrimSpace(hit.Timestamp),
+ MediaType: strings.TrimSpace(hit.MediaType),
+ IsReply: hit.IsReply,
+ IsQuotePost: hit.IsQuotePost,
+ HasReplies: hit.HasReplies,
+ RootPostID: strings.TrimSpace(hit.RootPostID),
+ ParentID: strings.TrimSpace(hit.ParentID),
+ SyncedAt: now,
+ UpdateAt: now,
+ }
+}
+
+func (u *mentionInboxUseCase) fillContext(ctx context.Context, client *libthreads.Client, hit libthreads.MentionItem, item *entity.Mention) {
+ mentionText := strings.TrimSpace(hit.Text)
+ if !hit.IsReply && !hit.IsQuotePost {
+ item.ThreadPostText = mentionText
+ return
+ }
+
+ rootID := strings.TrimSpace(hit.RootPostID)
+ if rootID == "" {
+ rootID = strings.TrimSpace(hit.ID)
+ }
+ if rootID != "" {
+ if detail, err := client.GetMediaDetail(ctx, rootID); err == nil && detail != nil {
+ item.ThreadPostText = strings.TrimSpace(detail.Text)
+ }
+ }
+ if item.ThreadPostText == "" {
+ item.ThreadPostText = mentionText
+ }
+
+ parentID := strings.TrimSpace(hit.ParentID)
+ mediaID := strings.TrimSpace(hit.ID)
+ if hit.IsReply && parentID != "" && parentID != mediaID && parentID != rootID {
+ if detail, err := client.GetMediaDetail(ctx, parentID); err == nil && detail != nil {
+ item.ParentReplyText = strings.TrimSpace(detail.Text)
+ }
+ }
+}
+
+func (u *mentionInboxUseCase) primeReplyTarget(ctx context.Context, client *libthreads.Client, hit libthreads.MentionItem, item *entity.Mention) {
+ permalink := strings.TrimSpace(hit.Permalink)
+ mediaID := strings.TrimSpace(hit.ID)
+ if permalink == "" {
+ item.ReplyReady = false
+ item.ReplyReadyMsg = "缺少 permalink,無法註冊回覆目標"
+ return
+ }
+ if err := client.PrimeReplyTargetForPublish(ctx, libthreads.ResolveReplyMediaInput{
+ ExternalID: mediaID,
+ Permalink: permalink,
+ HintText: strings.TrimSpace(hit.Text),
+ }, mediaID); err != nil {
+ item.ReplyReady = false
+ item.ReplyReadyMsg = err.Error()
+ return
+ }
+ item.ReplyReady = true
+ item.ReplyReadyMsg = ""
+}
+
+func shortcodeFromPermalink(permalink string) string {
+ permalink = strings.Split(strings.TrimSpace(permalink), "?")[0]
+ permalink = strings.Split(permalink, "#")[0]
+ parts := strings.Split(permalink, "/post/")
+ if len(parts) < 2 {
+ return ""
+ }
+ return strings.TrimSpace(parts[len(parts)-1])
+}
+
+func toSummary(item *entity.Mention) domusecase.MentionSummary {
+ if item == nil {
+ return domusecase.MentionSummary{}
+ }
+ return domusecase.MentionSummary{
+ MediaID: item.MediaID,
+ AuthorUsername: item.AuthorUsername,
+ Text: item.Text,
+ Permalink: item.Permalink,
+ Shortcode: item.Shortcode,
+ Timestamp: item.Timestamp,
+ MediaType: item.MediaType,
+ IsReply: item.IsReply,
+ IsQuotePost: item.IsQuotePost,
+ HasReplies: item.HasReplies,
+ RootPostID: item.RootPostID,
+ ParentID: item.ParentID,
+ ThreadPostText: item.ThreadPostText,
+ ParentReplyText: item.ParentReplyText,
+ ReplyReady: item.ReplyReady,
+ ReplyReadyMsg: item.ReplyReadyMsg,
+ SyncedAt: item.SyncedAt,
+ }
+}
+
+func toSummaries(items []entity.Mention) []domusecase.MentionSummary {
+ out := make([]domusecase.MentionSummary, 0, len(items))
+ for i := range items {
+ out = append(out, toSummary(&items[i]))
+ }
+ return out
+}
diff --git a/backend/internal/model/outreach_draft/domain/repository/repository.go b/backend/internal/model/outreach_draft/domain/repository/repository.go
index 1a64c56..2023a3c 100644
--- a/backend/internal/model/outreach_draft/domain/repository/repository.go
+++ b/backend/internal/model/outreach_draft/domain/repository/repository.go
@@ -9,5 +9,7 @@ import (
type Repository interface {
EnsureIndexes(ctx context.Context) error
Create(ctx context.Context, draft *entity.OutreachDraft) error
+ GetByID(ctx context.Context, tenantID, ownerUID, brandID, draftID string) (*entity.OutreachDraft, error)
GetLatestByScanPost(ctx context.Context, tenantID, ownerUID, brandID, topicID, scanPostID string) (*entity.OutreachDraft, error)
+ UpdateDrafts(ctx context.Context, tenantID, ownerUID, brandID, draftID string, drafts []entity.DraftItem) (*entity.OutreachDraft, error)
}
diff --git a/backend/internal/model/outreach_draft/domain/usecase/usecase.go b/backend/internal/model/outreach_draft/domain/usecase/usecase.go
index 2892847..846eb85 100644
--- a/backend/internal/model/outreach_draft/domain/usecase/usecase.go
+++ b/backend/internal/model/outreach_draft/domain/usecase/usecase.go
@@ -31,7 +31,17 @@ type CreateRequest struct {
Drafts []DraftItem
}
+type UpdateDraftItemRequest struct {
+ TenantID string
+ OwnerUID string
+ BrandID string
+ DraftID string
+ DraftIndex int
+ Text string
+}
+
type UseCase interface {
Create(ctx context.Context, req CreateRequest) (*DraftSummary, error)
GetLatestByScanPost(ctx context.Context, tenantID, ownerUID, brandID, topicID, scanPostID string) (*DraftSummary, error)
+ UpdateDraftItem(ctx context.Context, req UpdateDraftItemRequest) (*DraftSummary, error)
}
diff --git a/backend/internal/model/outreach_draft/repository/mongo.go b/backend/internal/model/outreach_draft/repository/mongo.go
index 6a54553..badee67 100644
--- a/backend/internal/model/outreach_draft/repository/mongo.go
+++ b/backend/internal/model/outreach_draft/repository/mongo.go
@@ -84,6 +84,57 @@ func draftScopeFilter(tenantID, ownerUID, brandID, topicID string) bson.M {
return filter
}
+func (r *mongoRepository) GetByID(
+ ctx context.Context,
+ tenantID, ownerUID, brandID, draftID string,
+) (*entity.OutreachDraft, error) {
+ if r.collection == nil {
+ return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
+ }
+ draftID = strings.TrimSpace(draftID)
+ if draftID == "" {
+ return nil, app.For(code.Brand).InputMissingRequired("draft_id is required")
+ }
+ filter := brandOwnerFilter(tenantID, ownerUID, brandID)
+ filter["_id"] = draftID
+ var out entity.OutreachDraft
+ err := r.collection.FindOne(ctx, filter).Decode(&out)
+ if err == mongo.ErrNoDocuments {
+ return nil, app.For(code.Brand).ResNotFound("outreach draft not found")
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+func (r *mongoRepository) UpdateDrafts(
+ ctx context.Context,
+ tenantID, ownerUID, brandID, draftID string,
+ drafts []entity.DraftItem,
+) (*entity.OutreachDraft, error) {
+ if r.collection == nil {
+ return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
+ }
+ draftID = strings.TrimSpace(draftID)
+ if draftID == "" {
+ return nil, app.For(code.Brand).InputMissingRequired("draft_id is required")
+ }
+ filter := brandOwnerFilter(tenantID, ownerUID, brandID)
+ filter["_id"] = draftID
+ update := bson.M{"$set": bson.M{"drafts": drafts}}
+ opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
+ var out entity.OutreachDraft
+ err := r.collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&out)
+ if err == mongo.ErrNoDocuments {
+ return nil, app.For(code.Brand).ResNotFound("outreach draft not found")
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
func (r *mongoRepository) GetLatestByScanPost(
ctx context.Context,
tenantID, ownerUID, brandID, topicID, scanPostID string,
diff --git a/backend/internal/model/outreach_draft/usecase/errors.go b/backend/internal/model/outreach_draft/usecase/errors.go
index 75da245..be64b44 100644
--- a/backend/internal/model/outreach_draft/usecase/errors.go
+++ b/backend/internal/model/outreach_draft/usecase/errors.go
@@ -16,3 +16,19 @@ func errMissingBrand() error {
func errMissingScanPost() error {
return app.For(code.Brand).InputMissingRequired("scan_post_id is required")
}
+
+func errMissingDraftID() error {
+ return app.For(code.Brand).InputMissingRequired("draft_id is required")
+}
+
+func errMissingDraftText() error {
+ return app.For(code.Brand).InputMissingRequired("draft text cannot be empty")
+}
+
+func errDraftNotFound() error {
+ return app.For(code.Brand).ResNotFound("outreach draft not found")
+}
+
+func errInvalidDraftIndex() error {
+ return app.For(code.Brand).InputInvalidFormat("draft_index out of range")
+}
diff --git a/backend/internal/model/outreach_draft/usecase/usecase.go b/backend/internal/model/outreach_draft/usecase/usecase.go
index 02175eb..bdf7b47 100644
--- a/backend/internal/model/outreach_draft/usecase/usecase.go
+++ b/backend/internal/model/outreach_draft/usecase/usecase.go
@@ -56,6 +56,39 @@ func (u *outreachDraftUseCase) Create(ctx context.Context, req domusecase.Create
return toSummary(record), nil
}
+func (u *outreachDraftUseCase) UpdateDraftItem(
+ ctx context.Context,
+ req domusecase.UpdateDraftItemRequest,
+) (*domusecase.DraftSummary, error) {
+ if err := requireActor(req.TenantID, req.OwnerUID, req.BrandID); err != nil {
+ return nil, err
+ }
+ draftID := strings.TrimSpace(req.DraftID)
+ if draftID == "" {
+ return nil, errMissingDraftID()
+ }
+ text := strings.TrimSpace(req.Text)
+ if text == "" {
+ return nil, errMissingDraftText()
+ }
+ record, err := u.repo.GetByID(ctx, req.TenantID, req.OwnerUID, req.BrandID, draftID)
+ if err != nil {
+ return nil, err
+ }
+ if record == nil || len(record.Drafts) == 0 {
+ return nil, errDraftNotFound()
+ }
+ if req.DraftIndex < 0 || req.DraftIndex >= len(record.Drafts) {
+ return nil, errInvalidDraftIndex()
+ }
+ record.Drafts[req.DraftIndex].Text = text
+ updated, err := u.repo.UpdateDrafts(ctx, req.TenantID, req.OwnerUID, req.BrandID, draftID, record.Drafts)
+ if err != nil {
+ return nil, err
+ }
+ return toSummary(updated), nil
+}
+
func (u *outreachDraftUseCase) GetLatestByScanPost(
ctx context.Context,
tenantID, ownerUID, brandID, topicID, scanPostID string,
diff --git a/backend/internal/model/own_post_formula/domain/entity/review.go b/backend/internal/model/own_post_formula/domain/entity/review.go
new file mode 100644
index 0000000..b6376c5
--- /dev/null
+++ b/backend/internal/model/own_post_formula/domain/entity/review.go
@@ -0,0 +1,28 @@
+package entity
+
+const CollectionName = "own_post_formula_reviews"
+
+type Review struct {
+ ID string `bson:"_id"`
+ TenantID string `bson:"tenant_id"`
+ OwnerUID string `bson:"owner_uid"`
+ AccountID string `bson:"account_id"`
+ MediaID string `bson:"media_id"`
+ PersonaID string `bson:"persona_id,omitempty"`
+ PostText string `bson:"post_text,omitempty"`
+ Summary string `bson:"summary"`
+ Wins []string `bson:"wins,omitempty"`
+ Improvements []string `bson:"improvements,omitempty"`
+ Formula string `bson:"formula"`
+ PostTemplate string `bson:"post_template"`
+ HookPattern string `bson:"hook_pattern"`
+ Structure string `bson:"structure"`
+ ReplicationTips []string `bson:"replication_tips,omitempty"`
+ Avoid []string `bson:"avoid,omitempty"`
+ CreateAt int64 `bson:"create_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
+
+func ReviewDocID(accountID, mediaID string) string {
+ return accountID + ":" + mediaID
+}
diff --git a/backend/internal/model/own_post_formula/domain/repository/repository.go b/backend/internal/model/own_post_formula/domain/repository/repository.go
new file mode 100644
index 0000000..7b1601e
--- /dev/null
+++ b/backend/internal/model/own_post_formula/domain/repository/repository.go
@@ -0,0 +1,14 @@
+package repository
+
+import (
+ "context"
+
+ "haixun-backend/internal/model/own_post_formula/domain/entity"
+)
+
+type Repository interface {
+ EnsureIndexes(ctx context.Context) error
+ Upsert(ctx context.Context, review *entity.Review) (*entity.Review, error)
+ Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Review, error)
+ ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]entity.Review, error)
+}
diff --git a/backend/internal/model/own_post_formula/repository/mongo.go b/backend/internal/model/own_post_formula/repository/mongo.go
new file mode 100644
index 0000000..4c60838
--- /dev/null
+++ b/backend/internal/model/own_post_formula/repository/mongo.go
@@ -0,0 +1,115 @@
+package repository
+
+import (
+ "context"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/own_post_formula/domain/entity"
+ domrepo "haixun-backend/internal/model/own_post_formula/domain/repository"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+type mongoRepository struct {
+ collection *mongo.Collection
+}
+
+func NewMongoRepository(db *mongo.Database) domrepo.Repository {
+ if db == nil {
+ return &mongoRepository{}
+ }
+ return &mongoRepository{collection: db.Collection(entity.CollectionName)}
+}
+
+func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
+ if r.collection == nil {
+ return nil
+ }
+ _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
+ {
+ Keys: bson.D{
+ {Key: "tenant_id", Value: 1},
+ {Key: "owner_uid", Value: 1},
+ {Key: "account_id", Value: 1},
+ {Key: "media_id", Value: 1},
+ },
+ Options: options.Index().SetUnique(true),
+ },
+ {
+ Keys: bson.D{
+ {Key: "tenant_id", Value: 1},
+ {Key: "owner_uid", Value: 1},
+ {Key: "account_id", Value: 1},
+ {Key: "update_at", Value: -1},
+ },
+ },
+ })
+ return err
+}
+
+func (r *mongoRepository) Upsert(ctx context.Context, review *entity.Review) (*entity.Review, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ if review == nil {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("review is required")
+ }
+ filter := actorMediaFilter(review.TenantID, review.OwnerUID, review.AccountID, review.MediaID)
+ opts := options.Replace().SetUpsert(true)
+ if _, err := r.collection.ReplaceOne(ctx, filter, review, opts); err != nil {
+ return nil, err
+ }
+ return review, nil
+}
+
+func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Review, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ var out entity.Review
+ err := r.collection.FindOne(ctx, actorMediaFilter(tenantID, ownerUID, accountID, mediaID)).Decode(&out)
+ if err != nil {
+ if err == mongo.ErrNoDocuments {
+ return nil, app.For(code.ThreadsAccount).ResNotFound("own post formula review not found")
+ }
+ return nil, err
+ }
+ return &out, nil
+}
+
+func (r *mongoRepository) ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]entity.Review, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ cur, err := r.collection.Find(
+ ctx,
+ bson.M{
+ "tenant_id": strings.TrimSpace(tenantID),
+ "owner_uid": strings.TrimSpace(ownerUID),
+ "account_id": strings.TrimSpace(accountID),
+ },
+ options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}}),
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ var out []entity.Review
+ if err := cur.All(ctx, &out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func actorMediaFilter(tenantID, ownerUID, accountID, mediaID string) bson.M {
+ return bson.M{
+ "tenant_id": strings.TrimSpace(tenantID),
+ "owner_uid": strings.TrimSpace(ownerUID),
+ "account_id": strings.TrimSpace(accountID),
+ "media_id": strings.TrimSpace(mediaID),
+ }
+}
diff --git a/backend/internal/model/own_post_formula/usecase/usecase.go b/backend/internal/model/own_post_formula/usecase/usecase.go
new file mode 100644
index 0000000..eb9863b
--- /dev/null
+++ b/backend/internal/model/own_post_formula/usecase/usecase.go
@@ -0,0 +1,93 @@
+package usecase
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "haixun-backend/internal/library/clock"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ libownpost "haixun-backend/internal/library/ownpost"
+ "haixun-backend/internal/model/own_post_formula/domain/entity"
+ domrepo "haixun-backend/internal/model/own_post_formula/domain/repository"
+)
+
+const FormulaCacheTTL = 5 * time.Minute
+
+type UseCase interface {
+ Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Review, error)
+ SaveGenerated(ctx context.Context, tenantID, ownerUID, accountID, mediaID, personaID, postText string, review *libownpost.PostFormulaReview) (*entity.Review, error)
+ ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]entity.Review, error)
+ IsFresh(review *entity.Review, now int64) bool
+}
+
+func (u *useCase) Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Review, error) {
+ return u.repo.Get(ctx, tenantID, ownerUID, accountID, mediaID)
+}
+
+func (u *useCase) IsFresh(review *entity.Review, now int64) bool {
+ if review == nil || review.UpdateAt <= 0 {
+ return false
+ }
+ if now <= 0 {
+ now = clock.NowUnixNano()
+ }
+ return now-review.UpdateAt < int64(FormulaCacheTTL)
+}
+
+type useCase struct {
+ repo domrepo.Repository
+}
+
+func NewUseCase(repo domrepo.Repository) UseCase {
+ return &useCase{repo: repo}
+}
+
+func (u *useCase) SaveGenerated(
+ ctx context.Context,
+ tenantID, ownerUID, accountID, mediaID, personaID, postText string,
+ review *libownpost.PostFormulaReview,
+) (*entity.Review, error) {
+ if review == nil {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("review is required")
+ }
+ accountID = strings.TrimSpace(accountID)
+ mediaID = strings.TrimSpace(mediaID)
+ if accountID == "" || mediaID == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id and media_id are required")
+ }
+ now := clock.NowUnixNano()
+ doc := &entity.Review{
+ ID: entity.ReviewDocID(accountID, mediaID),
+ TenantID: strings.TrimSpace(tenantID),
+ OwnerUID: strings.TrimSpace(ownerUID),
+ AccountID: accountID,
+ MediaID: mediaID,
+ PersonaID: strings.TrimSpace(personaID),
+ PostText: strings.TrimSpace(postText),
+ Summary: review.Summary,
+ Wins: review.Wins,
+ Improvements: review.Improvements,
+ Formula: review.Formula,
+ PostTemplate: review.PostTemplate,
+ HookPattern: review.HookPattern,
+ Structure: review.Structure,
+ ReplicationTips: review.ReplicationTips,
+ Avoid: review.Avoid,
+ CreateAt: now,
+ UpdateAt: now,
+ }
+ if existing, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, mediaID); err == nil && existing != nil {
+ doc.CreateAt = existing.CreateAt
+ }
+ return u.repo.Upsert(ctx, doc)
+}
+
+func (u *useCase) ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]entity.Review, error) {
+ accountID = strings.TrimSpace(accountID)
+ if accountID == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
+ }
+ return u.repo.ListByAccount(ctx, tenantID, ownerUID, accountID)
+}
diff --git a/backend/internal/model/permission/usecase/usecase.go b/backend/internal/model/permission/usecase/usecase.go
index 8fa0cd8..ad73322 100644
--- a/backend/internal/model/permission/usecase/usecase.go
+++ b/backend/internal/model/permission/usecase/usecase.go
@@ -95,14 +95,14 @@ func (u *permissionUseCase) Me(ctx context.Context, member *memberentity.Member,
if err != nil {
return nil, err
}
- if len(perms) == 0 {
- perms, err = u.permissions.List(ctx, domrepo.PermissionFilter{Status: entity.StatusOpen})
- if err != nil {
- return nil, err
- }
- if !hasRole(roles, "admin") {
- perms = filterPermissionsByName(perms, defaultUserPermissionNames())
- }
+ openPerms, err := u.permissions.List(ctx, domrepo.PermissionFilter{Status: entity.StatusOpen})
+ if err != nil {
+ return nil, err
+ }
+ if hasRole(roles, "admin") {
+ perms = mergePermissions(perms, openPerms)
+ } else {
+ perms = mergePermissions(perms, filterPermissionsByName(openPerms, defaultUserPermissionNames()))
}
nodes := permissionNodes(perms)
result := &domusecase.MePermissions{
@@ -169,6 +169,28 @@ func filterPermissionsByName(items []*entity.Permission, names []string) []*enti
return out
}
+func mergePermissions(base []*entity.Permission, extras []*entity.Permission) []*entity.Permission {
+ seen := map[string]struct{}{}
+ out := make([]*entity.Permission, 0, len(base)+len(extras))
+ for _, group := range [][]*entity.Permission{base, extras} {
+ for _, item := range group {
+ if item == nil {
+ continue
+ }
+ key := item.Name
+ if key == "" {
+ key = item.ID.Hex()
+ }
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ out = append(out, item)
+ }
+ }
+ return out
+}
+
func defaultUserPermissionNames() []string {
return []string{
"auth.logout",
@@ -251,7 +273,7 @@ func defaultPermissions() []*entity.Permission {
{Name: "brand.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/brands/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
{Name: "persona.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/personas/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
{Name: "placement_topic.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/placement/topics/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
- {Name: "threads_account.manage", HTTPMethods: "GET|POST|PUT|PATCH", HTTPPath: "/api/v1/threads-accounts/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
+ {Name: "threads_account.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/threads-accounts/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
{Name: "job.manage", HTTPMethods: "GET|POST", HTTPPath: "/api/v1/jobs/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
{Name: "job.schedule.manage", HTTPMethods: "GET|POST|PUT|DELETE", HTTPPath: "/api/v1/job/schedules/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
diff --git a/backend/internal/model/placement/usecase/settings.go b/backend/internal/model/placement/usecase/settings.go
index 1e7f423..fee05de 100644
--- a/backend/internal/model/placement/usecase/settings.go
+++ b/backend/internal/model/placement/usecase/settings.go
@@ -28,6 +28,7 @@ type Settings struct {
BraveSearchLang string
ExaUserLocation string
ExpandStrategy string
+ DevModeEnabled bool
}
type SettingsPatch struct {
@@ -38,6 +39,7 @@ type SettingsPatch struct {
BraveSearchLang *string
ExaUserLocation *string
ExpandStrategy *string
+ DevModeEnabled *bool
}
type UseCase interface {
@@ -164,6 +166,7 @@ type storedSettings struct {
BraveSearchLang string
ExaUserLocation string
ExpandStrategy string
+ DevModeEnabled bool
}
func defaultSettings() storedSettings {
@@ -201,6 +204,9 @@ func mergeSettings(defaults storedSettings, raw map[string]interface{}) storedSe
if v, ok := raw["expand_strategy"].(string); ok && strings.TrimSpace(v) != "" {
defaults.ExpandStrategy = string(libkg.ParseExpandStrategy(v))
}
+ if v, ok := raw["dev_mode_enabled"].(bool); ok {
+ defaults.DevModeEnabled = v
+ }
return defaults
}
@@ -232,6 +238,9 @@ func applyPatch(current storedSettings, patch SettingsPatch) storedSettings {
if patch.ExpandStrategy != nil && strings.TrimSpace(*patch.ExpandStrategy) != "" {
current.ExpandStrategy = string(libkg.ParseExpandStrategy(*patch.ExpandStrategy))
}
+ if patch.DevModeEnabled != nil {
+ current.DevModeEnabled = *patch.DevModeEnabled
+ }
return current
}
@@ -244,6 +253,7 @@ func (s storedSettings) toMap() map[string]interface{} {
"brave_search_lang": s.BraveSearchLang,
"exa_user_location": s.ExaUserLocation,
"expand_strategy": s.ExpandStrategy,
+ "dev_mode_enabled": s.DevModeEnabled,
}
}
@@ -267,6 +277,7 @@ func toPublic(stored storedSettings) *Settings {
BraveSearchLang: stored.BraveSearchLang,
ExaUserLocation: stored.ExaUserLocation,
ExpandStrategy: strategy,
+ DevModeEnabled: stored.DevModeEnabled,
}
}
diff --git a/backend/internal/model/placement/usecase/settings_dev_mode_test.go b/backend/internal/model/placement/usecase/settings_dev_mode_test.go
new file mode 100644
index 0000000..5dfb81d
--- /dev/null
+++ b/backend/internal/model/placement/usecase/settings_dev_mode_test.go
@@ -0,0 +1,28 @@
+package usecase
+
+import "testing"
+
+func TestApplyPatchDevModeEnabled(t *testing.T) {
+ enabled := true
+ disabled := false
+ current := defaultSettings()
+
+ next := applyPatch(current, SettingsPatch{DevModeEnabled: &enabled})
+ if !next.DevModeEnabled {
+ t.Fatal("expected dev_mode_enabled true")
+ }
+
+ next = applyPatch(next, SettingsPatch{DevModeEnabled: &disabled})
+ if next.DevModeEnabled {
+ t.Fatal("expected dev_mode_enabled false after toggle off")
+ }
+}
+
+func TestMergeSettingsDevModeEnabled(t *testing.T) {
+ merged := mergeSettings(defaultSettings(), map[string]interface{}{
+ "dev_mode_enabled": true,
+ })
+ if !merged.DevModeEnabled {
+ t.Fatal("expected merged dev_mode_enabled true")
+ }
+}
diff --git a/backend/internal/model/publish_analytics/domain/entity/analytics.go b/backend/internal/model/publish_analytics/domain/entity/analytics.go
index c290a0b..9e8e39d 100644
--- a/backend/internal/model/publish_analytics/domain/entity/analytics.go
+++ b/backend/internal/model/publish_analytics/domain/entity/analytics.go
@@ -16,6 +16,8 @@ type PublishAnalytics struct {
ReplyCount int `bson:"reply_count,omitempty"`
RepostCount int `bson:"repost_count,omitempty"`
QuoteCount int `bson:"quote_count,omitempty"`
+ ViewCount int `bson:"view_count,omitempty"`
+ ShareCount int `bson:"share_count,omitempty"`
ImpressionCount int `bson:"impression_count,omitempty"`
ReachCount int `bson:"reach_count,omitempty"`
CheckedAt int64 `bson:"checked_at"`
diff --git a/backend/internal/model/publish_analytics/domain/repository/repository.go b/backend/internal/model/publish_analytics/domain/repository/repository.go
index dcd2ced..b54dd32 100644
--- a/backend/internal/model/publish_analytics/domain/repository/repository.go
+++ b/backend/internal/model/publish_analytics/domain/repository/repository.go
@@ -12,4 +12,5 @@ type Repository interface {
Get(ctx context.Context, tenantID, ownerUID, analyticsID string) (*entity.PublishAnalytics, error)
ListByMedia(ctx context.Context, tenantID, ownerUID, mediaID string) ([]entity.PublishAnalytics, error)
Upsert(ctx context.Context, analytics *entity.PublishAnalytics) (*entity.PublishAnalytics, error)
+ DeleteByMediaIDs(ctx context.Context, tenantID, ownerUID string, mediaIDs []string) (int64, error)
}
diff --git a/backend/internal/model/publish_analytics/domain/usecase/usecase.go b/backend/internal/model/publish_analytics/domain/usecase/usecase.go
index 4a8f3f1..6331a48 100644
--- a/backend/internal/model/publish_analytics/domain/usecase/usecase.go
+++ b/backend/internal/model/publish_analytics/domain/usecase/usecase.go
@@ -12,13 +12,25 @@ type PublishAnalyticsSummary struct {
ReplyCount int
RepostCount int
QuoteCount int
+ ViewCount int
+ ShareCount int
ImpressionCount int
ReachCount int
CheckedAt int64
CreateAt int64
}
+type ScheduleCheckpointsRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ MediaID string
+ Permalink string
+ PublishedAt int64
+}
+
type UseCase interface {
RecordCheckpoint(ctx context.Context, tenantID, ownerUID string, summary PublishAnalyticsSummary) (*PublishAnalyticsSummary, error)
ListByMedia(ctx context.Context, tenantID, ownerUID, mediaID string) ([]PublishAnalyticsSummary, error)
+ ScheduleCheckpoints(ctx context.Context, req ScheduleCheckpointsRequest) error
}
diff --git a/backend/internal/model/publish_analytics/repository/mongo.go b/backend/internal/model/publish_analytics/repository/mongo.go
index 6a5862d..29d78ae 100644
--- a/backend/internal/model/publish_analytics/repository/mongo.go
+++ b/backend/internal/model/publish_analytics/repository/mongo.go
@@ -115,7 +115,9 @@ func (r *mongoRepository) Upsert(ctx context.Context, analytics *entity.PublishA
return nil, app.For(code.AI).InputMissingRequired("analytics id is required")
}
- filter := bson.M{"_id": analytics.ID}
+ filter := actorFilter(analytics.TenantID, analytics.OwnerUID)
+ filter["media_id"] = strings.TrimSpace(analytics.MediaID)
+ filter["checkpoint"] = strings.TrimSpace(analytics.Checkpoint)
opts := options.Replace().SetUpsert(true)
_, err := r.collection.ReplaceOne(ctx, filter, analytics, opts)
if err != nil {
@@ -123,3 +125,32 @@ func (r *mongoRepository) Upsert(ctx context.Context, analytics *entity.PublishA
}
return analytics, nil
}
+
+func (r *mongoRepository) DeleteByMediaIDs(ctx context.Context, tenantID, ownerUID string, mediaIDs []string) (int64, error) {
+ if r.collection == nil {
+ return 0, app.For(code.AI).DBUnavailable("Mongo is not configured")
+ }
+ ids := make([]string, 0, len(mediaIDs))
+ seen := map[string]struct{}{}
+ for _, mediaID := range mediaIDs {
+ trimmed := strings.TrimSpace(mediaID)
+ if trimmed == "" {
+ continue
+ }
+ if _, ok := seen[trimmed]; ok {
+ continue
+ }
+ seen[trimmed] = struct{}{}
+ ids = append(ids, trimmed)
+ }
+ if len(ids) == 0 {
+ return 0, nil
+ }
+ filter := actorFilter(tenantID, ownerUID)
+ filter["media_id"] = bson.M{"$in": ids}
+ res, err := r.collection.DeleteMany(ctx, filter)
+ if err != nil {
+ return 0, err
+ }
+ return res.DeletedCount, nil
+}
diff --git a/backend/internal/model/publish_analytics/usecase/schedule.go b/backend/internal/model/publish_analytics/usecase/schedule.go
new file mode 100644
index 0000000..3b33e06
--- /dev/null
+++ b/backend/internal/model/publish_analytics/usecase/schedule.go
@@ -0,0 +1,61 @@
+package usecase
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ jobdom "haixun-backend/internal/model/job/domain/usecase"
+ "haixun-backend/internal/model/publish_analytics/domain/entity"
+ domusecase "haixun-backend/internal/model/publish_analytics/domain/usecase"
+)
+
+const publishAnalyticsTemplateType = "publish-analytics"
+
+func (u *publishAnalyticsUseCase) ScheduleCheckpoints(ctx context.Context, req domusecase.ScheduleCheckpointsRequest) error {
+ if u.jobs == nil {
+ return nil
+ }
+ tenantID := strings.TrimSpace(req.TenantID)
+ ownerUID := strings.TrimSpace(req.OwnerUID)
+ accountID := strings.TrimSpace(req.AccountID)
+ mediaID := strings.TrimSpace(req.MediaID)
+ if tenantID == "" || ownerUID == "" || accountID == "" || mediaID == "" {
+ return nil
+ }
+ publishedAt := req.PublishedAt
+ if publishedAt <= 0 {
+ publishedAt = time.Now().UnixNano()
+ }
+ permalink := strings.TrimSpace(req.Permalink)
+ checkpoints := []struct {
+ name string
+ delay time.Duration
+ }{
+ {entity.Checkpoint1h, time.Hour},
+ {entity.Checkpoint24h, 24 * time.Hour},
+ {entity.Checkpoint7d, 7 * 24 * time.Hour},
+ }
+ for _, cp := range checkpoints {
+ at := publishedAt + cp.delay.Nanoseconds()
+ scheduled := at
+ _, _ = u.jobs.CreateRun(ctx, jobdom.CreateRunRequest{
+ TemplateType: publishAnalyticsTemplateType,
+ Scope: "threads_account",
+ ScopeID: accountID,
+ TenantID: tenantID,
+ OwnerUID: ownerUID,
+ ScheduledAt: &scheduled,
+ Payload: map[string]any{
+ "tenant_id": tenantID,
+ "owner_uid": ownerUID,
+ "account_id": accountID,
+ "media_id": mediaID,
+ "permalink": permalink,
+ "published_at": publishedAt,
+ "checkpoint": cp.name,
+ },
+ })
+ }
+ return nil
+}
diff --git a/backend/internal/model/publish_analytics/usecase/usecase.go b/backend/internal/model/publish_analytics/usecase/usecase.go
index fe8d3b3..aae8421 100644
--- a/backend/internal/model/publish_analytics/usecase/usecase.go
+++ b/backend/internal/model/publish_analytics/usecase/usecase.go
@@ -7,6 +7,7 @@ import (
"haixun-backend/internal/library/clock"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
+ jobdom "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/model/publish_analytics/domain/entity"
domrepo "haixun-backend/internal/model/publish_analytics/domain/repository"
domusecase "haixun-backend/internal/model/publish_analytics/domain/usecase"
@@ -16,10 +17,11 @@ import (
type publishAnalyticsUseCase struct {
repo domrepo.Repository
+ jobs jobdom.UseCase
}
-func NewUseCase(repo domrepo.Repository) domusecase.UseCase {
- return &publishAnalyticsUseCase{repo: repo}
+func NewUseCase(repo domrepo.Repository, jobs jobdom.UseCase) domusecase.UseCase {
+ return &publishAnalyticsUseCase{repo: repo, jobs: jobs}
}
func (u *publishAnalyticsUseCase) RecordCheckpoint(ctx context.Context, tenantID, ownerUID string, summary domusecase.PublishAnalyticsSummary) (*domusecase.PublishAnalyticsSummary, error) {
@@ -50,6 +52,8 @@ func (u *publishAnalyticsUseCase) RecordCheckpoint(ctx context.Context, tenantID
ReplyCount: summary.ReplyCount,
RepostCount: summary.RepostCount,
QuoteCount: summary.QuoteCount,
+ ViewCount: summary.ViewCount,
+ ShareCount: summary.ShareCount,
ImpressionCount: summary.ImpressionCount,
ReachCount: summary.ReachCount,
CheckedAt: now,
@@ -97,6 +101,8 @@ func toSummary(item entity.PublishAnalytics) domusecase.PublishAnalyticsSummary
ReplyCount: item.ReplyCount,
RepostCount: item.RepostCount,
QuoteCount: item.QuoteCount,
+ ViewCount: item.ViewCount,
+ ShareCount: item.ShareCount,
ImpressionCount: item.ImpressionCount,
ReachCount: item.ReachCount,
CheckedAt: item.CheckedAt,
diff --git a/backend/internal/model/publish_draft/domain/entity/draft.go b/backend/internal/model/publish_draft/domain/entity/draft.go
new file mode 100644
index 0000000..c3071f8
--- /dev/null
+++ b/backend/internal/model/publish_draft/domain/entity/draft.go
@@ -0,0 +1,15 @@
+package entity
+
+const CollectionName = "publish_text_drafts"
+
+type Draft struct {
+ ID string `bson:"_id"`
+ TenantID string `bson:"tenant_id"`
+ OwnerUID string `bson:"owner_uid"`
+ AccountID string `bson:"account_id"`
+ Title string `bson:"title,omitempty"`
+ Text string `bson:"text"`
+ Tags []string `bson:"tags,omitempty"`
+ CreateAt int64 `bson:"create_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
diff --git a/backend/internal/model/publish_draft/domain/repository/repository.go b/backend/internal/model/publish_draft/domain/repository/repository.go
new file mode 100644
index 0000000..0011df8
--- /dev/null
+++ b/backend/internal/model/publish_draft/domain/repository/repository.go
@@ -0,0 +1,21 @@
+package repository
+
+import (
+ "context"
+
+ "haixun-backend/internal/model/publish_draft/domain/entity"
+)
+
+type ListResult struct {
+ Items []entity.Draft
+ Total int64
+}
+
+type Repository interface {
+ EnsureIndexes(ctx context.Context) error
+ Create(ctx context.Context, draft *entity.Draft) error
+ Get(ctx context.Context, tenantID, ownerUID, accountID, draftID string) (*entity.Draft, error)
+ List(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*ListResult, error)
+ Update(ctx context.Context, draft *entity.Draft) (*entity.Draft, error)
+ Delete(ctx context.Context, tenantID, ownerUID, accountID, draftID string) error
+}
diff --git a/backend/internal/model/publish_draft/domain/usecase/usecase.go b/backend/internal/model/publish_draft/domain/usecase/usecase.go
new file mode 100644
index 0000000..e62e87d
--- /dev/null
+++ b/backend/internal/model/publish_draft/domain/usecase/usecase.go
@@ -0,0 +1,48 @@
+package usecase
+
+import "context"
+
+type DraftSummary struct {
+ ID string
+ AccountID string
+ Title string
+ Text string
+ Tags []string
+ CreateAt int64
+ UpdateAt int64
+}
+
+type CreateRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ Title string
+ Text string
+ Tags []string
+}
+
+type UpdateRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ DraftID string
+ Title *string
+ Text *string
+ Tags *[]string
+}
+
+type ListResult struct {
+ Items []DraftSummary
+ Total int64
+ Page int
+ PageSize int
+ TotalPages int
+}
+
+type UseCase interface {
+ Create(ctx context.Context, req CreateRequest) (*DraftSummary, error)
+ Get(ctx context.Context, tenantID, ownerUID, accountID, draftID string) (*DraftSummary, error)
+ List(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*ListResult, error)
+ Update(ctx context.Context, req UpdateRequest) (*DraftSummary, error)
+ Delete(ctx context.Context, tenantID, ownerUID, accountID, draftID string) error
+}
diff --git a/backend/internal/model/publish_guard/domain/entity/policy.go b/backend/internal/model/publish_guard/domain/entity/policy.go
new file mode 100644
index 0000000..5e703e6
--- /dev/null
+++ b/backend/internal/model/publish_guard/domain/entity/policy.go
@@ -0,0 +1,18 @@
+package entity
+
+const CollectionName = "publish_guard_policies"
+
+type Policy struct {
+ ID string `bson:"_id"`
+ TenantID string `bson:"tenant_id"`
+ OwnerUID string `bson:"owner_uid"`
+ AccountID string `bson:"account_id"`
+ Enabled bool `bson:"enabled"`
+ MaxDailyPosts int `bson:"max_daily_posts"`
+ MinIntervalMinutes int `bson:"min_interval_minutes"`
+ ConsecutiveFailurePauseLimit int `bson:"consecutive_failure_pause_limit"`
+ Paused bool `bson:"paused"`
+ PausedReason string `bson:"paused_reason,omitempty"`
+ CreateAt int64 `bson:"create_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
diff --git a/backend/internal/model/publish_guard/domain/repository/repository.go b/backend/internal/model/publish_guard/domain/repository/repository.go
new file mode 100644
index 0000000..a958381
--- /dev/null
+++ b/backend/internal/model/publish_guard/domain/repository/repository.go
@@ -0,0 +1,14 @@
+package repository
+
+import (
+ "context"
+
+ "haixun-backend/internal/model/publish_guard/domain/entity"
+)
+
+type Repository interface {
+ EnsureIndexes(ctx context.Context) error
+ Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error)
+ Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error)
+ DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error
+}
diff --git a/backend/internal/model/publish_guard/domain/usecase/usecase.go b/backend/internal/model/publish_guard/domain/usecase/usecase.go
new file mode 100644
index 0000000..4ec89b7
--- /dev/null
+++ b/backend/internal/model/publish_guard/domain/usecase/usecase.go
@@ -0,0 +1,51 @@
+package usecase
+
+import "context"
+
+type PolicySummary struct {
+ AccountID string
+ Enabled bool
+ MaxDailyPosts int
+ MinIntervalMinutes int
+ ConsecutiveFailurePauseLimit int
+ Paused bool
+ PausedReason string
+ UpdateAt int64
+}
+
+type UpsertRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ Enabled bool
+ MaxDailyPosts int
+ MinIntervalMinutes int
+ ConsecutiveFailurePauseLimit int
+ Paused *bool
+ PausedReason string
+}
+
+type CheckRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ Now int64
+ PublishedToday int64
+ LastPublishedAt int64
+ FailureStreak int
+}
+
+type CheckResult struct {
+ Allowed bool
+ DelayUntil int64
+ Reason string
+ ShouldPause bool
+}
+
+type UseCase interface {
+ Get(ctx context.Context, tenantID, ownerUID, accountID string) (*PolicySummary, error)
+ Upsert(ctx context.Context, req UpsertRequest) (*PolicySummary, error)
+ Resume(ctx context.Context, tenantID, ownerUID, accountID string) (*PolicySummary, error)
+ Check(ctx context.Context, req CheckRequest) (*CheckResult, error)
+ Pause(ctx context.Context, tenantID, ownerUID, accountID, reason string) (*PolicySummary, error)
+}
diff --git a/backend/internal/model/publish_guard/repository/mongo.go b/backend/internal/model/publish_guard/repository/mongo.go
new file mode 100644
index 0000000..dcc262e
--- /dev/null
+++ b/backend/internal/model/publish_guard/repository/mongo.go
@@ -0,0 +1,83 @@
+package repository
+
+import (
+ "context"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/publish_guard/domain/entity"
+ domrepo "haixun-backend/internal/model/publish_guard/domain/repository"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+type mongoRepository struct {
+ collection *mongo.Collection
+}
+
+func NewMongoRepository(db *mongo.Database) domrepo.Repository {
+ if db == nil {
+ return &mongoRepository{}
+ }
+ return &mongoRepository{collection: db.Collection(entity.CollectionName)}
+}
+
+func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
+ if r.collection == nil {
+ return nil
+ }
+ _, err := r.collection.Indexes().CreateOne(ctx, mongo.IndexModel{
+ Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "account_id", Value: 1}},
+ Options: options.Index().SetUnique(true),
+ })
+ return err
+}
+
+func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ var out entity.Policy
+ err := r.collection.FindOne(ctx, actorFilter(tenantID, ownerUID, accountID)).Decode(&out)
+ if err != nil {
+ if err == mongo.ErrNoDocuments {
+ return nil, app.For(code.ThreadsAccount).ResNotFound("publish guard policy not found")
+ }
+ return nil, err
+ }
+ return &out, nil
+}
+
+func (r *mongoRepository) Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ if policy == nil {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("policy is required")
+ }
+ opts := options.Replace().SetUpsert(true)
+ _, err := r.collection.ReplaceOne(ctx, actorFilter(policy.TenantID, policy.OwnerUID, policy.AccountID), policy, opts)
+ if err != nil {
+ return nil, err
+ }
+ return policy, nil
+}
+
+func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error {
+ if r.collection == nil {
+ return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ _, err := r.collection.DeleteOne(ctx, actorFilter(tenantID, ownerUID, accountID))
+ return err
+}
+
+func actorFilter(tenantID, ownerUID, accountID string) bson.M {
+ return bson.M{
+ "tenant_id": strings.TrimSpace(tenantID),
+ "owner_uid": strings.TrimSpace(ownerUID),
+ "account_id": strings.TrimSpace(accountID),
+ }
+}
diff --git a/backend/internal/model/publish_guard/usecase/usecase.go b/backend/internal/model/publish_guard/usecase/usecase.go
new file mode 100644
index 0000000..e7777b6
--- /dev/null
+++ b/backend/internal/model/publish_guard/usecase/usecase.go
@@ -0,0 +1,171 @@
+package usecase
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "haixun-backend/internal/library/clock"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/publish_guard/domain/entity"
+ domrepo "haixun-backend/internal/model/publish_guard/domain/repository"
+ domusecase "haixun-backend/internal/model/publish_guard/domain/usecase"
+ threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
+
+ "github.com/google/uuid"
+)
+
+type guardUseCase struct {
+ repo domrepo.Repository
+ threadsAccount threadsaccountdomain.UseCase
+}
+
+func NewUseCase(repo domrepo.Repository, threadsAccount threadsaccountdomain.UseCase) domusecase.UseCase {
+ return &guardUseCase{repo: repo, threadsAccount: threadsAccount}
+}
+
+func (u *guardUseCase) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.PolicySummary, error) {
+ if err := u.require(ctx, tenantID, ownerUID, accountID); err != nil {
+ return nil, err
+ }
+ item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ if strings.Contains(err.Error(), "not found") {
+ item = defaultPolicy(tenantID, ownerUID, accountID)
+ } else {
+ return nil, err
+ }
+ }
+ out := toSummary(item)
+ return &out, nil
+}
+
+func (u *guardUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.PolicySummary, error) {
+ if err := u.require(ctx, req.TenantID, req.OwnerUID, req.AccountID); err != nil {
+ return nil, err
+ }
+ item := defaultPolicy(req.TenantID, req.OwnerUID, req.AccountID)
+ item.Enabled = req.Enabled
+ if req.MaxDailyPosts > 0 {
+ item.MaxDailyPosts = req.MaxDailyPosts
+ }
+ if req.MinIntervalMinutes > 0 {
+ item.MinIntervalMinutes = req.MinIntervalMinutes
+ }
+ if req.ConsecutiveFailurePauseLimit > 0 {
+ item.ConsecutiveFailurePauseLimit = req.ConsecutiveFailurePauseLimit
+ }
+ if req.Paused != nil {
+ item.Paused = *req.Paused
+ }
+ item.PausedReason = strings.TrimSpace(req.PausedReason)
+ item.UpdateAt = clock.NowUnixNano()
+ saved, err := u.repo.Upsert(ctx, item)
+ if err != nil {
+ return nil, err
+ }
+ out := toSummary(saved)
+ return &out, nil
+}
+
+func (u *guardUseCase) Resume(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.PolicySummary, error) {
+ policy, err := u.Get(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ paused := false
+ return u.Upsert(ctx, domusecase.UpsertRequest{
+ TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, Enabled: policy.Enabled,
+ MaxDailyPosts: policy.MaxDailyPosts, MinIntervalMinutes: policy.MinIntervalMinutes,
+ ConsecutiveFailurePauseLimit: policy.ConsecutiveFailurePauseLimit,
+ Paused: &paused,
+ })
+}
+
+func (u *guardUseCase) Pause(ctx context.Context, tenantID, ownerUID, accountID, reason string) (*domusecase.PolicySummary, error) {
+ policy, err := u.Get(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ paused := true
+ return u.Upsert(ctx, domusecase.UpsertRequest{
+ TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, Enabled: policy.Enabled,
+ MaxDailyPosts: policy.MaxDailyPosts, MinIntervalMinutes: policy.MinIntervalMinutes,
+ ConsecutiveFailurePauseLimit: policy.ConsecutiveFailurePauseLimit,
+ Paused: &paused, PausedReason: reason,
+ })
+}
+
+func (u *guardUseCase) Check(ctx context.Context, req domusecase.CheckRequest) (*domusecase.CheckResult, error) {
+ policy, err := u.Get(ctx, req.TenantID, req.OwnerUID, req.AccountID)
+ if err != nil {
+ return nil, err
+ }
+ if !policy.Enabled {
+ return &domusecase.CheckResult{Allowed: true}, nil
+ }
+ now := req.Now
+ if now <= 0 {
+ now = clock.NowUnixNano()
+ }
+ if policy.Paused {
+ return &domusecase.CheckResult{Allowed: false, Reason: firstNonEmpty(policy.PausedReason, "發文已暫停")}, nil
+ }
+ if policy.MaxDailyPosts > 0 && req.PublishedToday >= int64(policy.MaxDailyPosts) {
+ return &domusecase.CheckResult{Allowed: false, DelayUntil: now + int64(time.Hour), Reason: "已達每日發文上限"}, nil
+ }
+ if policy.MinIntervalMinutes > 0 && req.LastPublishedAt > 0 {
+ minNext := req.LastPublishedAt + int64(time.Duration(policy.MinIntervalMinutes)*time.Minute)
+ if minNext > now {
+ return &domusecase.CheckResult{Allowed: false, DelayUntil: minNext, Reason: "未達最小發文間隔"}, nil
+ }
+ }
+ if policy.ConsecutiveFailurePauseLimit > 0 && req.FailureStreak >= policy.ConsecutiveFailurePauseLimit {
+ return &domusecase.CheckResult{Allowed: false, Reason: "連續失敗達上限,已暫停帳號發文", ShouldPause: true}, nil
+ }
+ return &domusecase.CheckResult{Allowed: true}, nil
+}
+
+func (u *guardUseCase) require(ctx context.Context, tenantID, ownerUID, accountID string) error {
+ if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
+ return app.For(code.Auth).AuthUnauthorized("missing actor")
+ }
+ if strings.TrimSpace(accountID) == "" {
+ return app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
+ }
+ if u.threadsAccount != nil {
+ _, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID)
+ return err
+ }
+ return nil
+}
+
+func defaultPolicy(tenantID, ownerUID, accountID string) *entity.Policy {
+ now := clock.NowUnixNano()
+ return &entity.Policy{
+ ID: uuid.NewString(), TenantID: strings.TrimSpace(tenantID), OwnerUID: strings.TrimSpace(ownerUID), AccountID: strings.TrimSpace(accountID),
+ Enabled: true, MaxDailyPosts: 6, MinIntervalMinutes: 90, ConsecutiveFailurePauseLimit: 3,
+ CreateAt: now, UpdateAt: now,
+ }
+}
+
+func toSummary(item *entity.Policy) domusecase.PolicySummary {
+ if item == nil {
+ return domusecase.PolicySummary{}
+ }
+ return domusecase.PolicySummary{
+ AccountID: item.AccountID, Enabled: item.Enabled, MaxDailyPosts: item.MaxDailyPosts,
+ MinIntervalMinutes: item.MinIntervalMinutes, ConsecutiveFailurePauseLimit: item.ConsecutiveFailurePauseLimit,
+ Paused: item.Paused, PausedReason: item.PausedReason, UpdateAt: item.UpdateAt,
+ }
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
diff --git a/backend/internal/model/publish_guard/usecase/usecase_test.go b/backend/internal/model/publish_guard/usecase/usecase_test.go
new file mode 100644
index 0000000..6c829ad
--- /dev/null
+++ b/backend/internal/model/publish_guard/usecase/usecase_test.go
@@ -0,0 +1,100 @@
+package usecase
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "haixun-backend/internal/model/publish_guard/domain/entity"
+ domusecase "haixun-backend/internal/model/publish_guard/domain/usecase"
+)
+
+type guardRepoStub struct {
+ policy *entity.Policy
+}
+
+func (r *guardRepoStub) EnsureIndexes(ctx context.Context) error { return nil }
+func (r *guardRepoStub) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) {
+ if r.policy == nil {
+ r.policy = defaultPolicy(tenantID, ownerUID, accountID)
+ }
+ return r.policy, nil
+}
+func (r *guardRepoStub) Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) {
+ r.policy = policy
+ return policy, nil
+}
+func (r *guardRepoStub) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error {
+ r.policy = nil
+ return nil
+}
+
+func TestGuardCheckBlocksPausedPolicy(t *testing.T) {
+ repo := &guardRepoStub{policy: defaultPolicy("t", "u", "a")}
+ repo.policy.Paused = true
+ repo.policy.PausedReason = "token error"
+ uc := NewUseCase(repo, nil)
+
+ result, err := uc.Check(context.Background(), checkReq())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Allowed || result.Reason != "token error" {
+ t.Fatalf("expected paused block, got %#v", result)
+ }
+}
+
+func TestGuardCheckDelaysWhenDailyLimitReached(t *testing.T) {
+ repo := &guardRepoStub{policy: defaultPolicy("t", "u", "a")}
+ repo.policy.MaxDailyPosts = 1
+ uc := NewUseCase(repo, nil)
+
+ result, err := uc.Check(context.Background(), checkReqWith(func(req *CheckRequestBuilder) {
+ req.publishedToday = 1
+ }))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Allowed || result.DelayUntil == 0 {
+ t.Fatalf("expected delayed daily limit, got %#v", result)
+ }
+}
+
+func TestGuardCheckDelaysWhenIntervalNotReached(t *testing.T) {
+ repo := &guardRepoStub{policy: defaultPolicy("t", "u", "a")}
+ repo.policy.MinIntervalMinutes = 90
+ uc := NewUseCase(repo, nil)
+
+ now := time.Date(2026, 6, 30, 8, 0, 0, 0, time.UTC).UnixNano()
+ result, err := uc.Check(context.Background(), checkReqWith(func(req *CheckRequestBuilder) {
+ req.now = now
+ req.lastPublishedAt = now - int64(30*time.Minute)
+ }))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Allowed || result.DelayUntil <= now {
+ t.Fatalf("expected interval delay, got %#v", result)
+ }
+}
+
+type CheckRequestBuilder struct {
+ now int64
+ publishedToday int64
+ lastPublishedAt int64
+}
+
+func checkReq() domusecase.CheckRequest {
+ return checkReqWith(nil)
+}
+
+func checkReqWith(update func(*CheckRequestBuilder)) domusecase.CheckRequest {
+ builder := &CheckRequestBuilder{now: time.Date(2026, 6, 30, 8, 0, 0, 0, time.UTC).UnixNano()}
+ if update != nil {
+ update(builder)
+ }
+ return domusecase.CheckRequest{
+ TenantID: "t", OwnerUID: "u", AccountID: "a",
+ Now: builder.now, PublishedToday: builder.publishedToday, LastPublishedAt: builder.lastPublishedAt,
+ }
+}
diff --git a/backend/internal/model/publish_inventory/domain/entity/policy.go b/backend/internal/model/publish_inventory/domain/entity/policy.go
new file mode 100644
index 0000000..053d97e
--- /dev/null
+++ b/backend/internal/model/publish_inventory/domain/entity/policy.go
@@ -0,0 +1,32 @@
+package entity
+
+const CollectionName = "publish_inventory_policies"
+
+type Slot struct {
+ Weekday int `bson:"weekday" json:"weekday"`
+ Time string `bson:"time" json:"time"`
+}
+
+type SourceRef struct {
+ Type string `bson:"type" json:"type"`
+ PersonaID string `bson:"persona_id,omitempty" json:"persona_id,omitempty"`
+ CopyMissionID string `bson:"copy_mission_id,omitempty" json:"copy_mission_id,omitempty"`
+ ScanPostID string `bson:"scan_post_id,omitempty" json:"scan_post_id,omitempty"`
+ ManualSeed string `bson:"manual_seed,omitempty" json:"manual_seed,omitempty"`
+}
+
+type Policy struct {
+ ID string `bson:"_id"`
+ TenantID string `bson:"tenant_id"`
+ OwnerUID string `bson:"owner_uid"`
+ AccountID string `bson:"account_id"`
+ Enabled bool `bson:"enabled"`
+ TargetDailyCount int `bson:"target_daily_count"`
+ LowStockThreshold int `bson:"low_stock_threshold"`
+ LookaheadDays int `bson:"lookahead_days"`
+ Timezone string `bson:"timezone"`
+ Slots []Slot `bson:"slots,omitempty"`
+ SourceRefs []SourceRef `bson:"source_refs,omitempty"`
+ CreateAt int64 `bson:"create_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
diff --git a/backend/internal/model/publish_inventory/domain/repository/repository.go b/backend/internal/model/publish_inventory/domain/repository/repository.go
new file mode 100644
index 0000000..e55a79b
--- /dev/null
+++ b/backend/internal/model/publish_inventory/domain/repository/repository.go
@@ -0,0 +1,15 @@
+package repository
+
+import (
+ "context"
+
+ "haixun-backend/internal/model/publish_inventory/domain/entity"
+)
+
+type Repository interface {
+ EnsureIndexes(ctx context.Context) error
+ Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error)
+ Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error)
+ ListEnabled(ctx context.Context, limit int) ([]entity.Policy, error)
+ DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error
+}
diff --git a/backend/internal/model/publish_inventory/domain/usecase/usecase.go b/backend/internal/model/publish_inventory/domain/usecase/usecase.go
new file mode 100644
index 0000000..68525d3
--- /dev/null
+++ b/backend/internal/model/publish_inventory/domain/usecase/usecase.go
@@ -0,0 +1,47 @@
+package usecase
+
+import "context"
+
+type Slot struct {
+ Weekday int
+ Time string
+}
+
+type SourceRef struct {
+ Type string
+ PersonaID string
+ CopyMissionID string
+ ScanPostID string
+ ManualSeed string
+}
+
+type PolicySummary struct {
+ AccountID string
+ Enabled bool
+ TargetDailyCount int
+ LowStockThreshold int
+ LookaheadDays int
+ Timezone string
+ Slots []Slot
+ SourceRefs []SourceRef
+ UpdateAt int64
+}
+
+type UpsertRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ Enabled bool
+ TargetDailyCount int
+ LowStockThreshold int
+ LookaheadDays int
+ Timezone string
+ Slots []Slot
+ SourceRefs []SourceRef
+}
+
+type UseCase interface {
+ Get(ctx context.Context, tenantID, ownerUID, accountID string) (*PolicySummary, error)
+ Upsert(ctx context.Context, req UpsertRequest) (*PolicySummary, error)
+ ListEnabled(ctx context.Context, limit int) ([]PolicySummary, error)
+}
diff --git a/backend/internal/model/publish_inventory/repository/mongo.go b/backend/internal/model/publish_inventory/repository/mongo.go
new file mode 100644
index 0000000..3edcef1
--- /dev/null
+++ b/backend/internal/model/publish_inventory/repository/mongo.go
@@ -0,0 +1,102 @@
+package repository
+
+import (
+ "context"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/publish_inventory/domain/entity"
+ domrepo "haixun-backend/internal/model/publish_inventory/domain/repository"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+type mongoRepository struct {
+ collection *mongo.Collection
+}
+
+func NewMongoRepository(db *mongo.Database) domrepo.Repository {
+ if db == nil {
+ return &mongoRepository{}
+ }
+ return &mongoRepository{collection: db.Collection(entity.CollectionName)}
+}
+
+func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
+ if r.collection == nil {
+ return nil
+ }
+ _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
+ {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "account_id", Value: 1}}, Options: options.Index().SetUnique(true)},
+ {Keys: bson.D{{Key: "enabled", Value: 1}}},
+ })
+ return err
+}
+
+func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ var out entity.Policy
+ err := r.collection.FindOne(ctx, actorFilter(tenantID, ownerUID, accountID)).Decode(&out)
+ if err != nil {
+ if err == mongo.ErrNoDocuments {
+ return nil, app.For(code.ThreadsAccount).ResNotFound("publish inventory policy not found")
+ }
+ return nil, err
+ }
+ return &out, nil
+}
+
+func (r *mongoRepository) Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ if policy == nil {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("policy is required")
+ }
+ filter := actorFilter(policy.TenantID, policy.OwnerUID, policy.AccountID)
+ opts := options.Replace().SetUpsert(true)
+ if _, err := r.collection.ReplaceOne(ctx, filter, policy, opts); err != nil {
+ return nil, err
+ }
+ return policy, nil
+}
+
+func (r *mongoRepository) ListEnabled(ctx context.Context, limit int) ([]entity.Policy, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ if limit <= 0 {
+ limit = 100
+ }
+ cur, err := r.collection.Find(ctx, bson.M{"enabled": true}, options.Find().SetLimit(int64(limit)))
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ var out []entity.Policy
+ if err := cur.All(ctx, &out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error {
+ if r.collection == nil {
+ return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ _, err := r.collection.DeleteOne(ctx, actorFilter(tenantID, ownerUID, accountID))
+ return err
+}
+
+func actorFilter(tenantID, ownerUID, accountID string) bson.M {
+ return bson.M{
+ "tenant_id": strings.TrimSpace(tenantID),
+ "owner_uid": strings.TrimSpace(ownerUID),
+ "account_id": strings.TrimSpace(accountID),
+ }
+}
diff --git a/backend/internal/model/publish_inventory/usecase/usecase.go b/backend/internal/model/publish_inventory/usecase/usecase.go
new file mode 100644
index 0000000..497c412
--- /dev/null
+++ b/backend/internal/model/publish_inventory/usecase/usecase.go
@@ -0,0 +1,174 @@
+package usecase
+
+import (
+ "context"
+ "strings"
+
+ "haixun-backend/internal/library/clock"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/publish_inventory/domain/entity"
+ domrepo "haixun-backend/internal/model/publish_inventory/domain/repository"
+ domusecase "haixun-backend/internal/model/publish_inventory/domain/usecase"
+ threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
+
+ "github.com/google/uuid"
+)
+
+type inventoryUseCase struct {
+ repo domrepo.Repository
+ threadsAccount threadsaccountdomain.UseCase
+}
+
+func NewUseCase(repo domrepo.Repository, threadsAccount threadsaccountdomain.UseCase) domusecase.UseCase {
+ return &inventoryUseCase{repo: repo, threadsAccount: threadsAccount}
+}
+
+func (u *inventoryUseCase) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.PolicySummary, error) {
+ if err := u.require(ctx, tenantID, ownerUID, accountID); err != nil {
+ return nil, err
+ }
+ item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ if strings.Contains(err.Error(), "not found") {
+ item = defaultPolicy(tenantID, ownerUID, accountID)
+ } else {
+ return nil, err
+ }
+ }
+ out := toSummary(item)
+ return &out, nil
+}
+
+func (u *inventoryUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.PolicySummary, error) {
+ if err := u.require(ctx, req.TenantID, req.OwnerUID, req.AccountID); err != nil {
+ return nil, err
+ }
+ item := defaultPolicy(req.TenantID, req.OwnerUID, req.AccountID)
+ item.Enabled = req.Enabled
+ if req.TargetDailyCount > 0 {
+ item.TargetDailyCount = req.TargetDailyCount
+ }
+ if req.LowStockThreshold > 0 {
+ item.LowStockThreshold = req.LowStockThreshold
+ }
+ if req.LookaheadDays > 0 {
+ item.LookaheadDays = req.LookaheadDays
+ }
+ if strings.TrimSpace(req.Timezone) != "" {
+ item.Timezone = strings.TrimSpace(req.Timezone)
+ }
+ item.Slots = slotsToEntity(req.Slots)
+ item.SourceRefs = sourceRefsToEntity(req.SourceRefs)
+ now := clock.NowUnixNano()
+ item.UpdateAt = now
+ saved, err := u.repo.Upsert(ctx, item)
+ if err != nil {
+ return nil, err
+ }
+ out := toSummary(saved)
+ return &out, nil
+}
+
+func (u *inventoryUseCase) ListEnabled(ctx context.Context, limit int) ([]domusecase.PolicySummary, error) {
+ items, err := u.repo.ListEnabled(ctx, limit)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]domusecase.PolicySummary, 0, len(items))
+ for _, item := range items {
+ summary := toSummary(&item)
+ out = append(out, summary)
+ }
+ return out, nil
+}
+
+func (u *inventoryUseCase) require(ctx context.Context, tenantID, ownerUID, accountID string) error {
+ if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
+ return app.For(code.Auth).AuthUnauthorized("missing actor")
+ }
+ if strings.TrimSpace(accountID) == "" {
+ return app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
+ }
+ if u.threadsAccount != nil {
+ _, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID)
+ return err
+ }
+ return nil
+}
+
+func defaultPolicy(tenantID, ownerUID, accountID string) *entity.Policy {
+ now := clock.NowUnixNano()
+ return &entity.Policy{
+ ID: uuid.NewString(),
+ TenantID: strings.TrimSpace(tenantID),
+ OwnerUID: strings.TrimSpace(ownerUID),
+ AccountID: strings.TrimSpace(accountID),
+ Enabled: false,
+ TargetDailyCount: 2,
+ LowStockThreshold: 3,
+ LookaheadDays: 7,
+ Timezone: "Asia/Taipei",
+ Slots: []entity.Slot{
+ {Weekday: 1, Time: "09:30"},
+ {Weekday: 3, Time: "12:30"},
+ {Weekday: 5, Time: "18:30"},
+ },
+ CreateAt: now,
+ UpdateAt: now,
+ }
+}
+
+func toSummary(item *entity.Policy) domusecase.PolicySummary {
+ if item == nil {
+ return domusecase.PolicySummary{}
+ }
+ return domusecase.PolicySummary{
+ AccountID: item.AccountID, Enabled: item.Enabled, TargetDailyCount: item.TargetDailyCount,
+ LowStockThreshold: item.LowStockThreshold, LookaheadDays: item.LookaheadDays,
+ Timezone: item.Timezone, Slots: slotsFromEntity(item.Slots), SourceRefs: sourceRefsFromEntity(item.SourceRefs), UpdateAt: item.UpdateAt,
+ }
+}
+
+func slotsToEntity(items []domusecase.Slot) []entity.Slot {
+ out := make([]entity.Slot, 0, len(items))
+ for _, item := range items {
+ if strings.TrimSpace(item.Time) == "" {
+ continue
+ }
+ out = append(out, entity.Slot{Weekday: item.Weekday, Time: strings.TrimSpace(item.Time)})
+ }
+ return out
+}
+
+func slotsFromEntity(items []entity.Slot) []domusecase.Slot {
+ out := make([]domusecase.Slot, 0, len(items))
+ for _, item := range items {
+ out = append(out, domusecase.Slot{Weekday: item.Weekday, Time: item.Time})
+ }
+ return out
+}
+
+func sourceRefsToEntity(items []domusecase.SourceRef) []entity.SourceRef {
+ out := make([]entity.SourceRef, 0, len(items))
+ for _, item := range items {
+ if strings.TrimSpace(item.Type) == "" {
+ continue
+ }
+ out = append(out, entity.SourceRef{
+ Type: strings.TrimSpace(item.Type), PersonaID: strings.TrimSpace(item.PersonaID),
+ CopyMissionID: strings.TrimSpace(item.CopyMissionID), ScanPostID: strings.TrimSpace(item.ScanPostID), ManualSeed: strings.TrimSpace(item.ManualSeed),
+ })
+ }
+ return out
+}
+
+func sourceRefsFromEntity(items []entity.SourceRef) []domusecase.SourceRef {
+ out := make([]domusecase.SourceRef, 0, len(items))
+ for _, item := range items {
+ out = append(out, domusecase.SourceRef{
+ Type: item.Type, PersonaID: item.PersonaID, CopyMissionID: item.CopyMissionID, ScanPostID: item.ScanPostID, ManualSeed: item.ManualSeed,
+ })
+ }
+ return out
+}
diff --git a/backend/internal/model/publish_queue/domain/entity/queue_item.go b/backend/internal/model/publish_queue/domain/entity/queue_item.go
new file mode 100644
index 0000000..295d93c
--- /dev/null
+++ b/backend/internal/model/publish_queue/domain/entity/queue_item.go
@@ -0,0 +1,37 @@
+package entity
+
+const CollectionName = "publish_queue"
+
+const (
+ StatusScheduled = "scheduled"
+ StatusPublishing = "publishing"
+ StatusPublished = "published"
+ StatusFailed = "failed"
+ StatusCancelled = "cancelled"
+ StatusMissed = "missed"
+)
+
+type QueueItem struct {
+ ID string `bson:"_id"`
+ TenantID string `bson:"tenant_id"`
+ OwnerUID string `bson:"owner_uid"`
+ AccountID string `bson:"account_id"`
+ PersonaID string `bson:"persona_id,omitempty"`
+ CopyMissionID string `bson:"copy_mission_id,omitempty"`
+ CopyDraftID string `bson:"copy_draft_id,omitempty"`
+ Text string `bson:"text"`
+ TopicTag string `bson:"topic_tag,omitempty"`
+ ScheduledAt int64 `bson:"scheduled_at"`
+ Status string `bson:"status"`
+ MediaID string `bson:"media_id,omitempty"`
+ Permalink string `bson:"permalink,omitempty"`
+ PublishedAt int64 `bson:"published_at,omitempty"`
+ ErrorMessage string `bson:"error_message,omitempty"`
+ RetryCount int `bson:"retry_count,omitempty"`
+ LastAttemptAt int64 `bson:"last_attempt_at,omitempty"`
+ NextAttemptAt int64 `bson:"next_attempt_at,omitempty"`
+ MissedAt int64 `bson:"missed_at,omitempty"`
+ PausedReason string `bson:"paused_reason,omitempty"`
+ CreateAt int64 `bson:"create_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
diff --git a/backend/internal/model/publish_queue/domain/repository/repository.go b/backend/internal/model/publish_queue/domain/repository/repository.go
new file mode 100644
index 0000000..68c85cf
--- /dev/null
+++ b/backend/internal/model/publish_queue/domain/repository/repository.go
@@ -0,0 +1,39 @@
+package repository
+
+import (
+ "context"
+
+ "haixun-backend/internal/model/publish_queue/domain/entity"
+)
+
+type ListFilter struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ Status string
+ StartAt int64
+ EndAt int64
+}
+
+type ListResult struct {
+ Items []entity.QueueItem
+ Total int64
+}
+
+type Repository interface {
+ EnsureIndexes(ctx context.Context) error
+ Create(ctx context.Context, item *entity.QueueItem) error
+ Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*entity.QueueItem, error)
+ ListByIDs(ctx context.Context, tenantID, ownerUID string, ids []string) ([]entity.QueueItem, error)
+ List(ctx context.Context, filter ListFilter, page, pageSize int) (*ListResult, error)
+ ListDue(ctx context.Context, now int64, limit int) ([]entity.QueueItem, error)
+ ListMissedCandidates(ctx context.Context, before int64, limit int) ([]entity.QueueItem, error)
+ UpdateIfStatus(ctx context.Context, item *entity.QueueItem, allowed []string) (*entity.QueueItem, error)
+ Update(ctx context.Context, item *entity.QueueItem) (*entity.QueueItem, error)
+ CountByStatus(ctx context.Context, tenantID, ownerUID, accountID string, status string) (int64, error)
+ CountPublishedSince(ctx context.Context, tenantID, ownerUID, accountID string, since int64) (int64, error)
+ LastPublishedAt(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error)
+ Delete(ctx context.Context, tenantID, ownerUID, accountID, queueID string) error
+ ListMediaIDsByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]string, error)
+ DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error)
+}
diff --git a/backend/internal/model/publish_queue/domain/usecase/usecase.go b/backend/internal/model/publish_queue/domain/usecase/usecase.go
new file mode 100644
index 0000000..7487ada
--- /dev/null
+++ b/backend/internal/model/publish_queue/domain/usecase/usecase.go
@@ -0,0 +1,171 @@
+package usecase
+
+import "context"
+
+type QueueItemSummary struct {
+ ID string
+ AccountID string
+ PersonaID string
+ CopyMissionID string
+ CopyDraftID string
+ Text string
+ TopicTag string
+ ScheduledAt int64
+ Status string
+ MediaID string
+ Permalink string
+ PublishedAt int64
+ ErrorMessage string
+ RetryCount int
+ LastAttemptAt int64
+ NextAttemptAt int64
+ MissedAt int64
+ PausedReason string
+ CreateAt int64
+ UpdateAt int64
+}
+
+type CreateRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ PersonaID string
+ CopyMissionID string
+ CopyDraftID string
+ Text string
+ TopicTag string
+ ScheduledAt int64
+}
+
+type UpdateRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ QueueID string
+ Text *string
+ TopicTag *string
+ ScheduledAt *int64
+}
+
+type ListRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ Status string
+ Page int
+ PageSize int
+}
+
+type RangeRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ Status string
+ StartAt int64
+ EndAt int64
+ Page int
+ PageSize int
+}
+
+type ListResult struct {
+ Items []QueueItemSummary
+ Total int64
+ Page int
+ PageSize int
+ TotalPages int
+}
+
+type PublishHealthSummary struct {
+ PendingScheduled int64
+ FailedCount int64
+ Published7d int64
+ TotalLikes7d int
+ TotalReplies7d int
+}
+
+type PublishHealthCheckpoint struct {
+ Checkpoint string
+ LikeCount int
+ ReplyCount int
+ RepostCount int
+ QuoteCount int
+ Views int
+ CheckedAt int64
+}
+
+type PublishHealthItem struct {
+ MediaID string
+ Permalink string
+ Text string
+ PublishedAt int64
+ LikeCount int
+ ReplyCount int
+ RepostCount int
+ QuoteCount int
+ Views int
+ Shares int
+ Checkpoints []PublishHealthCheckpoint
+}
+
+type PublishHealthResult struct {
+ Summary PublishHealthSummary
+ Items []PublishHealthItem
+ Total int64
+ Page int
+ PageSize int
+ TotalPages int
+}
+
+type RecordPublishedRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ PersonaID string
+ CopyMissionID string
+ CopyDraftID string
+ Text string
+ TopicTag string
+ MediaID string
+ Permalink string
+ PublishedAt int64
+}
+
+type SlotInsight struct {
+ Weekday int
+ Time string
+ AvgLikes1h int
+ AvgReplies1h int
+ AvgLikes24h int
+ AvgReplies24h int
+ AvgLikes7d int
+ AvgReplies7d int
+ SampleSize int
+ Recommendation string
+}
+
+type Alert struct {
+ Type string
+ Severity string
+ Message string
+ QueueID string
+ AccountID string
+ CreateAt int64
+}
+
+type UseCase interface {
+ Create(ctx context.Context, req CreateRequest) (*QueueItemSummary, error)
+ RecordPublished(ctx context.Context, req RecordPublishedRequest) (*QueueItemSummary, error)
+ Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*QueueItemSummary, error)
+ ListByIDs(ctx context.Context, tenantID, ownerUID string, ids []string) ([]QueueItemSummary, error)
+ List(ctx context.Context, req ListRequest) (*ListResult, error)
+ ListRange(ctx context.Context, req RangeRequest) (*ListResult, error)
+ Update(ctx context.Context, req UpdateRequest) (*QueueItemSummary, error)
+ Cancel(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*QueueItemSummary, error)
+ Retry(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*QueueItemSummary, error)
+ Delete(ctx context.Context, tenantID, ownerUID, accountID, queueID string) error
+ DispatchDue(ctx context.Context, limit int) (int, error)
+ MarkMissed(ctx context.Context, graceNs int64, limit int) (int, error)
+ ListAlerts(ctx context.Context, tenantID, ownerUID, accountID string) ([]Alert, error)
+ ListSlotInsights(ctx context.Context, tenantID, ownerUID, accountID, timezone string) ([]SlotInsight, error)
+ ListPublishHealth(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*PublishHealthResult, error)
+}
diff --git a/backend/internal/model/publish_queue/repository/mongo.go b/backend/internal/model/publish_queue/repository/mongo.go
new file mode 100644
index 0000000..ab060ff
--- /dev/null
+++ b/backend/internal/model/publish_queue/repository/mongo.go
@@ -0,0 +1,359 @@
+package repository
+
+import (
+ "context"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/publish_queue/domain/entity"
+ domrepo "haixun-backend/internal/model/publish_queue/domain/repository"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+type mongoRepository struct {
+ collection *mongo.Collection
+}
+
+func NewMongoRepository(db *mongo.Database) domrepo.Repository {
+ if db == nil {
+ return &mongoRepository{}
+ }
+ return &mongoRepository{collection: db.Collection(entity.CollectionName)}
+}
+
+func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
+ if r.collection == nil {
+ return nil
+ }
+ _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
+ {
+ Keys: bson.D{
+ {Key: "tenant_id", Value: 1},
+ {Key: "owner_uid", Value: 1},
+ {Key: "account_id", Value: 1},
+ {Key: "status", Value: 1},
+ {Key: "scheduled_at", Value: 1},
+ },
+ },
+ })
+ return err
+}
+
+func actorFilter(tenantID, ownerUID, accountID string) bson.M {
+ filter := bson.M{
+ "tenant_id": strings.TrimSpace(tenantID),
+ "owner_uid": strings.TrimSpace(ownerUID),
+ }
+ if accountID = strings.TrimSpace(accountID); accountID != "" {
+ filter["account_id"] = accountID
+ }
+ return filter
+}
+
+func normalizePage(page, pageSize int) (int, int) {
+ if page <= 0 {
+ page = 1
+ }
+ if pageSize <= 0 {
+ pageSize = 10
+ }
+ if pageSize > 50 {
+ pageSize = 50
+ }
+ return page, pageSize
+}
+
+func (r *mongoRepository) Create(ctx context.Context, item *entity.QueueItem) error {
+ if r.collection == nil {
+ return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ if item == nil {
+ return app.For(code.ThreadsAccount).InputMissingRequired("queue item is required")
+ }
+ _, err := r.collection.InsertOne(ctx, item)
+ return err
+}
+
+func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*entity.QueueItem, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ queueID = strings.TrimSpace(queueID)
+ if queueID == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("queue id is required")
+ }
+ filter := actorFilter(tenantID, ownerUID, accountID)
+ filter["_id"] = queueID
+ var out entity.QueueItem
+ err := r.collection.FindOne(ctx, filter).Decode(&out)
+ if err != nil {
+ if err == mongo.ErrNoDocuments {
+ return nil, app.For(code.ThreadsAccount).ResNotFound("publish queue item not found")
+ }
+ return nil, err
+ }
+ return &out, nil
+}
+
+func (r *mongoRepository) ListByIDs(ctx context.Context, tenantID, ownerUID string, ids []string) ([]entity.QueueItem, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ trimmed := make([]string, 0, len(ids))
+ for _, id := range ids {
+ id = strings.TrimSpace(id)
+ if id != "" {
+ trimmed = append(trimmed, id)
+ }
+ }
+ if len(trimmed) == 0 {
+ return nil, nil
+ }
+ filter := actorFilter(tenantID, ownerUID, "")
+ filter["_id"] = bson.M{"$in": trimmed}
+ cur, err := r.collection.Find(ctx, filter)
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ var out []entity.QueueItem
+ if err := cur.All(ctx, &out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (r *mongoRepository) List(ctx context.Context, filter domrepo.ListFilter, page, pageSize int) (*domrepo.ListResult, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ page, pageSize = normalizePage(page, pageSize)
+ mongoFilter := actorFilter(filter.TenantID, filter.OwnerUID, filter.AccountID)
+ if status := strings.TrimSpace(filter.Status); status != "" {
+ mongoFilter["status"] = status
+ }
+ if filter.StartAt > 0 || filter.EndAt > 0 {
+ rangeFilter := bson.M{}
+ if filter.StartAt > 0 {
+ rangeFilter["$gte"] = filter.StartAt
+ }
+ if filter.EndAt > 0 {
+ rangeFilter["$lte"] = filter.EndAt
+ }
+ mongoFilter["scheduled_at"] = rangeFilter
+ }
+ total, err := r.collection.CountDocuments(ctx, mongoFilter)
+ if err != nil {
+ return nil, err
+ }
+ opts := options.Find().
+ SetSort(bson.D{{Key: "scheduled_at", Value: -1}}).
+ SetSkip(int64((page - 1) * pageSize)).
+ SetLimit(int64(pageSize))
+ cur, err := r.collection.Find(ctx, mongoFilter, opts)
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ var items []entity.QueueItem
+ if err := cur.All(ctx, &items); err != nil {
+ return nil, err
+ }
+ return &domrepo.ListResult{Items: items, Total: total}, nil
+}
+
+func (r *mongoRepository) ListDue(ctx context.Context, now int64, limit int) ([]entity.QueueItem, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ if limit <= 0 {
+ limit = 20
+ }
+ filter := bson.M{
+ "status": entity.StatusScheduled,
+ "scheduled_at": bson.M{"$lte": now},
+ }
+ opts := options.Find().
+ SetSort(bson.D{{Key: "scheduled_at", Value: 1}}).
+ SetLimit(int64(limit))
+ cur, err := r.collection.Find(ctx, filter, opts)
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ var items []entity.QueueItem
+ if err := cur.All(ctx, &items); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+func (r *mongoRepository) ListMissedCandidates(ctx context.Context, before int64, limit int) ([]entity.QueueItem, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ if limit <= 0 {
+ limit = 20
+ }
+ filter := bson.M{
+ "status": entity.StatusScheduled,
+ "scheduled_at": bson.M{"$lte": before},
+ }
+ opts := options.Find().
+ SetSort(bson.D{{Key: "scheduled_at", Value: 1}}).
+ SetLimit(int64(limit))
+ cur, err := r.collection.Find(ctx, filter, opts)
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ var items []entity.QueueItem
+ if err := cur.All(ctx, &items); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+func (r *mongoRepository) UpdateIfStatus(ctx context.Context, item *entity.QueueItem, allowed []string) (*entity.QueueItem, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ if item == nil || item.ID == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("queue item is required")
+ }
+ filter := bson.M{
+ "_id": item.ID,
+ "status": bson.M{"$in": allowed},
+ }
+ res, err := r.collection.ReplaceOne(ctx, filter, item)
+ if err != nil {
+ return nil, err
+ }
+ if res.MatchedCount == 0 {
+ return nil, app.For(code.ThreadsAccount).ResInvalidState("queue item status changed; update rejected")
+ }
+ return item, nil
+}
+
+func (r *mongoRepository) Update(ctx context.Context, item *entity.QueueItem) (*entity.QueueItem, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ if item == nil || item.ID == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("queue item is required")
+ }
+ filter := bson.M{"_id": item.ID}
+ res, err := r.collection.ReplaceOne(ctx, filter, item)
+ if err != nil {
+ return nil, err
+ }
+ if res.MatchedCount == 0 {
+ return nil, app.For(code.ThreadsAccount).ResNotFound("publish queue item not found")
+ }
+ return item, nil
+}
+
+func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, accountID, queueID string) error {
+ if r.collection == nil {
+ return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ queueID = strings.TrimSpace(queueID)
+ if queueID == "" {
+ return app.For(code.ThreadsAccount).InputMissingRequired("queue id is required")
+ }
+ filter := actorFilter(tenantID, ownerUID, accountID)
+ filter["_id"] = queueID
+ res, err := r.collection.DeleteOne(ctx, filter)
+ if err != nil {
+ return err
+ }
+ if res.DeletedCount == 0 {
+ return app.For(code.ThreadsAccount).ResNotFound("publish queue item not found")
+ }
+ return nil
+}
+
+func (r *mongoRepository) CountByStatus(ctx context.Context, tenantID, ownerUID, accountID, status string) (int64, error) {
+ if r.collection == nil {
+ return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ filter := actorFilter(tenantID, ownerUID, accountID)
+ filter["status"] = strings.TrimSpace(status)
+ return r.collection.CountDocuments(ctx, filter)
+}
+
+func (r *mongoRepository) CountPublishedSince(ctx context.Context, tenantID, ownerUID, accountID string, since int64) (int64, error) {
+ if r.collection == nil {
+ return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ filter := actorFilter(tenantID, ownerUID, accountID)
+ filter["status"] = entity.StatusPublished
+ filter["published_at"] = bson.M{"$gte": since}
+ return r.collection.CountDocuments(ctx, filter)
+}
+
+func (r *mongoRepository) LastPublishedAt(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) {
+ if r.collection == nil {
+ return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ filter := actorFilter(tenantID, ownerUID, accountID)
+ filter["status"] = entity.StatusPublished
+ opts := options.FindOne().SetSort(bson.D{{Key: "published_at", Value: -1}})
+ var out entity.QueueItem
+ err := r.collection.FindOne(ctx, filter, opts).Decode(&out)
+ if err != nil {
+ if err == mongo.ErrNoDocuments {
+ return 0, nil
+ }
+ return 0, err
+ }
+ return out.PublishedAt, nil
+}
+
+func (r *mongoRepository) ListMediaIDsByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]string, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ filter := actorFilter(tenantID, ownerUID, accountID)
+ filter["media_id"] = bson.M{"$ne": ""}
+ cur, err := r.collection.Find(ctx, filter, options.Find().SetProjection(bson.M{"media_id": 1}))
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ seen := map[string]struct{}{}
+ var ids []string
+ for cur.Next(ctx) {
+ var row struct {
+ MediaID string `bson:"media_id"`
+ }
+ if err := cur.Decode(&row); err != nil {
+ return nil, err
+ }
+ if id := strings.TrimSpace(row.MediaID); id != "" {
+ if _, ok := seen[id]; !ok {
+ seen[id] = struct{}{}
+ ids = append(ids, id)
+ }
+ }
+ }
+ if err := cur.Err(); err != nil {
+ return nil, err
+ }
+ return ids, nil
+}
+
+func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) {
+ if r.collection == nil {
+ return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ res, err := r.collection.DeleteMany(ctx, actorFilter(tenantID, ownerUID, accountID))
+ if err != nil {
+ return 0, err
+ }
+ return res.DeletedCount, nil
+}
diff --git a/backend/internal/model/publish_queue/usecase/usecase.go b/backend/internal/model/publish_queue/usecase/usecase.go
new file mode 100644
index 0000000..a09798a
--- /dev/null
+++ b/backend/internal/model/publish_queue/usecase/usecase.go
@@ -0,0 +1,782 @@
+package usecase
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "haixun-backend/internal/library/clock"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ libthreads "haixun-backend/internal/library/threadsapi"
+ "haixun-backend/internal/library/threadspost"
+ copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
+ padomain "haixun-backend/internal/model/publish_analytics/domain/usecase"
+ guarddomain "haixun-backend/internal/model/publish_guard/domain/usecase"
+ "haixun-backend/internal/model/publish_queue/domain/entity"
+ domrepo "haixun-backend/internal/model/publish_queue/domain/repository"
+ domusecase "haixun-backend/internal/model/publish_queue/domain/usecase"
+ evententity "haixun-backend/internal/model/publish_queue_event/domain/entity"
+ eventdomain "haixun-backend/internal/model/publish_queue_event/domain/usecase"
+ threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
+
+ "github.com/google/uuid"
+)
+
+type publishQueueUseCase struct {
+ repo domrepo.Repository
+ threadsAccount threadsaccountdomain.UseCase
+ publishAnalytics padomain.UseCase
+ guard guarddomain.UseCase
+ events eventdomain.UseCase
+ copyDraft copydraftdomain.UseCase
+}
+
+func NewUseCase(
+ repo domrepo.Repository,
+ threadsAccount threadsaccountdomain.UseCase,
+ publishAnalytics padomain.UseCase,
+ guard guarddomain.UseCase,
+ events eventdomain.UseCase,
+ copyDraft copydraftdomain.UseCase,
+) domusecase.UseCase {
+ return &publishQueueUseCase{
+ repo: repo,
+ threadsAccount: threadsAccount,
+ publishAnalytics: publishAnalytics,
+ guard: guard,
+ events: events,
+ copyDraft: copyDraft,
+ }
+}
+
+func (u *publishQueueUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.QueueItemSummary, error) {
+ tenantID := strings.TrimSpace(req.TenantID)
+ ownerUID := strings.TrimSpace(req.OwnerUID)
+ accountID := strings.TrimSpace(req.AccountID)
+ text := strings.TrimSpace(req.Text)
+ if tenantID == "" || ownerUID == "" {
+ return nil, app.For(code.Auth).AuthUnauthorized("missing actor")
+ }
+ if accountID == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
+ }
+ if text == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("text is required")
+ }
+ if err := threadspost.ValidatePublish(text); err != nil {
+ return nil, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error())
+ }
+ if _, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID); err != nil {
+ return nil, err
+ }
+
+ now := clock.NowUnixNano()
+ scheduledAt := req.ScheduledAt
+ if scheduledAt <= 0 {
+ scheduledAt = now
+ }
+
+ item := &entity.QueueItem{
+ ID: uuid.NewString(),
+ TenantID: tenantID,
+ OwnerUID: ownerUID,
+ AccountID: accountID,
+ PersonaID: strings.TrimSpace(req.PersonaID),
+ CopyMissionID: strings.TrimSpace(req.CopyMissionID),
+ CopyDraftID: strings.TrimSpace(req.CopyDraftID),
+ Text: text,
+ TopicTag: normalizeTopicTag(req.TopicTag),
+ ScheduledAt: scheduledAt,
+ Status: entity.StatusScheduled,
+ CreateAt: now,
+ UpdateAt: now,
+ }
+ if err := u.repo.Create(ctx, item); err != nil {
+ return nil, err
+ }
+ u.recordEvent(ctx, item, evententity.EventCreated, "", item.Status, "加入發文庫存")
+
+ if scheduledAt <= now {
+ _, _ = u.DispatchDue(ctx, 1)
+ fresh, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, item.ID)
+ if err == nil && fresh != nil {
+ out := toSummary(*fresh)
+ return &out, nil
+ }
+ }
+
+ out := toSummary(*item)
+ return &out, nil
+}
+
+func (u *publishQueueUseCase) RecordPublished(ctx context.Context, req domusecase.RecordPublishedRequest) (*domusecase.QueueItemSummary, error) {
+ tenantID := strings.TrimSpace(req.TenantID)
+ ownerUID := strings.TrimSpace(req.OwnerUID)
+ accountID := strings.TrimSpace(req.AccountID)
+ mediaID := strings.TrimSpace(req.MediaID)
+ if err := requireActor(tenantID, ownerUID); err != nil {
+ return nil, err
+ }
+ if accountID == "" || mediaID == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id and media_id are required")
+ }
+ if _, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID); err != nil {
+ return nil, err
+ }
+ now := clock.NowUnixNano()
+ publishedAt := req.PublishedAt
+ if publishedAt <= 0 {
+ publishedAt = now
+ }
+ item := &entity.QueueItem{
+ ID: uuid.NewString(),
+ TenantID: tenantID,
+ OwnerUID: ownerUID,
+ AccountID: accountID,
+ PersonaID: strings.TrimSpace(req.PersonaID),
+ CopyMissionID: strings.TrimSpace(req.CopyMissionID),
+ CopyDraftID: strings.TrimSpace(req.CopyDraftID),
+ Text: strings.TrimSpace(req.Text),
+ TopicTag: normalizeTopicTag(req.TopicTag),
+ ScheduledAt: publishedAt,
+ Status: entity.StatusPublished,
+ MediaID: mediaID,
+ Permalink: strings.TrimSpace(req.Permalink),
+ PublishedAt: publishedAt,
+ CreateAt: now,
+ UpdateAt: now,
+ }
+ if err := u.repo.Create(ctx, item); err != nil {
+ return nil, err
+ }
+ u.recordEvent(ctx, item, evententity.EventPublished, "", item.Status, "已記錄發布結果")
+ _ = u.publishAnalytics.ScheduleCheckpoints(ctx, padomain.ScheduleCheckpointsRequest{
+ TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID,
+ MediaID: mediaID, Permalink: item.Permalink, PublishedAt: publishedAt,
+ })
+ out := toSummary(*item)
+ return &out, nil
+}
+
+func (u *publishQueueUseCase) Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*domusecase.QueueItemSummary, error) {
+ if err := requireActor(tenantID, ownerUID); err != nil {
+ return nil, err
+ }
+ item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID)
+ if err != nil {
+ return nil, err
+ }
+ out := toSummary(*item)
+ return &out, nil
+}
+
+func (u *publishQueueUseCase) ListByIDs(ctx context.Context, tenantID, ownerUID string, ids []string) ([]domusecase.QueueItemSummary, error) {
+ if err := requireActor(tenantID, ownerUID); err != nil {
+ return nil, err
+ }
+ items, err := u.repo.ListByIDs(ctx, tenantID, ownerUID, ids)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]domusecase.QueueItemSummary, 0, len(items))
+ for _, item := range items {
+ out = append(out, toSummary(item))
+ }
+ return out, nil
+}
+
+func (u *publishQueueUseCase) List(ctx context.Context, req domusecase.ListRequest) (*domusecase.ListResult, error) {
+ if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
+ return nil, err
+ }
+ page, pageSize := req.Page, req.PageSize
+ if page <= 0 {
+ page = 1
+ }
+ if pageSize <= 0 {
+ pageSize = 10
+ }
+ if pageSize > 50 {
+ pageSize = 50
+ }
+ result, err := u.repo.List(ctx, domrepo.ListFilter{
+ TenantID: req.TenantID,
+ OwnerUID: req.OwnerUID,
+ AccountID: req.AccountID,
+ Status: req.Status,
+ }, page, pageSize)
+ if err != nil {
+ return nil, err
+ }
+ items := make([]domusecase.QueueItemSummary, 0, len(result.Items))
+ for _, item := range result.Items {
+ items = append(items, toSummary(item))
+ }
+ totalPages := 0
+ if pageSize > 0 {
+ totalPages = int((result.Total + int64(pageSize) - 1) / int64(pageSize))
+ }
+ return &domusecase.ListResult{
+ Items: items,
+ Total: result.Total,
+ Page: page,
+ PageSize: pageSize,
+ TotalPages: totalPages,
+ }, nil
+}
+
+func (u *publishQueueUseCase) ListRange(ctx context.Context, req domusecase.RangeRequest) (*domusecase.ListResult, error) {
+ if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
+ return nil, err
+ }
+ page, pageSize := req.Page, req.PageSize
+ if page <= 0 {
+ page = 1
+ }
+ if pageSize <= 0 {
+ pageSize = 50
+ }
+ if pageSize > 200 {
+ pageSize = 200
+ }
+ result, err := u.repo.List(ctx, domrepo.ListFilter{
+ TenantID: req.TenantID, OwnerUID: req.OwnerUID, AccountID: req.AccountID,
+ Status: req.Status, StartAt: req.StartAt, EndAt: req.EndAt,
+ }, page, pageSize)
+ if err != nil {
+ return nil, err
+ }
+ items := make([]domusecase.QueueItemSummary, 0, len(result.Items))
+ for _, item := range result.Items {
+ items = append(items, toSummary(item))
+ }
+ totalPages := 0
+ if pageSize > 0 {
+ totalPages = int((result.Total + int64(pageSize) - 1) / int64(pageSize))
+ }
+ return &domusecase.ListResult{Items: items, Total: result.Total, Page: page, PageSize: pageSize, TotalPages: totalPages}, nil
+}
+
+func (u *publishQueueUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.QueueItemSummary, error) {
+ if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
+ return nil, err
+ }
+ item, err := u.repo.Get(ctx, req.TenantID, req.OwnerUID, req.AccountID, req.QueueID)
+ if err != nil {
+ return nil, err
+ }
+ if item.Status != entity.StatusScheduled {
+ return nil, app.For(code.ThreadsAccount).ResInvalidState("僅排程中的項目可修改")
+ }
+ if req.Text != nil {
+ text := strings.TrimSpace(*req.Text)
+ if text == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("text is required")
+ }
+ if err := threadspost.ValidatePublish(text); err != nil {
+ return nil, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error())
+ }
+ item.Text = text
+ }
+ if req.ScheduledAt != nil && *req.ScheduledAt > 0 {
+ item.ScheduledAt = *req.ScheduledAt
+ }
+ if req.TopicTag != nil {
+ item.TopicTag = normalizeTopicTag(*req.TopicTag)
+ }
+ item.UpdateAt = clock.NowUnixNano()
+ updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled})
+ if err != nil {
+ return nil, err
+ }
+ out := toSummary(*updated)
+ return &out, nil
+}
+
+func (u *publishQueueUseCase) Cancel(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*domusecase.QueueItemSummary, error) {
+ if err := requireActor(tenantID, ownerUID); err != nil {
+ return nil, err
+ }
+ item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID)
+ if err != nil {
+ return nil, err
+ }
+ if item.Status != entity.StatusScheduled {
+ return nil, app.For(code.ThreadsAccount).ResInvalidState("僅排程中的項目可取消")
+ }
+ item.Status = entity.StatusCancelled
+ item.UpdateAt = clock.NowUnixNano()
+ updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled})
+ if err != nil {
+ return nil, err
+ }
+ u.recordEvent(ctx, updated, evententity.EventCancelled, entity.StatusScheduled, updated.Status, "已取消排程")
+ out := toSummary(*updated)
+ return &out, nil
+}
+
+func (u *publishQueueUseCase) Retry(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*domusecase.QueueItemSummary, error) {
+ if err := requireActor(tenantID, ownerUID); err != nil {
+ return nil, err
+ }
+ item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID)
+ if err != nil {
+ return nil, err
+ }
+ if item.Status != entity.StatusFailed && item.Status != entity.StatusCancelled && item.Status != entity.StatusMissed {
+ return nil, app.For(code.ThreadsAccount).ResInvalidState("僅 failed/cancelled/missed 項目可重試")
+ }
+ from := item.Status
+ item.Status = entity.StatusScheduled
+ item.ErrorMessage = ""
+ item.PausedReason = ""
+ item.MissedAt = 0
+ item.RetryCount++
+ item.NextAttemptAt = 0
+ now := clock.NowUnixNano()
+ item.UpdateAt = now
+ if item.ScheduledAt < now {
+ item.ScheduledAt = now
+ }
+ updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusFailed, entity.StatusCancelled, entity.StatusMissed})
+ if err != nil {
+ return nil, err
+ }
+ u.recordEvent(ctx, updated, evententity.EventRetry, from, updated.Status, "已重新排程")
+ out := toSummary(*updated)
+ return &out, nil
+}
+
+func (u *publishQueueUseCase) Delete(ctx context.Context, tenantID, ownerUID, accountID, queueID string) error {
+ if err := requireActor(tenantID, ownerUID); err != nil {
+ return err
+ }
+ item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID)
+ if err != nil {
+ return err
+ }
+ switch item.Status {
+ case entity.StatusPublishing:
+ return app.For(code.ThreadsAccount).ResInvalidState("發佈進行中,無法刪除")
+ case entity.StatusPublished:
+ return app.For(code.ThreadsAccount).ResInvalidState("已發佈項目不可刪除")
+ case entity.StatusScheduled:
+ _, err = u.Cancel(ctx, tenantID, ownerUID, accountID, queueID)
+ return err
+ default:
+ return u.repo.Delete(ctx, tenantID, ownerUID, accountID, queueID)
+ }
+}
+
+func (u *publishQueueUseCase) DispatchDue(ctx context.Context, limit int) (int, error) {
+ if u.repo == nil {
+ return 0, nil
+ }
+ now := clock.NowUnixNano()
+ due, err := u.repo.ListDue(ctx, now, limit)
+ if err != nil {
+ return 0, err
+ }
+ dispatched := 0
+ for _, item := range due {
+ if err := u.dispatchOne(ctx, &item); err != nil {
+ continue
+ }
+ dispatched++
+ }
+ return dispatched, nil
+}
+
+func (u *publishQueueUseCase) dispatchOne(ctx context.Context, item *entity.QueueItem) error {
+ if result, err := u.checkGuard(ctx, item); err == nil && result != nil && !result.Allowed {
+ if result.ShouldPause && u.guard != nil {
+ _, _ = u.guard.Pause(ctx, item.TenantID, item.OwnerUID, item.AccountID, result.Reason)
+ }
+ item.PausedReason = result.Reason
+ if result.DelayUntil > 0 {
+ item.ScheduledAt = result.DelayUntil
+ } else {
+ item.Status = entity.StatusFailed
+ item.ErrorMessage = result.Reason
+ }
+ item.UpdateAt = clock.NowUnixNano()
+ updated, updateErr := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled})
+ if updateErr != nil {
+ return updateErr
+ }
+ u.recordEvent(ctx, updated, evententity.EventGuardDelayed, entity.StatusScheduled, updated.Status, result.Reason)
+ return nil
+ }
+ item.Status = entity.StatusPublishing
+ item.LastAttemptAt = clock.NowUnixNano()
+ item.UpdateAt = clock.NowUnixNano()
+ claimed, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled})
+ if err != nil {
+ return err
+ }
+ u.recordEvent(ctx, claimed, evententity.EventPublishing, entity.StatusScheduled, entity.StatusPublishing, "開始發布")
+
+ account, err := u.threadsAccount.Get(ctx, claimed.TenantID, claimed.OwnerUID, claimed.AccountID)
+ if err != nil {
+ return u.markFailed(ctx, claimed, "取得帳號失敗:"+err.Error())
+ }
+ token, err := u.threadsAccount.ResolveAccountAPIAccessToken(ctx, claimed.TenantID, claimed.OwnerUID, claimed.AccountID)
+ if err != nil {
+ return u.markFailed(ctx, claimed, err.Error())
+ }
+ userID := strings.TrimSpace(account.ThreadsUserID)
+ if userID == "" || token == "" {
+ return u.markFailed(ctx, claimed, "Threads API 未連線")
+ }
+
+ result, err := libthreads.PublishText(ctx, libthreads.PublishTextInput{
+ ThreadsUserID: userID,
+ AccessToken: token,
+ Text: claimed.Text,
+ TopicTag: claimed.TopicTag,
+ })
+ if err != nil {
+ return u.markFailed(ctx, claimed, "發文失敗:"+err.Error())
+ }
+
+ now := clock.NowUnixNano()
+ claimed.Status = entity.StatusPublished
+ claimed.MediaID = result.MediaID
+ claimed.Permalink = result.Permalink
+ claimed.PublishedAt = now
+ claimed.UpdateAt = now
+ claimed.ErrorMessage = ""
+ if _, err := u.repo.UpdateIfStatus(ctx, claimed, []string{entity.StatusPublishing}); err != nil {
+ return err
+ }
+ u.recordEvent(ctx, claimed, evententity.EventPublished, entity.StatusPublishing, entity.StatusPublished, "Threads API 發布完成")
+ if claimed.CopyDraftID != "" && u.copyDraft != nil {
+ _, _ = u.copyDraft.MarkPublished(ctx, copydraftdomain.MarkPublishedRequest{
+ TenantID: claimed.TenantID, OwnerUID: claimed.OwnerUID, PersonaID: claimed.PersonaID,
+ DraftID: claimed.CopyDraftID, MediaID: result.MediaID, Permalink: result.Permalink,
+ })
+ }
+ _ = u.publishAnalytics.ScheduleCheckpoints(ctx, padomain.ScheduleCheckpointsRequest{
+ TenantID: claimed.TenantID, OwnerUID: claimed.OwnerUID, AccountID: claimed.AccountID,
+ MediaID: result.MediaID, Permalink: result.Permalink, PublishedAt: now,
+ })
+ return nil
+}
+
+func (u *publishQueueUseCase) markFailed(ctx context.Context, item *entity.QueueItem, message string) error {
+ item.Status = entity.StatusFailed
+ item.ErrorMessage = message
+ item.LastAttemptAt = clock.NowUnixNano()
+ item.UpdateAt = clock.NowUnixNano()
+ updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusPublishing, entity.StatusScheduled})
+ if err == nil {
+ u.recordEvent(ctx, updated, evententity.EventFailed, entity.StatusPublishing, entity.StatusFailed, message)
+ failureStreak := u.failureStreak(ctx, item.TenantID, item.OwnerUID, item.AccountID)
+ if u.guard != nil && failureStreak >= 3 {
+ _, _ = u.guard.Pause(ctx, item.TenantID, item.OwnerUID, item.AccountID, "連續發文失敗,已暫停帳號")
+ }
+ }
+ return err
+}
+
+func (u *publishQueueUseCase) ListPublishHealth(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*domusecase.PublishHealthResult, error) {
+ if err := requireActor(tenantID, ownerUID); err != nil {
+ return nil, err
+ }
+ if page <= 0 {
+ page = 1
+ }
+ if pageSize <= 0 {
+ pageSize = 10
+ }
+ if pageSize > 30 {
+ pageSize = 30
+ }
+
+ pending, _ := u.repo.CountByStatus(ctx, tenantID, ownerUID, accountID, entity.StatusScheduled)
+ failed, _ := u.repo.CountByStatus(ctx, tenantID, ownerUID, accountID, entity.StatusFailed)
+
+ publishedList, err := u.repo.List(ctx, domrepo.ListFilter{
+ TenantID: tenantID,
+ OwnerUID: ownerUID,
+ AccountID: accountID,
+ Status: entity.StatusPublished,
+ }, page, pageSize)
+ if err != nil {
+ return nil, err
+ }
+
+ cutoff7d := clock.NowUnixNano() - int64(7*24*time.Hour)
+ var published7d int64
+ var totalLikes7d, totalReplies7d int
+
+ items := make([]domusecase.PublishHealthItem, 0, len(publishedList.Items))
+ for _, row := range publishedList.Items {
+ if row.PublishedAt >= cutoff7d {
+ published7d++
+ }
+ checkpoints, err := u.publishAnalytics.ListByMedia(ctx, tenantID, ownerUID, row.MediaID)
+ if err != nil {
+ checkpoints = nil
+ }
+ healthCPs := make([]domusecase.PublishHealthCheckpoint, 0, len(checkpoints))
+ var latestLike, latestReply, latestRepost, latestQuote, latestViews, latestShares int
+ var latestChecked int64
+ for _, cp := range checkpoints {
+ healthCPs = append(healthCPs, domusecase.PublishHealthCheckpoint{
+ Checkpoint: cp.Checkpoint,
+ LikeCount: cp.LikeCount,
+ ReplyCount: cp.ReplyCount,
+ RepostCount: cp.RepostCount,
+ QuoteCount: cp.QuoteCount,
+ Views: cp.ViewCount,
+ CheckedAt: cp.CheckedAt,
+ })
+ if cp.CheckedAt >= latestChecked {
+ latestChecked = cp.CheckedAt
+ latestLike = cp.LikeCount
+ latestReply = cp.ReplyCount
+ latestRepost = cp.RepostCount
+ latestQuote = cp.QuoteCount
+ latestViews = cp.ViewCount
+ latestShares = cp.ShareCount
+ }
+ }
+ if row.PublishedAt >= cutoff7d {
+ totalLikes7d += latestLike
+ totalReplies7d += latestReply
+ }
+ items = append(items, domusecase.PublishHealthItem{
+ MediaID: row.MediaID,
+ Permalink: row.Permalink,
+ Text: row.Text,
+ PublishedAt: row.PublishedAt,
+ LikeCount: latestLike,
+ ReplyCount: latestReply,
+ RepostCount: latestRepost,
+ QuoteCount: latestQuote,
+ Views: latestViews,
+ Shares: latestShares,
+ Checkpoints: healthCPs,
+ })
+ }
+
+ totalPages := 0
+ if pageSize > 0 {
+ totalPages = int((publishedList.Total + int64(pageSize) - 1) / int64(pageSize))
+ }
+ return &domusecase.PublishHealthResult{
+ Summary: domusecase.PublishHealthSummary{
+ PendingScheduled: pending,
+ FailedCount: failed,
+ Published7d: published7d,
+ TotalLikes7d: totalLikes7d,
+ TotalReplies7d: totalReplies7d,
+ },
+ Items: items,
+ Total: publishedList.Total,
+ Page: page,
+ PageSize: pageSize,
+ TotalPages: totalPages,
+ }, nil
+}
+
+func (u *publishQueueUseCase) MarkMissed(ctx context.Context, graceNs int64, limit int) (int, error) {
+ if graceNs <= 0 {
+ graceNs = int64(10 * time.Minute)
+ }
+ before := clock.NowUnixNano() - graceNs
+ items, err := u.repo.ListMissedCandidates(ctx, before, limit)
+ if err != nil {
+ return 0, err
+ }
+ count := 0
+ for _, item := range items {
+ item.Status = entity.StatusMissed
+ item.MissedAt = clock.NowUnixNano()
+ item.UpdateAt = item.MissedAt
+ updated, err := u.repo.UpdateIfStatus(ctx, &item, []string{entity.StatusScheduled})
+ if err != nil {
+ continue
+ }
+ u.recordEvent(ctx, updated, evententity.EventMissed, entity.StatusScheduled, entity.StatusMissed, "超過 grace period 未發布")
+ count++
+ }
+ return count, nil
+}
+
+func (u *publishQueueUseCase) ListAlerts(ctx context.Context, tenantID, ownerUID, accountID string) ([]domusecase.Alert, error) {
+ if err := requireActor(tenantID, ownerUID); err != nil {
+ return nil, err
+ }
+ result, err := u.repo.List(ctx, domrepo.ListFilter{TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID}, 1, 50)
+ if err != nil {
+ return nil, err
+ }
+ alerts := make([]domusecase.Alert, 0)
+ for _, item := range result.Items {
+ switch item.Status {
+ case entity.StatusFailed:
+ alerts = append(alerts, domusecase.Alert{Type: "failed", Severity: "danger", Message: firstNonEmpty(item.ErrorMessage, "發文失敗"), QueueID: item.ID, AccountID: item.AccountID, CreateAt: item.UpdateAt})
+ case entity.StatusMissed:
+ alerts = append(alerts, domusecase.Alert{Type: "missed", Severity: "warning", Message: "排程已漏發", QueueID: item.ID, AccountID: item.AccountID, CreateAt: item.MissedAt})
+ }
+ if item.PausedReason != "" {
+ alerts = append(alerts, domusecase.Alert{Type: "guard", Severity: "warning", Message: item.PausedReason, QueueID: item.ID, AccountID: item.AccountID, CreateAt: item.UpdateAt})
+ }
+ }
+ return alerts, nil
+}
+
+func (u *publishQueueUseCase) ListSlotInsights(ctx context.Context, tenantID, ownerUID, accountID, timezone string) ([]domusecase.SlotInsight, error) {
+ if err := requireActor(tenantID, ownerUID); err != nil {
+ return nil, err
+ }
+ health, err := u.ListPublishHealth(ctx, tenantID, ownerUID, accountID, 1, 30)
+ if err != nil {
+ return nil, err
+ }
+ type agg struct {
+ likes7d int
+ replies7d int
+ samples int
+ }
+ slots := map[string]*agg{}
+ for _, item := range health.Items {
+ t := time.Unix(0, item.PublishedAt).UTC()
+ key := t.Format("15:04")
+ if _, ok := slots[key]; !ok {
+ slots[key] = &agg{}
+ }
+ slots[key].likes7d += item.LikeCount
+ slots[key].replies7d += item.ReplyCount
+ slots[key].samples++
+ }
+ out := make([]domusecase.SlotInsight, 0, len(slots))
+ for key, item := range slots {
+ avgLikes := 0
+ avgReplies := 0
+ if item.samples > 0 {
+ avgLikes = item.likes7d / item.samples
+ avgReplies = item.replies7d / item.samples
+ }
+ recommendation := "neutral"
+ if avgLikes+avgReplies >= 5 {
+ recommendation = "recommended"
+ } else if item.samples >= 2 {
+ recommendation = "low"
+ }
+ out = append(out, domusecase.SlotInsight{
+ Weekday: -1, Time: key, AvgLikes1h: avgLikes, AvgReplies1h: avgReplies,
+ AvgLikes24h: avgLikes, AvgReplies24h: avgReplies, AvgLikes7d: avgLikes, AvgReplies7d: avgReplies,
+ SampleSize: item.samples, Recommendation: recommendation,
+ })
+ }
+ if len(out) == 0 {
+ out = []domusecase.SlotInsight{
+ {Weekday: 1, Time: "09:30", Recommendation: "recommended"},
+ {Weekday: 3, Time: "12:30", Recommendation: "neutral"},
+ {Weekday: 5, Time: "18:30", Recommendation: "recommended"},
+ }
+ }
+ return out, nil
+}
+
+func (u *publishQueueUseCase) checkGuard(ctx context.Context, item *entity.QueueItem) (*guarddomain.CheckResult, error) {
+ if u.guard == nil || item == nil {
+ return &guarddomain.CheckResult{Allowed: true}, nil
+ }
+ now := clock.NowUnixNano()
+ publishedToday, _ := u.repo.CountPublishedSince(ctx, item.TenantID, item.OwnerUID, item.AccountID, startOfUTCDay(now))
+ lastPublishedAt, _ := u.repo.LastPublishedAt(ctx, item.TenantID, item.OwnerUID, item.AccountID)
+ return u.guard.Check(ctx, guarddomain.CheckRequest{
+ TenantID: item.TenantID, OwnerUID: item.OwnerUID, AccountID: item.AccountID,
+ Now: now, PublishedToday: publishedToday, LastPublishedAt: lastPublishedAt, FailureStreak: u.failureStreak(ctx, item.TenantID, item.OwnerUID, item.AccountID),
+ })
+}
+
+func (u *publishQueueUseCase) failureStreak(ctx context.Context, tenantID, ownerUID, accountID string) int {
+ result, err := u.repo.List(ctx, domrepo.ListFilter{TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID}, 1, 10)
+ if err != nil {
+ return 0
+ }
+ streak := 0
+ for _, item := range result.Items {
+ if item.Status == entity.StatusFailed {
+ streak++
+ continue
+ }
+ if item.Status == entity.StatusPublished {
+ break
+ }
+ }
+ return streak
+}
+
+func (u *publishQueueUseCase) recordEvent(ctx context.Context, item *entity.QueueItem, eventType, fromStatus, toStatus, message string) {
+ if u.events == nil || item == nil {
+ return
+ }
+ _ = u.events.Record(ctx, eventdomain.RecordRequest{
+ TenantID: item.TenantID, OwnerUID: item.OwnerUID, AccountID: item.AccountID, QueueID: item.ID,
+ EventType: eventType, FromStatus: fromStatus, ToStatus: toStatus, Message: message,
+ })
+}
+
+func startOfUTCDay(ns int64) int64 {
+ t := time.Unix(0, ns).UTC()
+ return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC).UnixNano()
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
+
+func requireActor(tenantID, ownerUID string) error {
+ if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
+ return app.For(code.Auth).AuthUnauthorized("missing actor")
+ }
+ return nil
+}
+
+func toSummary(item entity.QueueItem) domusecase.QueueItemSummary {
+ return domusecase.QueueItemSummary{
+ ID: item.ID,
+ AccountID: item.AccountID,
+ PersonaID: item.PersonaID,
+ CopyMissionID: item.CopyMissionID,
+ CopyDraftID: item.CopyDraftID,
+ Text: item.Text,
+ TopicTag: item.TopicTag,
+ ScheduledAt: item.ScheduledAt,
+ Status: item.Status,
+ MediaID: item.MediaID,
+ Permalink: item.Permalink,
+ PublishedAt: item.PublishedAt,
+ ErrorMessage: item.ErrorMessage,
+ RetryCount: item.RetryCount,
+ LastAttemptAt: item.LastAttemptAt,
+ NextAttemptAt: item.NextAttemptAt,
+ MissedAt: item.MissedAt,
+ PausedReason: item.PausedReason,
+ CreateAt: item.CreateAt,
+ UpdateAt: item.UpdateAt,
+ }
+}
+
+func normalizeTopicTag(value string) string {
+ value = strings.TrimSpace(value)
+ value = strings.TrimPrefix(value, "#")
+ value = strings.TrimSpace(value)
+ if len([]rune(value)) > 40 {
+ runes := []rune(value)
+ value = string(runes[:40])
+ }
+ return value
+}
diff --git a/backend/internal/model/publish_queue_event/domain/entity/event.go b/backend/internal/model/publish_queue_event/domain/entity/event.go
new file mode 100644
index 0000000..9a8c938
--- /dev/null
+++ b/backend/internal/model/publish_queue_event/domain/entity/event.go
@@ -0,0 +1,27 @@
+package entity
+
+const CollectionName = "publish_queue_events"
+
+const (
+ EventCreated = "created"
+ EventRetry = "retry"
+ EventPublishing = "publishing"
+ EventPublished = "published"
+ EventFailed = "failed"
+ EventCancelled = "cancelled"
+ EventMissed = "missed"
+ EventGuardDelayed = "guard_delayed"
+)
+
+type Event struct {
+ ID string `bson:"_id"`
+ TenantID string `bson:"tenant_id"`
+ OwnerUID string `bson:"owner_uid"`
+ AccountID string `bson:"account_id"`
+ QueueID string `bson:"queue_id"`
+ EventType string `bson:"event_type"`
+ FromStatus string `bson:"from_status,omitempty"`
+ ToStatus string `bson:"to_status,omitempty"`
+ Message string `bson:"message,omitempty"`
+ CreateAt int64 `bson:"create_at"`
+}
diff --git a/backend/internal/model/publish_queue_event/domain/repository/repository.go b/backend/internal/model/publish_queue_event/domain/repository/repository.go
new file mode 100644
index 0000000..ec04c8d
--- /dev/null
+++ b/backend/internal/model/publish_queue_event/domain/repository/repository.go
@@ -0,0 +1,15 @@
+package repository
+
+import (
+ "context"
+
+ "haixun-backend/internal/model/publish_queue_event/domain/entity"
+)
+
+type Repository interface {
+ EnsureIndexes(ctx context.Context) error
+ Create(ctx context.Context, event *entity.Event) error
+ ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]entity.Event, error)
+ ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]entity.Event, error)
+ DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error
+}
diff --git a/backend/internal/model/publish_queue_event/domain/usecase/usecase.go b/backend/internal/model/publish_queue_event/domain/usecase/usecase.go
new file mode 100644
index 0000000..704e271
--- /dev/null
+++ b/backend/internal/model/publish_queue_event/domain/usecase/usecase.go
@@ -0,0 +1,31 @@
+package usecase
+
+import "context"
+
+type EventSummary struct {
+ ID string
+ QueueID string
+ AccountID string
+ EventType string
+ FromStatus string
+ ToStatus string
+ Message string
+ CreateAt int64
+}
+
+type RecordRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ QueueID string
+ EventType string
+ FromStatus string
+ ToStatus string
+ Message string
+}
+
+type UseCase interface {
+ Record(ctx context.Context, req RecordRequest) error
+ ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]EventSummary, error)
+ ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]EventSummary, error)
+}
diff --git a/backend/internal/model/publish_queue_event/repository/mongo.go b/backend/internal/model/publish_queue_event/repository/mongo.go
new file mode 100644
index 0000000..310513a
--- /dev/null
+++ b/backend/internal/model/publish_queue_event/repository/mongo.go
@@ -0,0 +1,99 @@
+package repository
+
+import (
+ "context"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/publish_queue_event/domain/entity"
+ domrepo "haixun-backend/internal/model/publish_queue_event/domain/repository"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+type mongoRepository struct {
+ collection *mongo.Collection
+}
+
+func NewMongoRepository(db *mongo.Database) domrepo.Repository {
+ if db == nil {
+ return &mongoRepository{}
+ }
+ return &mongoRepository{collection: db.Collection(entity.CollectionName)}
+}
+
+func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
+ if r.collection == nil {
+ return nil
+ }
+ _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
+ {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "account_id", Value: 1}, {Key: "queue_id", Value: 1}, {Key: "create_at", Value: -1}}},
+ })
+ return err
+}
+
+func (r *mongoRepository) Create(ctx context.Context, event *entity.Event) error {
+ if r.collection == nil {
+ return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ if event == nil {
+ return app.For(code.ThreadsAccount).InputMissingRequired("event is required")
+ }
+ _, err := r.collection.InsertOne(ctx, event)
+ return err
+}
+
+func (r *mongoRepository) ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]entity.Event, error) {
+ filter := actorFilter(tenantID, ownerUID, accountID)
+ filter["queue_id"] = strings.TrimSpace(queueID)
+ return r.list(ctx, filter, limit)
+}
+
+func (r *mongoRepository) ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]entity.Event, error) {
+ return r.list(ctx, actorFilter(tenantID, ownerUID, accountID), limit)
+}
+
+func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error {
+ if r.collection == nil {
+ return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ _, err := r.collection.DeleteMany(ctx, actorFilter(tenantID, ownerUID, accountID))
+ return err
+}
+
+func (r *mongoRepository) list(ctx context.Context, filter bson.M, limit int) ([]entity.Event, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ if limit <= 0 {
+ limit = 50
+ }
+ if limit > 200 {
+ limit = 200
+ }
+ opts := options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(int64(limit))
+ cur, err := r.collection.Find(ctx, filter, opts)
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ var out []entity.Event
+ if err := cur.All(ctx, &out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func actorFilter(tenantID, ownerUID, accountID string) bson.M {
+ filter := bson.M{
+ "tenant_id": strings.TrimSpace(tenantID),
+ "owner_uid": strings.TrimSpace(ownerUID),
+ }
+ if accountID = strings.TrimSpace(accountID); accountID != "" {
+ filter["account_id"] = accountID
+ }
+ return filter
+}
diff --git a/backend/internal/model/publish_queue_event/usecase/usecase.go b/backend/internal/model/publish_queue_event/usecase/usecase.go
new file mode 100644
index 0000000..b109522
--- /dev/null
+++ b/backend/internal/model/publish_queue_event/usecase/usecase.go
@@ -0,0 +1,75 @@
+package usecase
+
+import (
+ "context"
+ "strings"
+
+ "haixun-backend/internal/library/clock"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/publish_queue_event/domain/entity"
+ domrepo "haixun-backend/internal/model/publish_queue_event/domain/repository"
+ domusecase "haixun-backend/internal/model/publish_queue_event/domain/usecase"
+
+ "github.com/google/uuid"
+)
+
+type eventUseCase struct {
+ repo domrepo.Repository
+}
+
+func NewUseCase(repo domrepo.Repository) domusecase.UseCase {
+ return &eventUseCase{repo: repo}
+}
+
+func (u *eventUseCase) Record(ctx context.Context, req domusecase.RecordRequest) error {
+ if strings.TrimSpace(req.TenantID) == "" || strings.TrimSpace(req.OwnerUID) == "" {
+ return app.For(code.Auth).AuthUnauthorized("missing actor")
+ }
+ if strings.TrimSpace(req.AccountID) == "" || strings.TrimSpace(req.QueueID) == "" {
+ return app.For(code.ThreadsAccount).InputMissingRequired("account_id and queue_id are required")
+ }
+ eventType := strings.TrimSpace(req.EventType)
+ if eventType == "" {
+ eventType = entity.EventCreated
+ }
+ return u.repo.Create(ctx, &entity.Event{
+ ID: uuid.NewString(),
+ TenantID: strings.TrimSpace(req.TenantID),
+ OwnerUID: strings.TrimSpace(req.OwnerUID),
+ AccountID: strings.TrimSpace(req.AccountID),
+ QueueID: strings.TrimSpace(req.QueueID),
+ EventType: eventType,
+ FromStatus: strings.TrimSpace(req.FromStatus),
+ ToStatus: strings.TrimSpace(req.ToStatus),
+ Message: strings.TrimSpace(req.Message),
+ CreateAt: clock.NowUnixNano(),
+ })
+}
+
+func (u *eventUseCase) ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]domusecase.EventSummary, error) {
+ items, err := u.repo.ListByQueue(ctx, tenantID, ownerUID, accountID, queueID, limit)
+ if err != nil {
+ return nil, err
+ }
+ return toSummaries(items), nil
+}
+
+func (u *eventUseCase) ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]domusecase.EventSummary, error) {
+ items, err := u.repo.ListRecentByAccount(ctx, tenantID, ownerUID, accountID, limit)
+ if err != nil {
+ return nil, err
+ }
+ return toSummaries(items), nil
+}
+
+func toSummaries(items []entity.Event) []domusecase.EventSummary {
+ out := make([]domusecase.EventSummary, 0, len(items))
+ for _, item := range items {
+ out = append(out, domusecase.EventSummary{
+ ID: item.ID, QueueID: item.QueueID, AccountID: item.AccountID, EventType: item.EventType,
+ FromStatus: item.FromStatus, ToStatus: item.ToStatus, Message: item.Message, CreateAt: item.CreateAt,
+ })
+ }
+ return out
+}
diff --git a/backend/internal/model/scan_post/domain/entity/outreach_patch.go b/backend/internal/model/scan_post/domain/entity/outreach_patch.go
index 23381e6..2cf8893 100644
--- a/backend/internal/model/scan_post/domain/entity/outreach_patch.go
+++ b/backend/internal/model/scan_post/domain/entity/outreach_patch.go
@@ -4,4 +4,5 @@ type OutreachPatch struct {
Status string
PublishedReplyID string
PublishedPermalink string
+ ExternalID string
}
diff --git a/backend/internal/model/scan_post/domain/entity/post.go b/backend/internal/model/scan_post/domain/entity/post.go
index 1f0921f..37227aa 100644
--- a/backend/internal/model/scan_post/domain/entity/post.go
+++ b/backend/internal/model/scan_post/domain/entity/post.go
@@ -28,6 +28,7 @@ type ScanPost struct {
GraphNodeID string `bson:"graph_node_id"`
SearchTag string `bson:"search_tag"`
QueryDimension string `bson:"query_dimension"`
+ RecencyDays int `bson:"recency_days,omitempty"`
ExternalID string `bson:"external_id"`
Permalink string `bson:"permalink"`
Author string `bson:"author"`
diff --git a/backend/internal/model/scan_post/domain/usecase/usecase.go b/backend/internal/model/scan_post/domain/usecase/usecase.go
index 0e0ab16..93102eb 100644
--- a/backend/internal/model/scan_post/domain/usecase/usecase.go
+++ b/backend/internal/model/scan_post/domain/usecase/usecase.go
@@ -24,6 +24,7 @@ type ScanPostSummary struct {
GraphNodeID string
SearchTag string
QueryDimension string
+ RecencyDays int
ExternalID string
Permalink string
Author string
@@ -102,6 +103,7 @@ type UpdateOutreachRequest struct {
Status string
PublishedReplyID string
PublishedPermalink string
+ ExternalID string
}
type CheckpointRequest struct {
diff --git a/backend/internal/model/scan_post/repository/mongo.go b/backend/internal/model/scan_post/repository/mongo.go
index 5c955ff..d7bf37d 100644
--- a/backend/internal/model/scan_post/repository/mongo.go
+++ b/backend/internal/model/scan_post/repository/mongo.go
@@ -8,6 +8,7 @@ import (
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
libmongo "haixun-backend/internal/library/mongo"
+ "haixun-backend/internal/library/placement"
"haixun-backend/internal/model/scan_post/domain/entity"
domrepo "haixun-backend/internal/model/scan_post/domain/repository"
@@ -174,6 +175,7 @@ func (r *mongoRepository) UpsertBatchForScan(ctx context.Context, tenantID, owne
err := r.collection.FindOne(ctx, filter).Decode(&existing)
if err == nil {
post.ID = existing.ID
+ post.ExternalID = placement.PreferReplyExternalID(existing.ExternalID, post.ExternalID)
if existing.CreateAt > 0 {
post.CreateAt = existing.CreateAt
}
@@ -291,6 +293,9 @@ func (r *mongoRepository) UpdateOutreach(
if strings.TrimSpace(patch.PublishedPermalink) != "" {
set["published_permalink"] = strings.TrimSpace(patch.PublishedPermalink)
}
+ if strings.TrimSpace(patch.ExternalID) != "" {
+ set["external_id"] = strings.TrimSpace(patch.ExternalID)
+ }
if len(set) == 0 {
return r.Get(ctx, tenantID, ownerUID, brandID, postID)
}
@@ -450,6 +455,11 @@ func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, f
if filter.SemanticMin > 0 {
query["semantic_score"] = bson.M{"$gte": filter.SemanticMin}
}
+ if filter.Recent7dOnly {
+ for key, value := range recent7dListQuery(time.Now()) {
+ query[key] = value
+ }
+ }
limit := filter.Limit
if limit <= 0 {
limit = 100
@@ -470,9 +480,10 @@ func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, f
return nil, err
}
if filter.Recent7dOnly {
+ now := time.Now()
filtered := make([]entity.ScanPost, 0, len(out))
for _, item := range out {
- if item.Priority == "gold" || item.Priority == "recent" {
+ if matchesRecent7dPost(item, now) {
filtered = append(filtered, item)
}
}
@@ -481,6 +492,36 @@ func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, f
return out, nil
}
+const recent7dWindowDays = placement.IdealMaxPostAgeDays
+
+// recent7dListQuery matches posts from the recency track or with posted_at in the last 7 days.
+func recent7dListQuery(now time.Time) bson.M {
+ cutoff := now.AddDate(0, 0, -recent7dWindowDays).Format("2006-01-02")
+ return bson.M{
+ "$or": []bson.M{
+ {"posted_at": bson.M{"$gte": cutoff}},
+ {
+ "$and": []bson.M{
+ {"posted_at": bson.M{"$in": []interface{}{nil, ""}}},
+ {"recency_days": bson.M{"$lte": recent7dWindowDays, "$gt": 0}},
+ },
+ },
+ },
+ }
+}
+
+func matchesRecent7dPost(item entity.ScanPost, now time.Time) bool {
+ postedAt := strings.TrimSpace(item.PostedAt)
+ if postedAt != "" {
+ ts, ok := placement.ParsePostedAt(postedAt)
+ if ok {
+ cutoff := now.AddDate(0, 0, -recent7dWindowDays)
+ return !ts.Before(cutoff)
+ }
+ }
+ return item.RecencyDays > 0 && item.RecencyDays <= recent7dWindowDays
+}
+
// priorityListFilter maps UI track filters to stored priority values.
// gold posts hit both relevance and recency tracks, so they belong in either filter.
func priorityListFilter(priority string) bson.M {
diff --git a/backend/internal/model/scan_post/repository/mongo_recent7d_filter_test.go b/backend/internal/model/scan_post/repository/mongo_recent7d_filter_test.go
new file mode 100644
index 0000000..7735c30
--- /dev/null
+++ b/backend/internal/model/scan_post/repository/mongo_recent7d_filter_test.go
@@ -0,0 +1,54 @@
+package repository
+
+import (
+ "testing"
+ "time"
+
+ "haixun-backend/internal/model/scan_post/domain/entity"
+
+ "go.mongodb.org/mongo-driver/bson"
+)
+
+func TestMatchesRecent7dPost_Includes7dWindowWithoutDate(t *testing.T) {
+ now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
+ item := entity.ScanPost{RecencyDays: 7}
+ if !matchesRecent7dPost(item, now) {
+ t.Fatal("expected 7d-window post without date to match recent_7d filter")
+ }
+}
+
+func TestMatchesRecent7dPost_Excludes14dWindowWithoutDate(t *testing.T) {
+ now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
+ item := entity.ScanPost{RecencyDays: 14, QueryDimension: "recency"}
+ if matchesRecent7dPost(item, now) {
+ t.Fatal("expected 14d-window post without date to be excluded from recent_7d")
+ }
+}
+
+func TestMatchesRecent7dPost_IncludesPostedWithin7Days(t *testing.T) {
+ now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
+ item := entity.ScanPost{
+ Priority: "relevant",
+ PostedAt: "2026-06-28",
+ }
+ if !matchesRecent7dPost(item, now) {
+ t.Fatal("expected post within 7 days to match")
+ }
+}
+
+func TestMatchesRecent7dPost_ExcludesOldRelevantWithoutDate(t *testing.T) {
+ now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
+ item := entity.ScanPost{Priority: "relevant", QueryDimension: "relevance"}
+ if matchesRecent7dPost(item, now) {
+ t.Fatal("expected old relevance-only post without date to be excluded")
+ }
+}
+
+func TestRecent7dListQuery_HasExpectedClauses(t *testing.T) {
+ now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
+ query := recent7dListQuery(now)
+ orClause, ok := query["$or"].([]bson.M)
+ if !ok || len(orClause) != 2 {
+ t.Fatalf("expected 2 OR branches, got %T %v", query["$or"], query["$or"])
+ }
+}
diff --git a/backend/internal/model/scan_post/usecase/usecase.go b/backend/internal/model/scan_post/usecase/usecase.go
index 05988f8..558eac2 100644
--- a/backend/internal/model/scan_post/usecase/usecase.go
+++ b/backend/internal/model/scan_post/usecase/usecase.go
@@ -104,15 +104,9 @@ func (u *scanPostUseCase) FinalizeScan(ctx context.Context, req domusecase.Repla
if err != nil {
return 0, err
}
- keep := make([]string, 0, len(entities))
- for _, item := range entities {
- if strings.TrimSpace(item.Permalink) != "" {
- keep = append(keep, item.Permalink)
- }
- }
- if err := u.repo.PruneScanJobPosts(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, req.ScanJobID, keep); err != nil {
- return count, err
- }
+ // Do not prune checkpoint posts after finalize. Crawl checkpoints may include
+ // candidates that fail strict final filters; pruning them made patrol results
+ // disappear from the results tab right after a job completed.
return count, nil
}
@@ -146,6 +140,7 @@ func placementCandidatesToEntities(tenantID, ownerUID, brandID, topicID, graphID
GraphNodeID: item.GraphNodeID,
SearchTag: item.SearchTag,
QueryDimension: string(item.QueryDimension),
+ RecencyDays: item.RecencyDays,
ExternalID: item.ExternalID,
Permalink: item.Permalink,
Author: item.Author,
@@ -203,6 +198,7 @@ func (u *scanPostUseCase) UpdateOutreach(ctx context.Context, req domusecase.Upd
Status: status,
PublishedReplyID: req.PublishedReplyID,
PublishedPermalink: req.PublishedPermalink,
+ ExternalID: req.ExternalID,
})
if err != nil {
return nil, err
@@ -384,6 +380,7 @@ func toSummary(item entity.ScanPost) domusecase.ScanPostSummary {
GraphNodeID: item.GraphNodeID,
SearchTag: item.SearchTag,
QueryDimension: item.QueryDimension,
+ RecencyDays: item.RecencyDays,
ExternalID: item.ExternalID,
Permalink: item.Permalink,
Author: item.Author,
diff --git a/backend/internal/model/style_preset/domain/entity/preset.go b/backend/internal/model/style_preset/domain/entity/preset.go
new file mode 100644
index 0000000..43fc367
--- /dev/null
+++ b/backend/internal/model/style_preset/domain/entity/preset.go
@@ -0,0 +1,17 @@
+package entity
+
+const CollectionName = "style_presets"
+
+type Preset struct {
+ ID string `bson:"_id"`
+ TenantID string `bson:"tenant_id"`
+ OwnerUID string `bson:"owner_uid"`
+ PersonaID string `bson:"persona_id"`
+ Name string `bson:"name"`
+ Tone string `bson:"tone,omitempty"`
+ CTA []string `bson:"cta,omitempty"`
+ BannedWords []string `bson:"banned_words,omitempty"`
+ Notes string `bson:"notes,omitempty"`
+ CreateAt int64 `bson:"create_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
diff --git a/backend/internal/model/style_preset/domain/repository/repository.go b/backend/internal/model/style_preset/domain/repository/repository.go
new file mode 100644
index 0000000..7cb0dcd
--- /dev/null
+++ b/backend/internal/model/style_preset/domain/repository/repository.go
@@ -0,0 +1,15 @@
+package repository
+
+import (
+ "context"
+
+ "haixun-backend/internal/model/style_preset/domain/entity"
+)
+
+type Repository interface {
+ EnsureIndexes(ctx context.Context) error
+ List(ctx context.Context, tenantID, ownerUID, personaID string) ([]entity.Preset, error)
+ Get(ctx context.Context, tenantID, ownerUID, personaID, presetID string) (*entity.Preset, error)
+ Upsert(ctx context.Context, preset *entity.Preset) (*entity.Preset, error)
+ Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error
+}
diff --git a/backend/internal/model/style_preset/domain/usecase/usecase.go b/backend/internal/model/style_preset/domain/usecase/usecase.go
new file mode 100644
index 0000000..7486c20
--- /dev/null
+++ b/backend/internal/model/style_preset/domain/usecase/usecase.go
@@ -0,0 +1,34 @@
+package usecase
+
+import "context"
+
+type PresetSummary struct {
+ ID string
+ PersonaID string
+ Name string
+ Tone string
+ CTA []string
+ BannedWords []string
+ Notes string
+ CreateAt int64
+ UpdateAt int64
+}
+
+type UpsertRequest struct {
+ TenantID string
+ OwnerUID string
+ PersonaID string
+ PresetID string
+ Name string
+ Tone string
+ CTA []string
+ BannedWords []string
+ Notes string
+ Apply bool
+}
+
+type UseCase interface {
+ List(ctx context.Context, tenantID, ownerUID, personaID string) ([]PresetSummary, error)
+ Upsert(ctx context.Context, req UpsertRequest) (*PresetSummary, error)
+ Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error
+}
diff --git a/backend/internal/model/style_preset/repository/mongo.go b/backend/internal/model/style_preset/repository/mongo.go
new file mode 100644
index 0000000..63385a6
--- /dev/null
+++ b/backend/internal/model/style_preset/repository/mongo.go
@@ -0,0 +1,109 @@
+package repository
+
+import (
+ "context"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/style_preset/domain/entity"
+ domrepo "haixun-backend/internal/model/style_preset/domain/repository"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+type mongoRepository struct {
+ collection *mongo.Collection
+}
+
+func NewMongoRepository(db *mongo.Database) domrepo.Repository {
+ if db == nil {
+ return &mongoRepository{}
+ }
+ return &mongoRepository{collection: db.Collection(entity.CollectionName)}
+}
+
+func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
+ if r.collection == nil {
+ return nil
+ }
+ _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
+ {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}},
+ })
+ return err
+}
+
+func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID, personaID string) ([]entity.Preset, error) {
+ if r.collection == nil {
+ return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ cur, err := r.collection.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}}))
+ if err != nil {
+ return nil, err
+ }
+ defer cur.Close(ctx)
+ var out []entity.Preset
+ if err := cur.All(ctx, &out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, personaID, presetID string) (*entity.Preset, error) {
+ if r.collection == nil {
+ return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ filter := personaFilter(tenantID, ownerUID, personaID)
+ filter["_id"] = strings.TrimSpace(presetID)
+ var out entity.Preset
+ err := r.collection.FindOne(ctx, filter).Decode(&out)
+ if err != nil {
+ if err == mongo.ErrNoDocuments {
+ return nil, app.For(code.Persona).ResNotFound("style preset not found")
+ }
+ return nil, err
+ }
+ return &out, nil
+}
+
+func (r *mongoRepository) Upsert(ctx context.Context, preset *entity.Preset) (*entity.Preset, error) {
+ if r.collection == nil {
+ return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ if preset == nil {
+ return nil, app.For(code.Persona).InputMissingRequired("preset is required")
+ }
+ filter := personaFilter(preset.TenantID, preset.OwnerUID, preset.PersonaID)
+ filter["_id"] = preset.ID
+ _, err := r.collection.ReplaceOne(ctx, filter, preset, options.Replace().SetUpsert(true))
+ if err != nil {
+ return nil, err
+ }
+ return preset, nil
+}
+
+func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error {
+ if r.collection == nil {
+ return app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ filter := personaFilter(tenantID, ownerUID, personaID)
+ filter["_id"] = strings.TrimSpace(presetID)
+ res, err := r.collection.DeleteOne(ctx, filter)
+ if err != nil {
+ return err
+ }
+ if res.DeletedCount == 0 {
+ return app.For(code.Persona).ResNotFound("style preset not found")
+ }
+ return nil
+}
+
+func personaFilter(tenantID, ownerUID, personaID string) bson.M {
+ return bson.M{
+ "tenant_id": strings.TrimSpace(tenantID),
+ "owner_uid": strings.TrimSpace(ownerUID),
+ "persona_id": strings.TrimSpace(personaID),
+ }
+}
diff --git a/backend/internal/model/style_preset/usecase/usecase.go b/backend/internal/model/style_preset/usecase/usecase.go
new file mode 100644
index 0000000..94a7893
--- /dev/null
+++ b/backend/internal/model/style_preset/usecase/usecase.go
@@ -0,0 +1,135 @@
+package usecase
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "haixun-backend/internal/library/clock"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ personadomain "haixun-backend/internal/model/persona/domain/usecase"
+ "haixun-backend/internal/model/style_preset/domain/entity"
+ domrepo "haixun-backend/internal/model/style_preset/domain/repository"
+ domusecase "haixun-backend/internal/model/style_preset/domain/usecase"
+
+ "github.com/google/uuid"
+)
+
+type stylePresetUseCase struct {
+ repo domrepo.Repository
+ persona personadomain.UseCase
+}
+
+func NewUseCase(repo domrepo.Repository, persona personadomain.UseCase) domusecase.UseCase {
+ return &stylePresetUseCase{repo: repo, persona: persona}
+}
+
+func (u *stylePresetUseCase) List(ctx context.Context, tenantID, ownerUID, personaID string) ([]domusecase.PresetSummary, error) {
+ if err := u.require(ctx, tenantID, ownerUID, personaID); err != nil {
+ return nil, err
+ }
+ items, err := u.repo.List(ctx, tenantID, ownerUID, personaID)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]domusecase.PresetSummary, 0, len(items))
+ for _, item := range items {
+ out = append(out, toSummary(item))
+ }
+ return out, nil
+}
+
+func (u *stylePresetUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.PresetSummary, error) {
+ if err := u.require(ctx, req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
+ return nil, err
+ }
+ name := strings.TrimSpace(req.Name)
+ if name == "" {
+ return nil, app.For(code.Persona).InputMissingRequired("name is required")
+ }
+ now := clock.NowUnixNano()
+ id := strings.TrimSpace(req.PresetID)
+ item := &entity.Preset{ID: id}
+ if id == "" || id == "new" {
+ item.ID = uuid.NewString()
+ item.CreateAt = now
+ } else if existing, err := u.repo.Get(ctx, req.TenantID, req.OwnerUID, req.PersonaID, id); err == nil {
+ item = existing
+ }
+ item.TenantID = req.TenantID
+ item.OwnerUID = req.OwnerUID
+ item.PersonaID = req.PersonaID
+ item.Name = name
+ item.Tone = strings.TrimSpace(req.Tone)
+ item.CTA = cleanList(req.CTA)
+ item.BannedWords = cleanList(req.BannedWords)
+ item.Notes = strings.TrimSpace(req.Notes)
+ item.UpdateAt = now
+ if item.CreateAt == 0 {
+ item.CreateAt = now
+ }
+ saved, err := u.repo.Upsert(ctx, item)
+ if err != nil {
+ return nil, err
+ }
+ if req.Apply && u.persona != nil {
+ profile := formatPresetProfile(saved)
+ _, _ = u.persona.Update(ctx, personadomain.UpdateRequest{
+ TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID,
+ Patch: personadomain.PersonaPatch{StyleProfile: &profile},
+ })
+ }
+ out := toSummary(*saved)
+ return &out, nil
+}
+
+func (u *stylePresetUseCase) Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error {
+ if err := u.require(ctx, tenantID, ownerUID, personaID); err != nil {
+ return err
+ }
+ return u.repo.Delete(ctx, tenantID, ownerUID, personaID, presetID)
+}
+
+func (u *stylePresetUseCase) require(ctx context.Context, tenantID, ownerUID, personaID string) error {
+ if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
+ return app.For(code.Auth).AuthUnauthorized("missing actor")
+ }
+ if strings.TrimSpace(personaID) == "" {
+ return app.For(code.Persona).InputMissingRequired("persona_id is required")
+ }
+ if u.persona != nil {
+ _, err := u.persona.Get(ctx, tenantID, ownerUID, personaID)
+ return err
+ }
+ return nil
+}
+
+func toSummary(item entity.Preset) domusecase.PresetSummary {
+ return domusecase.PresetSummary{
+ ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Tone: item.Tone,
+ CTA: append([]string(nil), item.CTA...), BannedWords: append([]string(nil), item.BannedWords...),
+ Notes: item.Notes, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt,
+ }
+}
+
+func cleanList(values []string) []string {
+ out := make([]string, 0, len(values))
+ seen := map[string]struct{}{}
+ for _, value := range values {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ continue
+ }
+ if _, ok := seen[trimmed]; ok {
+ continue
+ }
+ seen[trimmed] = struct{}{}
+ out = append(out, trimmed)
+ }
+ return out
+}
+
+func formatPresetProfile(item *entity.Preset) string {
+ return fmt.Sprintf("語調:%s\nCTA:%s\n禁用詞:%s\n備註:%s", item.Tone, strings.Join(item.CTA, "、"), strings.Join(item.BannedWords, "、"), item.Notes)
+}
diff --git a/backend/internal/model/threads_account/domain/repository/repository.go b/backend/internal/model/threads_account/domain/repository/repository.go
index e6514a8..b50ca8a 100644
--- a/backend/internal/model/threads_account/domain/repository/repository.go
+++ b/backend/internal/model/threads_account/domain/repository/repository.go
@@ -10,9 +10,14 @@ type Repository interface {
EnsureIndexes(ctx context.Context) error
Create(ctx context.Context, account *entity.Account) (*entity.Account, error)
FindByID(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Account, error)
+ FindByIDGlobal(ctx context.Context, accountID string) (*entity.Account, error)
+ FindByThreadsUserID(ctx context.Context, tenantID, ownerUID, threadsUserID string) (*entity.Account, error)
ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Account, error)
UpdateShell(ctx context.Context, tenantID, ownerUID, accountID string, displayName, username, personaID *string) (*entity.Account, error)
+ UpdateAPIProfile(ctx context.Context, tenantID, ownerUID, accountID, username, threadsUserID, displayName string) (*entity.Account, error)
+ ClearAPIProfile(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Account, error)
SoftDelete(ctx context.Context, tenantID, ownerUID, accountID string) error
+ Delete(ctx context.Context, tenantID, ownerUID, accountID string) error
SetKnownAccounts(ctx context.Context, tenantID, ownerUID, accountID string, known map[string]entity.KnownAccountProfile) error
}
@@ -21,4 +26,18 @@ type SecretsRepository interface {
FindByAccountID(ctx context.Context, accountID string) (*entity.Secrets, error)
SaveBrowserStorageState(ctx context.Context, accountID, storageState string) (*entity.Secrets, error)
SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*entity.Secrets, error)
+ ClearAPIAccessToken(ctx context.Context, accountID string) error
+ DeleteByAccountID(ctx context.Context, accountID string) error
+ ListAPIAccessTokensExpiringBefore(ctx context.Context, beforeNs int64) ([]*entity.Secrets, error)
+}
+
+type OAuthStateStore interface {
+ Save(ctx context.Context, state string, payload OAuthStatePayload) error
+ Consume(ctx context.Context, state string) (*OAuthStatePayload, error)
+}
+
+type OAuthStatePayload struct {
+ TenantID string `json:"tenant_id"`
+ OwnerUID string `json:"owner_uid"`
+ AccountID string `json:"account_id,omitempty"`
}
diff --git a/backend/internal/model/threads_account/domain/usecase/usecase.go b/backend/internal/model/threads_account/domain/usecase/usecase.go
index 2160a40..7d41efd 100644
--- a/backend/internal/model/threads_account/domain/usecase/usecase.go
+++ b/backend/internal/model/threads_account/domain/usecase/usecase.go
@@ -2,6 +2,8 @@ package usecase
import (
"context"
+ "encoding/json"
+ "time"
"haixun-backend/internal/library/placement"
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
@@ -9,16 +11,24 @@ import (
)
type AccountSummary struct {
- ID string
- DisplayName string
- Username string
- ThreadsUserID string
- PersonaID string
- BrowserConnected bool
- ApiConnected bool
- Status string
- CreateAt int64
- UpdateAt int64
+ ID string
+ DisplayName string
+ Username string
+ ThreadsUserID string
+ PersonaID string
+ BrowserConnected bool
+ ApiConnected bool
+ APITokenExpiresAt int64
+ Status string
+ CreateAt int64
+ UpdateAt int64
+}
+
+type TokenRefreshCandidate struct {
+ AccountID string
+ TenantID string
+ OwnerUID string
+ ExpiresAt int64
}
type ConnectionPrefs struct {
@@ -139,6 +149,108 @@ type ListResult struct {
ActiveAccountID string
}
+type OAuthStartResult struct {
+ AuthorizeURL string
+ State string
+ AccountID string
+}
+
+type OAuthCallbackResult struct {
+ AccountID string
+ Username string
+ ThreadsUserID string
+ RedirectURL string
+}
+
+type OAuthDebugLogEntry struct {
+ At int64
+ Level string
+ Stage string
+ AccountID string
+ Message string
+}
+
+type APISmokeTestItem struct {
+ Scope string
+ Name string
+ Status string // ok, error, skip, manual
+ Message string
+ Count int
+}
+
+type APIPlaygroundRequest struct {
+ Action string
+ Query string
+ Username string
+ MediaID string
+ ReplyID string
+ ReplyToID string
+ Text string
+ Permalink string
+ HintText string
+ SkipPrime bool
+ LocationQuery string
+ Limit int
+ Hide bool
+ SearchType string
+}
+
+type APIPlaygroundResult struct {
+ Action string
+ Ok bool
+ Message string
+ Data json.RawMessage
+}
+
+type PostMetricCapability struct {
+ Scope string
+ Name string
+ Metrics []string
+ Trackable bool
+ Note string
+}
+
+type InsightMetricItem struct {
+ Name string
+ Value int
+}
+
+type AccountInsightsBlock struct {
+ Status string
+ Message string
+ Metrics []InsightMetricItem
+}
+
+type PostPerformanceItem struct {
+ MediaID string
+ Text string
+ Permalink string
+ Timestamp string
+ MediaType string
+ MediaURL string
+ ThumbnailURL string
+ TopicTag string
+ Shortcode string
+ LikeCount int
+ ReplyCount int
+ RepostCount int
+ QuoteCount int
+ Views int
+ Shares int
+ InsightsStatus string
+ InsightsMessage string
+}
+
+type PostPerformanceResult struct {
+ AccountID string
+ Username string
+ ThreadsUserID string
+ FetchedAt int64
+ Capabilities []PostMetricCapability
+ AccountInsights AccountInsightsBlock
+ Posts []PostPerformanceItem
+}
+
type UpsertKnownAccountsFromScanRequest struct {
TenantID string
OwnerUID string
@@ -155,6 +267,7 @@ type UseCase interface {
Get(ctx context.Context, tenantID, ownerUID, accountID string) (*AccountSummary, error)
Update(ctx context.Context, req UpdateAccountRequest) (*AccountSummary, error)
Activate(ctx context.Context, tenantID, ownerUID, accountID string) error
+ Delete(ctx context.Context, tenantID, ownerUID, accountID string) error
GetConnection(ctx context.Context, tenantID, ownerUID, accountID string) (*ConnectionData, error)
UpdateConnection(ctx context.Context, req UpdateConnectionRequest) (*ConnectionData, error)
ImportBrowserSession(ctx context.Context, req ImportBrowserSessionRequest) (*ImportBrowserSessionResult, error)
@@ -164,9 +277,22 @@ type UseCase interface {
ResolveAiProviderAPIKey(ctx context.Context, tenantID, ownerUID, accountID, provider, overrideKey string) (string, error)
ResolveWorkerAiCredential(ctx context.Context, tenantID, ownerUID, accountID string) (*WorkerAiCredential, error)
ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error)
+ ResolveMemberResearchAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error)
ResolveMemberPlacementContext(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberContext, error)
ResolveMemberThreadsPublishCredential(ctx context.Context, tenantID, ownerUID string) (*ThreadsPublishCredential, error)
+ ResolveMemberThreadsReplyCredential(ctx context.Context, tenantID, ownerUID string) (*ThreadsPublishCredential, error)
SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*SecretsData, error)
+ StartThreadsOAuth(ctx context.Context, tenantID, ownerUID, accountID string) (*OAuthStartResult, error)
+ CompleteThreadsOAuthCallback(ctx context.Context, code, state string) (*OAuthCallbackResult, error)
+ DisconnectThreadsAPI(ctx context.Context, tenantID, ownerUID, accountID string) (*AccountSummary, error)
+ ListOAuthDebugLogs(limit int) []OAuthDebugLogEntry
+ RecordOAuthDebug(level, stage, accountID, message string)
+ RunAPISmokeTest(ctx context.Context, tenantID, ownerUID, accountID string) ([]APISmokeTestItem, error)
+ RunAPIPlayground(ctx context.Context, tenantID, ownerUID, accountID string, req APIPlaygroundRequest) (*APIPlaygroundResult, error)
+ ListPostPerformance(ctx context.Context, tenantID, ownerUID, accountID string, limit int) (*PostPerformanceResult, error)
+ ResolveAccountAPIAccessToken(ctx context.Context, tenantID, ownerUID, accountID string) (string, error)
+ RefreshAccountAPIAccessToken(ctx context.Context, tenantID, ownerUID, accountID string) (*SecretsData, error)
+ ListTokenRefreshCandidates(ctx context.Context, leadTime time.Duration) ([]TokenRefreshCandidate, error)
ResolveMemberCapabilities(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberCapabilities, error)
GetKnownAccounts(ctx context.Context, tenantID, ownerUID, accountID string) (map[string]entity.KnownAccountProfile, error)
UpsertKnownAccountsFromScan(ctx context.Context, req UpsertKnownAccountsFromScanRequest) error
diff --git a/backend/internal/model/threads_account/repository/mongo.go b/backend/internal/model/threads_account/repository/mongo.go
index dd559e4..9336b67 100644
--- a/backend/internal/model/threads_account/repository/mongo.go
+++ b/backend/internal/model/threads_account/repository/mongo.go
@@ -63,6 +63,25 @@ func (r *mongoRepository) FindByID(ctx context.Context, tenantID, ownerUID, acco
})
}
+func (r *mongoRepository) FindByIDGlobal(ctx context.Context, accountID string) (*entity.Account, error) {
+ return r.findOne(ctx, bson.M{
+ "_id": strings.TrimSpace(accountID),
+ "status": entity.StatusOpen,
+ })
+}
+
+func (r *mongoRepository) FindByThreadsUserID(
+ ctx context.Context,
+ tenantID, ownerUID, threadsUserID string,
+) (*entity.Account, error) {
+ return r.findOne(ctx, bson.M{
+ "tenant_id": tenantID,
+ "owner_uid": ownerUID,
+ "threads_user_id": strings.TrimSpace(threadsUserID),
+ "status": entity.StatusOpen,
+ })
+}
+
func (r *mongoRepository) ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Account, error) {
if r.collection == nil {
return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
@@ -110,6 +129,64 @@ func (r *mongoRepository) UpdateShell(ctx context.Context, tenantID, ownerUID, a
return &out, err
}
+func (r *mongoRepository) UpdateAPIProfile(
+ ctx context.Context,
+ tenantID, ownerUID, accountID, username, threadsUserID, displayName string,
+) (*entity.Account, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ set := bson.M{
+ "update_at": clock.NowUnixNano(),
+ }
+ if v := strings.TrimPrefix(strings.TrimSpace(username), "@"); v != "" {
+ set["username"] = v
+ }
+ if v := strings.TrimSpace(threadsUserID); v != "" {
+ set["threads_user_id"] = v
+ }
+ if v := strings.TrimSpace(displayName); v != "" {
+ set["display_name"] = v
+ }
+ var out entity.Account
+ err := r.collection.FindOneAndUpdate(
+ ctx,
+ bson.M{"_id": accountID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
+ bson.M{"$set": set},
+ options.FindOneAndUpdate().SetReturnDocument(options.After),
+ ).Decode(&out)
+ if err == mongo.ErrNoDocuments {
+ return nil, app.For(code.ThreadsAccount).ResNotFound("threads account not found")
+ }
+ return &out, err
+}
+
+func (r *mongoRepository) ClearAPIProfile(
+ ctx context.Context,
+ tenantID, ownerUID, accountID string,
+) (*entity.Account, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ var out entity.Account
+ err := r.collection.FindOneAndUpdate(
+ ctx,
+ bson.M{"_id": accountID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
+ bson.M{
+ "$set": bson.M{"update_at": clock.NowUnixNano()},
+ "$unset": bson.M{
+ "username": "",
+ "threads_user_id": "",
+ },
+ },
+ options.FindOneAndUpdate().SetReturnDocument(options.After),
+ ).Decode(&out)
+ if err == mongo.ErrNoDocuments {
+ return nil, app.For(code.ThreadsAccount).ResNotFound("threads account not found")
+ }
+ return &out, err
+}
+
func (r *mongoRepository) SoftDelete(ctx context.Context, tenantID, ownerUID, accountID string) error {
if r.collection == nil {
return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
@@ -128,6 +205,23 @@ func (r *mongoRepository) SoftDelete(ctx context.Context, tenantID, ownerUID, ac
return nil
}
+func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, accountID string) error {
+ if r.collection == nil {
+ return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ res, err := r.collection.DeleteOne(
+ ctx,
+ bson.M{"_id": accountID, "tenant_id": tenantID, "owner_uid": ownerUID},
+ )
+ if err != nil {
+ return err
+ }
+ if res.DeletedCount == 0 {
+ return app.For(code.ThreadsAccount).ResNotFound("threads account not found")
+ }
+ return nil
+}
+
func (r *mongoRepository) SetKnownAccounts(
ctx context.Context,
tenantID, ownerUID, accountID string,
diff --git a/backend/internal/model/threads_account/repository/oauth_state_redis.go b/backend/internal/model/threads_account/repository/oauth_state_redis.go
new file mode 100644
index 0000000..f5d7fc7
--- /dev/null
+++ b/backend/internal/model/threads_account/repository/oauth_state_redis.go
@@ -0,0 +1,67 @@
+package repository
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ domrepo "haixun-backend/internal/model/threads_account/domain/repository"
+
+ goredis "github.com/redis/go-redis/v9"
+)
+
+const oauthStateKeyPrefix = "threads_oauth_state:"
+const oauthStateTTL = 10 * time.Minute
+
+type oauthStateRedisStore struct {
+ client *goredis.Client
+}
+
+func NewOAuthStateRedisStore(client *goredis.Client) domrepo.OAuthStateStore {
+ return &oauthStateRedisStore{client: client}
+}
+
+func (s *oauthStateRedisStore) Save(ctx context.Context, state string, payload domrepo.OAuthStatePayload) error {
+ if s.client == nil {
+ return app.For(code.ThreadsAccount).DBUnavailable("Redis is not configured")
+ }
+ state = strings.TrimSpace(state)
+ if state == "" {
+ return app.For(code.ThreadsAccount).InputMissingRequired("oauth state is required")
+ }
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+ return s.client.Set(ctx, oauthStateKeyPrefix+state, raw, oauthStateTTL).Err()
+}
+
+func (s *oauthStateRedisStore) Consume(ctx context.Context, state string) (*domrepo.OAuthStatePayload, error) {
+ if s.client == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Redis is not configured")
+ }
+ state = strings.TrimSpace(state)
+ if state == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("oauth state is required")
+ }
+ key := oauthStateKeyPrefix + state
+ raw, err := s.client.GetDel(ctx, key).Bytes()
+ if err == goredis.Nil {
+ return nil, app.For(code.ThreadsAccount).ResNotFound("oauth state expired or invalid")
+ }
+ if err != nil {
+ return nil, err
+ }
+ var payload domrepo.OAuthStatePayload
+ if err := json.Unmarshal(raw, &payload); err != nil {
+ return nil, fmt.Errorf("parse oauth state: %w", err)
+ }
+ if strings.TrimSpace(payload.TenantID) == "" || strings.TrimSpace(payload.OwnerUID) == "" {
+ return nil, app.For(code.ThreadsAccount).InputInvalidFormat("oauth state payload incomplete")
+ }
+ return &payload, nil
+}
diff --git a/backend/internal/model/threads_account/repository/secrets_mongo.go b/backend/internal/model/threads_account/repository/secrets_mongo.go
index 11fb167..dc527a9 100644
--- a/backend/internal/model/threads_account/repository/secrets_mongo.go
+++ b/backend/internal/model/threads_account/repository/secrets_mongo.go
@@ -56,6 +56,13 @@ func (r *secretsMongoRepository) FindByAccountID(ctx context.Context, accountID
}
out.BrowserStorageState = plain
}
+ if r.cipher != nil && out.APIAccessToken != "" {
+ plain, decErr := r.cipher.Decrypt(out.APIAccessToken)
+ if decErr != nil {
+ return nil, decErr
+ }
+ out.APIAccessToken = plain
+ }
return &out, nil
}
@@ -98,13 +105,21 @@ func (r *secretsMongoRepository) SaveAPIAccessToken(ctx context.Context, account
return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
}
now := clock.NowUnixNano()
+ storedToken := accessToken
+ if r.cipher != nil {
+ enc, encErr := r.cipher.Encrypt(accessToken)
+ if encErr != nil {
+ return nil, encErr
+ }
+ storedToken = enc
+ }
var out entity.Secrets
err := r.collection.FindOneAndUpdate(
ctx,
bson.M{"_id": accountID},
bson.M{
"$set": bson.M{
- "api_access_token": accessToken,
+ "api_access_token": storedToken,
"api_token_expires_at": expiresAt,
"update_at": now,
},
@@ -119,3 +134,76 @@ func (r *secretsMongoRepository) SaveAPIAccessToken(ctx context.Context, account
out.APITokenExpiresAt = expiresAt
return &out, nil
}
+
+func (r *secretsMongoRepository) ListAPIAccessTokensExpiringBefore(
+ ctx context.Context,
+ beforeNs int64,
+) ([]*entity.Secrets, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ if beforeNs <= 0 {
+ return nil, nil
+ }
+ cursor, err := r.collection.Find(ctx, bson.M{
+ "api_access_token": bson.M{"$ne": ""},
+ "api_token_expires_at": bson.M{
+ "$gt": int64(0),
+ "$lte": beforeNs,
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+ defer cursor.Close(ctx)
+ var items []*entity.Secrets
+ if err := cursor.All(ctx, &items); err != nil {
+ return nil, err
+ }
+ for _, item := range items {
+ if item == nil {
+ continue
+ }
+ if r.cipher != nil && item.APIAccessToken != "" {
+ plain, decErr := r.cipher.Decrypt(item.APIAccessToken)
+ if decErr != nil {
+ return nil, decErr
+ }
+ item.APIAccessToken = plain
+ }
+ }
+ return items, nil
+}
+
+func (r *secretsMongoRepository) ClearAPIAccessToken(ctx context.Context, accountID string) error {
+ if r.collection == nil {
+ return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ now := clock.NowUnixNano()
+ res, err := r.collection.UpdateOne(
+ ctx,
+ bson.M{"_id": accountID},
+ bson.M{
+ "$set": bson.M{
+ "api_access_token": "",
+ "api_token_expires_at": int64(0),
+ "update_at": now,
+ },
+ },
+ )
+ if err != nil {
+ return err
+ }
+ if res.MatchedCount == 0 {
+ return nil
+ }
+ return nil
+}
+
+func (r *secretsMongoRepository) DeleteByAccountID(ctx context.Context, accountID string) error {
+ if r.collection == nil {
+ return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ _, err := r.collection.DeleteOne(ctx, bson.M{"_id": accountID})
+ return err
+}
diff --git a/backend/internal/model/threads_account/usecase/ai_credentials.go b/backend/internal/model/threads_account/usecase/ai_credentials.go
index 6b1e1ea..b146c91 100644
--- a/backend/internal/model/threads_account/usecase/ai_credentials.go
+++ b/backend/internal/model/threads_account/usecase/ai_credentials.go
@@ -56,6 +56,36 @@ func (u *threadsAccountUseCase) ResolveMemberAiCredential(
}, nil
}
+func (u *threadsAccountUseCase) ResolveMemberResearchAiCredential(
+ ctx context.Context,
+ tenantID, ownerUID string,
+) (*domusecase.WorkerAiCredential, error) {
+ if err := requireActor(tenantID, ownerUID); err != nil {
+ return nil, err
+ }
+ stored, err := u.loadAiCredentials(ctx, ownerUID, "")
+ if err != nil {
+ return nil, err
+ }
+ provider := strings.TrimSpace(stored.ResearchProvider)
+ model := strings.TrimSpace(stored.ResearchModel)
+ if provider == "" {
+ provider = strings.TrimSpace(stored.Provider)
+ }
+ if model == "" {
+ model = strings.TrimSpace(stored.Model)
+ }
+ apiKey := strings.TrimSpace(stored.ApiKeys[provider])
+ if provider == "" || apiKey == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定研究用 AI API key")
+ }
+ return &domusecase.WorkerAiCredential{
+ Provider: provider,
+ Model: model,
+ APIKey: apiKey,
+ }, nil
+}
+
func (u *threadsAccountUseCase) ResolveWorkerAiCredential(
ctx context.Context,
tenantID, ownerUID, accountID string,
diff --git a/backend/internal/model/threads_account/usecase/api_playground.go b/backend/internal/model/threads_account/usecase/api_playground.go
new file mode 100644
index 0000000..c1f0883
--- /dev/null
+++ b/backend/internal/model/threads_account/usecase/api_playground.go
@@ -0,0 +1,263 @@
+package usecase
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ libthreads "haixun-backend/internal/library/threadsapi"
+ domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
+)
+
+func (u *threadsAccountUseCase) RunAPIPlayground(
+ ctx context.Context,
+ tenantID, ownerUID, accountID string,
+ req domusecase.APIPlaygroundRequest,
+) (*domusecase.APIPlaygroundResult, error) {
+ account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ token, err := u.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ client := libthreads.NewClient(token)
+ if !client.Enabled() {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API 未連線")
+ }
+
+ action := strings.TrimSpace(strings.ToLower(req.Action))
+ if action == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("action 為必填")
+ }
+
+ userID := strings.TrimSpace(account.ThreadsUserID)
+ if userID == "" {
+ if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil {
+ userID = profile.ID.String()
+ }
+ }
+
+ limit := req.Limit
+ if limit <= 0 {
+ limit = 10
+ }
+ if limit > 50 {
+ limit = 50
+ }
+
+ result := &domusecase.APIPlaygroundResult{Action: action}
+ var payload any
+ var runErr error
+
+ switch action {
+ case "me":
+ payload, runErr = libthreads.GetMeProfile(ctx, token)
+ case "user_threads":
+ if userID == "" {
+ runErr = fmt.Errorf("缺少 threads_user_id")
+ } else {
+ payload, runErr = client.UserThreads(ctx, userID, limit)
+ }
+ case "keyword_search":
+ q := strings.TrimSpace(req.Query)
+ if q == "" {
+ runErr = fmt.Errorf("query 為必填")
+ } else {
+ searchType := strings.TrimSpace(req.SearchType)
+ if searchType == "" {
+ searchType = "TOP"
+ }
+ payload, runErr = client.KeywordSearch(ctx, libthreads.KeywordSearchOptions{
+ Query: q, Limit: limit, SearchType: searchType,
+ })
+ }
+ case "profile_lookup":
+ u := strings.TrimPrefix(strings.TrimSpace(req.Username), "@")
+ if u == "" {
+ runErr = fmt.Errorf("username 為必填")
+ } else {
+ payload, runErr = client.ProfileLookup(ctx, u)
+ }
+ case "mentions":
+ if userID == "" {
+ runErr = fmt.Errorf("缺少 threads_user_id")
+ } else {
+ payload, runErr = client.UserMentions(ctx, userID, limit)
+ }
+ case "insights":
+ if userID == "" {
+ runErr = fmt.Errorf("缺少 threads_user_id")
+ } else {
+ payload, runErr = client.UserInsights(ctx, userID, nil)
+ }
+ case "location_search":
+ q := strings.TrimSpace(req.LocationQuery)
+ if q == "" {
+ q = strings.TrimSpace(req.Query)
+ }
+ if q == "" {
+ runErr = fmt.Errorf("location 查詢字串為必填")
+ } else {
+ payload, runErr = client.LocationSearch(ctx, q, limit)
+ }
+ case "media_replies", "media_conversation":
+ mid := strings.TrimSpace(req.MediaID)
+ if mid == "" {
+ runErr = fmt.Errorf("media_id 為必填")
+ } else {
+ if limit < 30 {
+ limit = 30
+ }
+ payload, runErr = client.MediaConversation(ctx, mid, limit)
+ }
+ case "media_stats":
+ mid := strings.TrimSpace(req.MediaID)
+ if mid == "" {
+ runErr = fmt.Errorf("media_id 為必填")
+ } else {
+ payload, runErr = client.GetMediaStats(ctx, mid)
+ }
+ case "media_detail":
+ mid := strings.TrimSpace(req.MediaID)
+ if mid == "" {
+ runErr = fmt.Errorf("media_id 為必填")
+ } else {
+ payload, runErr = client.GetMediaDetail(ctx, mid)
+ }
+ case "media_insights":
+ mid := strings.TrimSpace(req.MediaID)
+ if mid == "" {
+ runErr = fmt.Errorf("media_id 為必填")
+ } else {
+ payload, runErr = client.MediaInsights(ctx, mid, nil)
+ }
+ case "publish_text":
+ text := strings.TrimSpace(req.Text)
+ if userID == "" || text == "" {
+ runErr = fmt.Errorf("threads_user_id 與 text 為必填")
+ } else {
+ payload, runErr = libthreads.PublishText(ctx, libthreads.PublishTextInput{
+ ThreadsUserID: userID, AccessToken: token, Text: text,
+ })
+ }
+ case "prime_reply_target":
+ replyTo := strings.TrimSpace(req.ReplyToID)
+ permalink := strings.TrimSpace(req.Permalink)
+ if replyTo == "" || permalink == "" {
+ runErr = fmt.Errorf("reply_to_id 與 permalink 為必填")
+ } else {
+ hint := strings.TrimSpace(req.HintText)
+ if hint == "" {
+ hint = strings.TrimSpace(req.Query)
+ }
+ if primeErr := client.PrimeReplyTargetForPublish(ctx, libthreads.ResolveReplyMediaInput{
+ ExternalID: replyTo,
+ Permalink: permalink,
+ HintText: hint,
+ }, replyTo); primeErr != nil {
+ runErr = primeErr
+ } else {
+ payload = map[string]string{"primed": replyTo}
+ }
+ }
+ case "publish_reply":
+ text := strings.TrimSpace(req.Text)
+ replyTo := strings.TrimSpace(req.ReplyToID)
+ if userID == "" || text == "" || replyTo == "" {
+ runErr = fmt.Errorf("threads_user_id、reply_to_id、text 為必填")
+ } else {
+ permalink := strings.TrimSpace(req.Permalink)
+ if permalink != "" && !req.SkipPrime {
+ hint := strings.TrimSpace(req.HintText)
+ if hint == "" {
+ hint = strings.TrimSpace(req.Query)
+ }
+ if primeErr := client.PrimeReplyTargetForPublish(ctx, libthreads.ResolveReplyMediaInput{
+ ExternalID: replyTo,
+ Permalink: permalink,
+ HintText: hint,
+ }, replyTo); primeErr != nil {
+ runErr = fmt.Errorf("回覆他人貼文前需先透過 keyword_search 註冊目標:%w", primeErr)
+ }
+ }
+ if runErr == nil {
+ payload, runErr = libthreads.PublishReply(ctx, libthreads.PublishReplyInput{
+ ThreadsUserID: userID, AccessToken: token, ReplyToID: replyTo, Text: text,
+ })
+ }
+ }
+ case "delete_media":
+ mid := strings.TrimSpace(req.MediaID)
+ if mid == "" {
+ runErr = fmt.Errorf("media_id 為必填")
+ } else {
+ runErr = client.DeleteMedia(ctx, mid)
+ if runErr == nil {
+ payload = map[string]string{"deleted": mid}
+ }
+ }
+ case "hide_reply":
+ rid := strings.TrimSpace(req.ReplyID)
+ if rid == "" {
+ runErr = fmt.Errorf("reply_id 為必填")
+ } else {
+ runErr = client.HideReply(ctx, rid, req.Hide)
+ if runErr == nil {
+ payload = map[string]any{"reply_id": rid, "hide": req.Hide}
+ }
+ }
+ case "share_to_instagram":
+ mid := strings.TrimSpace(req.MediaID)
+ if mid == "" {
+ runErr = fmt.Errorf("media_id 為必填")
+ } else {
+ runErr = client.ShareToInstagram(ctx, mid)
+ if runErr == nil {
+ payload = map[string]string{"crossposted": mid}
+ }
+ }
+ case "refresh_token":
+ refreshed, rerr := libthreads.RefreshAccessToken(ctx, token)
+ if rerr != nil {
+ runErr = rerr
+ } else {
+ expiresAt := int64(0)
+ if refreshed.ExpiresIn >= 300 {
+ expiresAt = libthreads.NsFromNow(refreshed.ExpiresIn)
+ }
+ if _, serr := u.secretsRepo.SaveAPIAccessToken(ctx, account.ID, refreshed.AccessToken, expiresAt); serr != nil {
+ runErr = serr
+ } else {
+ payload = map[string]any{
+ "expires_in": refreshed.ExpiresIn,
+ "saved": true,
+ }
+ }
+ }
+ default:
+ return nil, app.For(code.ThreadsAccount).InputInvalidFormat("不支援的 action: " + action)
+ }
+
+ if runErr != nil {
+ result.Ok = false
+ result.Message = runErr.Error()
+ if libthreads.IsPermissionError(runErr) {
+ result.Message += "(請確認 Meta App 已授權此 scope,且帳號在測試人員名單)"
+ }
+ return result, nil
+ }
+
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+ result.Ok = true
+ result.Message = "ok"
+ result.Data = raw
+ return result, nil
+}
diff --git a/backend/internal/model/threads_account/usecase/api_smoke.go b/backend/internal/model/threads_account/usecase/api_smoke.go
new file mode 100644
index 0000000..ecb011e
--- /dev/null
+++ b/backend/internal/model/threads_account/usecase/api_smoke.go
@@ -0,0 +1,168 @@
+package usecase
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ libthreads "haixun-backend/internal/library/threadsapi"
+ domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
+)
+
+func (u *threadsAccountUseCase) RunAPISmokeTest(
+ ctx context.Context,
+ tenantID, ownerUID, accountID string,
+) ([]domusecase.APISmokeTestItem, error) {
+ account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ token, err := u.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ client := libthreads.NewClient(token)
+ if !client.Enabled() {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API token 無效")
+ }
+
+ var out []domusecase.APISmokeTestItem
+ add := func(scope, name, status, message string, count int) {
+ out = append(out, domusecase.APISmokeTestItem{
+ Scope: scope, Name: name, Status: status, Message: message, Count: count,
+ })
+ }
+ run := func(scope, name string, fn func() (int, string, error)) {
+ count, detail, err := fn()
+ if err != nil {
+ msg := err.Error()
+ if libthreads.IsPermissionError(err) {
+ msg += "(請確認 Meta App 已授權此 scope,且帳號在測試人員名單)"
+ }
+ add(scope, name, "error", msg, 0)
+ return
+ }
+ if detail != "" {
+ add(scope, name, "ok", detail, count)
+ return
+ }
+ add(scope, name, "ok", fmt.Sprintf("成功(%d 筆)", count), count)
+ }
+
+ profile, profileErr := libthreads.GetMeProfile(ctx, token)
+ userID := strings.TrimSpace(account.ThreadsUserID)
+ username := strings.TrimSpace(account.Username)
+ if profileErr == nil && profile != nil {
+ if userID == "" {
+ userID = profile.ID.String()
+ }
+ if username == "" {
+ username = profile.Username
+ }
+ }
+
+ run("threads_basic", "GET /me 個人資料", func() (int, string, error) {
+ if profileErr != nil {
+ return 0, "", profileErr
+ }
+ return 1, "user_id=" + profile.ID.String() + " @" + profile.Username, nil
+ })
+
+ if userID == "" {
+ add("threads_basic", "GET /{user-id}/threads 貼文列表", "error", "缺少 threads_user_id,請重新 OAuth", 0)
+ } else {
+ var sampleMediaID string
+ run("threads_basic", "GET /{user-id}/threads 貼文列表", func() (int, string, error) {
+ items, err := client.UserThreads(ctx, userID, 5)
+ if err != nil {
+ return 0, "", err
+ }
+ if len(items) > 0 {
+ sampleMediaID = items[0].ID
+ }
+ return len(items), "", nil
+ })
+ if sampleMediaID != "" {
+ run("threads_manage_insights", "GET /{media-id}/insights 貼文互動", func() (int, string, error) {
+ stats, err := client.GetMediaStats(ctx, sampleMediaID)
+ if err != nil {
+ return 0, "", err
+ }
+ return 1, fmt.Sprintf("likes=%d replies=%d views=%d", stats.LikeCount, stats.ReplyCount, stats.Views), nil
+ })
+ run("threads_read_replies", "GET /{media-id}/replies 留言列表", func() (int, string, error) {
+ items, err := client.MediaReplies(ctx, sampleMediaID, 5)
+ if err != nil {
+ return 0, "", err
+ }
+ return len(items), "", nil
+ })
+ } else {
+ add("threads_manage_insights", "GET /{media-id}/insights 貼文互動", "skip", "帳號尚無貼文,略過", 0)
+ add("threads_read_replies", "GET /{media-id}/replies 留言列表", "skip", "帳號尚無貼文,略過", 0)
+ }
+ }
+
+ run("threads_keyword_search", "GET /keyword_search 關鍵字搜尋", func() (int, string, error) {
+ items, err := client.KeywordSearch(ctx, libthreads.KeywordSearchOptions{
+ Query: "threads", Limit: 5, SearchType: "TOP",
+ })
+ if err != nil {
+ return 0, "", err
+ }
+ return len(items), "", nil
+ })
+
+ if username != "" {
+ run("threads_profile_discovery", "GET /profile_lookup 帳號查詢", func() (int, string, error) {
+ res, err := client.ProfileLookup(ctx, username)
+ if err != nil {
+ return 0, "", err
+ }
+ return 1, "@" + res.Username + " name=" + res.Name, nil
+ })
+ } else {
+ add("threads_profile_discovery", "GET /profile_lookup 帳號查詢", "skip", "缺少 username,略過", 0)
+ }
+
+ if userID != "" {
+ run("threads_manage_mentions", "GET /{user-id}/mentions 提及", func() (int, string, error) {
+ items, err := client.UserMentions(ctx, userID, 5)
+ if err != nil {
+ return 0, "", err
+ }
+ return len(items), "", nil
+ })
+ run("threads_manage_insights", "GET /{user-id}/threads_insights 洞察", func() (int, string, error) {
+ items, err := client.UserInsights(ctx, userID, nil)
+ if err != nil {
+ return 0, "", err
+ }
+ return len(items), fmt.Sprintf("%d 項指標", len(items)), nil
+ })
+ } else {
+ add("threads_manage_mentions", "GET /{user-id}/mentions 提及", "skip", "缺少 threads_user_id", 0)
+ add("threads_manage_insights", "GET /{user-id}/threads_insights 洞察", "skip", "缺少 threads_user_id", 0)
+ }
+
+ run("threads_location_tagging", "GET /location_search 地點搜尋", func() (int, string, error) {
+ items, err := client.LocationSearch(ctx, "Taipei", 5)
+ if err != nil {
+ return 0, "", err
+ }
+ return len(items), "", nil
+ })
+
+ add("threads_content_publish", "POST 發文 / 回覆", "manual",
+ "請用產品 API:POST /personas/:id/copy-drafts/:draftId/publish 或 outreach-drafts/publish", 0)
+ add("threads_delete", "DELETE /{media-id} 刪文", "manual",
+ "會真的刪除貼文,請在 Threads 後台或自行呼叫 DeleteMedia 測試", 0)
+ add("threads_manage_replies", "POST 隱藏留言", "manual",
+ "會改變留言狀態,請用 HideReply(reply_id) 手動測試", 0)
+ add("threads_share_to_instagram", "POST 同步到 IG", "manual",
+ "需已連結 IG 且會真的 crosspost,請用 ShareToInstagram(media_id) 手動測試", 0)
+
+ return out, nil
+}
diff --git a/backend/internal/model/threads_account/usecase/connection_prefs_test.go b/backend/internal/model/threads_account/usecase/connection_prefs_test.go
index 073aafe..2f259e5 100644
--- a/backend/internal/model/threads_account/usecase/connection_prefs_test.go
+++ b/backend/internal/model/threads_account/usecase/connection_prefs_test.go
@@ -6,39 +6,40 @@ import (
domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
)
-func TestApplyConnectionPatchSearchSourceInDevMode(t *testing.T) {
- current := deriveConnectionPrefsFromDevMode(true)
- patch := domusecase.ConnectionPrefsPatch{
- SearchSourceMode: strPtr("brave"),
- }
- next := applyConnectionPatch(current, patch)
- if next.SearchSourceMode != "brave" {
- t.Fatalf("expected brave, got %s", next.SearchSourceMode)
- }
- if !next.DevMode {
- t.Fatal("expected dev mode to stay enabled")
- }
-}
-
func TestApplyConnectionPatchSearchSourceInFormalMode(t *testing.T) {
- current := deriveConnectionPrefsFromDevMode(false)
+ current := formalConnectionPrefs()
patch := domusecase.ConnectionPrefsPatch{
SearchSourceMode: strPtr("threads_crawler"),
}
next := applyConnectionPatch(current, patch)
- if next.SearchSourceMode != "threads_crawler" {
- t.Fatalf("expected threads_crawler, got %s", next.SearchSourceMode)
+ if next.SearchSourceMode != "threads_brave" {
+ t.Fatalf("expected crawler mode stripped to threads_brave, got %s", next.SearchSourceMode)
+ }
+ if next.DevMode {
+ t.Fatal("expected dev_mode to stay disabled")
}
}
-func TestNormalizeConnectionPrefsKeepsCrawlerModeInFormalMode(t *testing.T) {
- prefs := domusecase.ConnectionPrefs{
- DevMode: false,
- SearchSourceMode: "crawler",
- }
+func TestNormalizeConnectionPrefsKeepsScrapeReplies(t *testing.T) {
+ prefs := formalConnectionPrefs()
+ prefs.ScrapeReplies = true
next := normalizeConnectionPrefs(prefs)
- if next.SearchSourceMode != "crawler" {
- t.Fatalf("expected crawler, got %s", next.SearchSourceMode)
+ if !next.ScrapeReplies {
+ t.Fatal("expected scrape_replies to persist")
+ }
+ if next.PublishViaApi != true || !next.SearchViaApi {
+ t.Fatal("expected formal API routing")
+ }
+}
+
+func TestApplyConnectionPatchIgnoresLegacyDevMode(t *testing.T) {
+ current := formalConnectionPrefs()
+ enabled := true
+ next := applyConnectionPatch(current, domusecase.ConnectionPrefsPatch{
+ DevMode: &enabled,
+ })
+ if next.DevMode {
+ t.Fatal("account dev_mode should not enable crawler routing")
}
}
diff --git a/backend/internal/model/threads_account/usecase/oauth.go b/backend/internal/model/threads_account/usecase/oauth.go
new file mode 100644
index 0000000..e0b07cf
--- /dev/null
+++ b/backend/internal/model/threads_account/usecase/oauth.go
@@ -0,0 +1,255 @@
+package usecase
+
+import (
+ "context"
+ "strings"
+
+ "haixun-backend/internal/library/clock"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ libthreads "haixun-backend/internal/library/threadsapi"
+ "haixun-backend/internal/model/threads_account/domain/entity"
+ domrepo "haixun-backend/internal/model/threads_account/domain/repository"
+ domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
+
+ "github.com/google/uuid"
+)
+
+func (u *threadsAccountUseCase) StartThreadsOAuth(
+ ctx context.Context,
+ tenantID, ownerUID, accountID string,
+) (*domusecase.OAuthStartResult, error) {
+ if err := requireActor(tenantID, ownerUID); err != nil {
+ return nil, err
+ }
+ if !u.oauth.Enabled() {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads OAuth 尚未設定(THREADS_APP_ID / THREADS_APP_SECRET / THREADS_REDIRECT_URI)")
+ }
+ if u.oauthState == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Redis 未設定,無法保存 OAuth state")
+ }
+
+ accountID = strings.TrimSpace(accountID)
+ var account *entity.Account
+ var err error
+ if accountID != "" {
+ account, err = u.assertOwned(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ existing, listErr := u.repo.ListByOwner(ctx, tenantID, ownerUID)
+ if listErr != nil {
+ return nil, listErr
+ }
+ created, createErr := u.Create(ctx, domusecase.CreateRequest{
+ TenantID: tenantID,
+ OwnerUID: ownerUID,
+ DisplayName: "Threads 帳號 " + itoa(len(existing)+1),
+ Activate: false,
+ })
+ if createErr != nil {
+ return nil, createErr
+ }
+ account, err = u.assertOwned(ctx, tenantID, ownerUID, created.ID)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ state := uuid.NewString()
+ if err := u.oauthState.Save(ctx, state, domrepo.OAuthStatePayload{
+ TenantID: tenantID,
+ OwnerUID: ownerUID,
+ AccountID: account.ID,
+ }); err != nil {
+ return nil, err
+ }
+ authorizeURL, err := libthreads.BuildAuthorizeURL(u.oauth, state)
+ if err != nil {
+ return nil, err
+ }
+ redirectURI := strings.TrimSpace(u.oauth.RedirectURI)
+ if !strings.HasPrefix(strings.ToLower(redirectURI), "https://") {
+ recordOAuthDebug("error", "start", account.ID, "redirect_uri must be https:// (Meta error 1349187): "+redirectURI)
+ return nil, app.For(code.ThreadsAccount).InputInvalidFormat("THREADS_REDIRECT_URI 必須為 https://(Meta 會拒絕 http://,錯誤碼 1349187)")
+ }
+ recordOAuthDebug("info", "start", account.ID, "redirect_uri="+redirectURI)
+ return &domusecase.OAuthStartResult{
+ AuthorizeURL: authorizeURL,
+ State: state,
+ AccountID: account.ID,
+ }, nil
+}
+
+func (u *threadsAccountUseCase) CompleteThreadsOAuthCallback(
+ ctx context.Context,
+ authCode, state string,
+) (*domusecase.OAuthCallbackResult, error) {
+ if !u.oauth.Enabled() {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads OAuth 尚未設定")
+ }
+ if u.oauthState == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Redis 未設定")
+ }
+ recordOAuthDebug("info", "callback", "", "received code len="+itoa(len(strings.TrimSpace(authCode)))+" state len="+itoa(len(strings.TrimSpace(state))))
+ payload, err := u.oauthState.Consume(ctx, state)
+ if err != nil {
+ recordOAuthDebug("error", "consume_state", "", err.Error())
+ return nil, err
+ }
+ short, err := libthreads.ExchangeCodeForToken(ctx, u.oauth, authCode)
+ if err != nil {
+ recordOAuthDebug("error", "exchange_code", payload.AccountID, err.Error())
+ return nil, err
+ }
+ recordOAuthDebug("info", "exchange_code", payload.AccountID, "short-lived ok expires_in="+itoa(short.ExpiresIn))
+ longLived, err := libthreads.ExchangeLongLivedToken(ctx, u.oauth, short.AccessToken)
+ if err != nil {
+ recordOAuthDebug("warn", "long_lived", payload.AccountID, "fallback short-lived: "+err.Error())
+ longLived = short
+ } else {
+ recordOAuthDebug("info", "long_lived", payload.AccountID, "ok expires_in="+itoa(longLived.ExpiresIn))
+ }
+ account, err := u.assertOwned(ctx, payload.TenantID, payload.OwnerUID, payload.AccountID)
+ if err != nil {
+ return nil, err
+ }
+
+ profile, err := libthreads.GetMeProfile(ctx, longLived.AccessToken)
+ threadsUserID := ""
+ username := ""
+ displayName := ""
+ if err != nil {
+ threadsUserID = libthreads.ThreadsUserIDFromToken(short, longLived)
+ if threadsUserID == "" {
+ recordOAuthDebug("error", "profile_me", payload.AccountID, err.Error())
+ if libthreads.IsPermissionError(err) {
+ return nil, app.For(code.ThreadsAccount).AuthForbidden(
+ "Threads API 需要 threads_basic:請到 Meta App Dashboard → 應用程式角色,把你的 Threads 帳號加為「測試人員」或管理員,並確認已啟用「Access the Threads API」",
+ )
+ }
+ return nil, err
+ }
+ recordOAuthDebug("warn", "profile_me", payload.AccountID, "fallback token user_id="+threadsUserID+": "+err.Error())
+ displayName = strings.TrimSpace(account.DisplayName)
+ if displayName == "" {
+ displayName = "Threads " + threadsUserID
+ }
+ } else {
+ threadsUserID = profile.ID.String()
+ username = profile.Username
+ recordOAuthDebug("info", "profile_me", payload.AccountID, "username="+username+" threads_user_id="+threadsUserID)
+ displayName = strings.TrimSpace(profile.Name)
+ if displayName == "" {
+ displayName = "@" + strings.TrimPrefix(username, "@")
+ }
+ }
+
+ if err := u.assertNoAPIBindingConflict(ctx, payload.TenantID, payload.OwnerUID, account.ID, threadsUserID); err != nil {
+ return nil, err
+ }
+
+ updated, err := u.repo.UpdateAPIProfile(
+ ctx,
+ payload.TenantID,
+ payload.OwnerUID,
+ account.ID,
+ username,
+ threadsUserID,
+ displayName,
+ )
+ if err != nil {
+ return nil, err
+ }
+ expiresAt := int64(0)
+ if longLived.ExpiresIn >= 300 {
+ expiresAt = libthreads.NsFromNow(longLived.ExpiresIn)
+ } else if longLived.ExpiresIn > 0 {
+ recordOAuthDebug("warn", "expires_in", payload.AccountID, "suspicious expires_in="+itoa(longLived.ExpiresIn)+"s; store without expiry deadline")
+ }
+ if _, err := u.secretsRepo.SaveAPIAccessToken(ctx, updated.ID, longLived.AccessToken, expiresAt); err != nil {
+ recordOAuthDebug("error", "save_token", updated.ID, err.Error())
+ return nil, err
+ }
+ recordOAuthDebug("info", "success", updated.ID, "api_connected=true username="+updated.Username)
+ if err := u.members.SetActiveThreadsAccountID(ctx, payload.TenantID, payload.OwnerUID, updated.ID); err != nil {
+ return nil, err
+ }
+
+ redirectURL := strings.TrimSpace(u.oauth.SuccessRedirect)
+ if redirectURL == "" {
+ redirectURL = "/"
+ }
+ sep := "?"
+ if strings.Contains(redirectURL, "?") {
+ sep = "&"
+ }
+ return &domusecase.OAuthCallbackResult{
+ AccountID: updated.ID,
+ Username: updated.Username,
+ ThreadsUserID: updated.ThreadsUserID,
+ RedirectURL: redirectURL + sep + "threads_oauth=ok&account_id=" + updated.ID,
+ }, nil
+}
+
+func (u *threadsAccountUseCase) DisconnectThreadsAPI(
+ ctx context.Context,
+ tenantID, ownerUID, accountID string,
+) (*domusecase.AccountSummary, error) {
+ account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ if err := u.secretsRepo.ClearAPIAccessToken(ctx, account.ID); err != nil {
+ return nil, err
+ }
+ if _, err := u.repo.ClearAPIProfile(ctx, tenantID, ownerUID, account.ID); err != nil {
+ return nil, err
+ }
+ return u.toSummary(ctx, account)
+}
+
+func (u *threadsAccountUseCase) assertNoAPIBindingConflict(
+ ctx context.Context,
+ tenantID, ownerUID, accountID, threadsUserID string,
+) error {
+ dup, err := u.repo.FindByThreadsUserID(ctx, tenantID, ownerUID, threadsUserID)
+ if err != nil || dup == nil || dup.ID == accountID {
+ return nil
+ }
+ secrets, err := u.secretsRepo.FindByAccountID(ctx, dup.ID)
+ if err != nil {
+ return err
+ }
+ if isValidAPIToken(secrets) {
+ label := dup.DisplayName
+ if label == "" && dup.Username != "" {
+ label = "@" + dup.Username
+ }
+ if label == "" {
+ label = dup.ID
+ }
+ return app.For(code.ThreadsAccount).ResConflict(
+ "此 Threads 帳號已綁定在「" + label + "」;請先中斷該帳號 API,或選該帳號按「連線選中帳號」重新授權",
+ )
+ }
+ if _, err := u.repo.ClearAPIProfile(ctx, tenantID, ownerUID, dup.ID); err != nil {
+ return err
+ }
+ return nil
+}
+
+func isValidAPIToken(secrets *entity.Secrets) bool {
+ if secrets == nil {
+ return false
+ }
+ token := strings.TrimSpace(secrets.APIAccessToken)
+ if token == "" {
+ return false
+ }
+ if secrets.APITokenExpiresAt > 0 && secrets.APITokenExpiresAt <= clock.NowUnixNano() {
+ return false
+ }
+ return true
+}
diff --git a/backend/internal/model/threads_account/usecase/oauth_debug_log.go b/backend/internal/model/threads_account/usecase/oauth_debug_log.go
new file mode 100644
index 0000000..a0d8302
--- /dev/null
+++ b/backend/internal/model/threads_account/usecase/oauth_debug_log.go
@@ -0,0 +1,102 @@
+package usecase
+
+import (
+ "log"
+ "sync"
+
+ "haixun-backend/internal/library/clock"
+ domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
+)
+
+const oauthDebugLogCap = 80
+
+// OAuthDebugEntry is a recent OAuth event for Dev Console debugging.
+type OAuthDebugEntry struct {
+ At int64 `json:"at"`
+ Level string `json:"level"`
+ Stage string `json:"stage"`
+ AccountID string `json:"account_id,omitempty"`
+ Message string `json:"message"`
+}
+
+var (
+ oauthDebugMu sync.Mutex
+ oauthDebugRing []OAuthDebugEntry
+ oauthDebugSeq int
+)
+
+func recordOAuthDebug(level, stage, accountID, message string) {
+ at := clock.NowUnixNano()
+ entry := OAuthDebugEntry{
+ At: at,
+ Level: level,
+ Stage: stage,
+ AccountID: accountID,
+ Message: message,
+ }
+ log.Printf("threads oauth [%s] %s account=%s %s", stage, level, accountID, message)
+
+ oauthDebugMu.Lock()
+ defer oauthDebugMu.Unlock()
+ if len(oauthDebugRing) < oauthDebugLogCap {
+ oauthDebugRing = append(oauthDebugRing, entry)
+ } else {
+ oauthDebugRing[oauthDebugSeq%oauthDebugLogCap] = entry
+ }
+ oauthDebugSeq++
+}
+
+func (u *threadsAccountUseCase) RecordOAuthDebug(level, stage, accountID, message string) {
+ recordOAuthDebug(level, stage, accountID, message)
+}
+
+func (u *threadsAccountUseCase) ListOAuthDebugLogs(limit int) []domusecase.OAuthDebugLogEntry {
+ raw := listOAuthDebugLogs(limit)
+ out := make([]domusecase.OAuthDebugLogEntry, 0, len(raw))
+ for _, item := range raw {
+ out = append(out, domusecase.OAuthDebugLogEntry{
+ At: item.At,
+ Level: item.Level,
+ Stage: item.Stage,
+ AccountID: item.AccountID,
+ Message: item.Message,
+ })
+ }
+ return out
+}
+
+func listOAuthDebugLogs(limit int) []OAuthDebugEntry {
+ if limit <= 0 || limit > oauthDebugLogCap {
+ limit = oauthDebugLogCap
+ }
+ oauthDebugMu.Lock()
+ defer oauthDebugMu.Unlock()
+ n := len(oauthDebugRing)
+ if n == 0 {
+ return nil
+ }
+ out := make([]OAuthDebugEntry, 0, min(n, limit))
+ if n < oauthDebugLogCap {
+ start := n - min(n, limit)
+ if start < 0 {
+ start = 0
+ }
+ out = append(out, oauthDebugRing[start:n]...)
+ return out
+ }
+ start := oauthDebugSeq - min(oauthDebugSeq, limit)
+ if start < 0 {
+ start = 0
+ }
+ for i := start; i < oauthDebugSeq; i++ {
+ out = append(out, oauthDebugRing[i%oauthDebugLogCap])
+ }
+ return out
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/backend/internal/model/threads_account/usecase/placement_context.go b/backend/internal/model/threads_account/usecase/placement_context.go
index 6492b5a..deb35aa 100644
--- a/backend/internal/model/threads_account/usecase/placement_context.go
+++ b/backend/internal/model/threads_account/usecase/placement_context.go
@@ -53,5 +53,5 @@ func (u *threadsAccountUseCase) ResolveMemberPlacementContext(
prefs.RepliesPerPost,
)
ctxOut.ThreadsAPIAccessToken = apiToken
- return ctxOut, nil
+ return placement.MemberContextForAPIOnly(ctxOut), nil
}
diff --git a/backend/internal/model/threads_account/usecase/post_performance.go b/backend/internal/model/threads_account/usecase/post_performance.go
new file mode 100644
index 0000000..bb29605
--- /dev/null
+++ b/backend/internal/model/threads_account/usecase/post_performance.go
@@ -0,0 +1,189 @@
+package usecase
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ libthreads "haixun-backend/internal/library/threadsapi"
+ domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
+)
+
+func (u *threadsAccountUseCase) ListPostPerformance(
+ ctx context.Context,
+ tenantID, ownerUID, accountID string,
+ limit int,
+) (*domusecase.PostPerformanceResult, error) {
+ account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ token, err := u.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ client := libthreads.NewClient(token)
+ if !client.Enabled() {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API 未連線")
+ }
+
+ if limit <= 0 {
+ limit = 15
+ }
+ if limit > 30 {
+ limit = 30
+ }
+
+ userID := strings.TrimSpace(account.ThreadsUserID)
+ username := strings.TrimSpace(account.Username)
+ if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil {
+ if userID == "" {
+ userID = profile.ID.String()
+ }
+ if username == "" {
+ username = profile.Username
+ }
+ }
+
+ result := &domusecase.PostPerformanceResult{
+ AccountID: account.ID,
+ Username: username,
+ ThreadsUserID: userID,
+ FetchedAt: time.Now().UnixNano(),
+ Capabilities: postPerformanceCapabilities(),
+ AccountInsights: domusecase.AccountInsightsBlock{
+ Status: "skip",
+ Message: "缺少 threads_user_id",
+ },
+ Posts: []domusecase.PostPerformanceItem{},
+ }
+
+ if userID != "" {
+ result.AccountInsights = fetchAccountInsights(ctx, client, userID)
+ posts, err := client.UserThreads(ctx, userID, limit)
+ if err != nil {
+ return nil, err
+ }
+ for _, post := range posts {
+ item := domusecase.PostPerformanceItem{
+ MediaID: post.ID,
+ Text: post.Text,
+ Permalink: post.Permalink,
+ Timestamp: post.Timestamp,
+ MediaType: post.MediaType,
+ MediaURL: post.MediaURL,
+ ThumbnailURL: post.ThumbnailURL,
+ TopicTag: post.TopicTag,
+ Shortcode: post.Shortcode,
+ LikeCount: post.LikeCount,
+ ReplyCount: post.ReplyCount,
+ }
+ if stats, serr := client.GetMediaStats(ctx, post.ID); serr == nil && stats != nil {
+ item.LikeCount = stats.LikeCount
+ item.ReplyCount = stats.ReplyCount
+ item.RepostCount = stats.RepostCount
+ item.QuoteCount = stats.QuoteCount
+ item.Views = stats.Views
+ item.Shares = stats.Shares
+ item.InsightsStatus = "ok"
+ } else {
+ item.InsightsStatus = "error"
+ if serr != nil {
+ item.InsightsMessage = serr.Error()
+ } else {
+ item.InsightsMessage = "無法讀取貼文洞察"
+ }
+ }
+ result.Posts = append(result.Posts, item)
+ }
+ }
+
+ return result, nil
+}
+
+func postPerformanceCapabilities() []domusecase.PostMetricCapability {
+ return []domusecase.PostMetricCapability{
+ {
+ Scope: "threads_manage_insights", Name: "貼文互動數",
+ Metrics: []string{"likes", "replies", "reposts", "quotes", "views", "shares"},
+ Trackable: true, Note: "GET /{media-id}/insights(非 media 物件欄位)",
+ },
+ {
+ Scope: "threads_manage_insights", Name: "貼文洞察",
+ Metrics: []string{"views", "likes", "replies", "reposts", "quotes", "shares"},
+ Trackable: true, Note: "GET /{media-id}/insights(lifetime)",
+ },
+ {
+ Scope: "threads_manage_insights", Name: "帳號洞察",
+ Metrics: []string{"views", "likes", "replies", "reposts", "quotes", "followers_count", "clicks"},
+ Trackable: true, Note: "GET /{user-id}/threads_insights",
+ },
+ {
+ Scope: "threads_read_replies", Name: "留言內容",
+ Metrics: []string{"reply_list"},
+ Trackable: true, Note: "GET /{media-id}/replies(非數字統計)",
+ },
+ {
+ Scope: "threads_keyword_search", Name: "關鍵字搜尋",
+ Trackable: false, Note: "搜尋他人貼文,非你的發文成效",
+ },
+ {
+ Scope: "threads_profile_discovery", Name: "帳號查詢",
+ Trackable: false, Note: "查他人 profile,非成效統計",
+ },
+ {
+ Scope: "threads_manage_mentions", Name: "提及",
+ Trackable: false, Note: "誰 @ 你,非單篇貼文成效",
+ },
+ {
+ Scope: "threads_location_tagging", Name: "地點",
+ Trackable: false, Note: "地點標籤搜尋",
+ },
+ {
+ Scope: "threads_content_publish", Name: "發文",
+ Trackable: false, Note: "寫入類;發文後用本頁追蹤成效",
+ },
+ {
+ Scope: "threads_delete", Name: "刪文",
+ Trackable: false, Note: "寫入類",
+ },
+ {
+ Scope: "threads_manage_replies", Name: "留言管理",
+ Trackable: false, Note: "隱藏留言,非統計",
+ },
+ {
+ Scope: "threads_share_to_instagram", Name: "同步 IG",
+ Trackable: false, Note: "crosspost 動作",
+ },
+ }
+}
+
+func fetchAccountInsights(ctx context.Context, client *libthreads.Client, userID string) domusecase.AccountInsightsBlock {
+ items, err := client.UserInsights(ctx, userID, nil)
+ if err != nil {
+ msg := err.Error()
+ if libthreads.IsPermissionError(err) {
+ msg += "(需 threads_manage_insights)"
+ }
+ return domusecase.AccountInsightsBlock{Status: "error", Message: msg}
+ }
+ metrics := make([]domusecase.InsightMetricItem, 0, len(items))
+ for _, item := range items {
+ name := strings.TrimSpace(item.Name)
+ if name == "" || len(item.Values) == 0 {
+ continue
+ }
+ metrics = append(metrics, domusecase.InsightMetricItem{Name: name, Value: item.Values[0].Value})
+ }
+ if len(metrics) == 0 {
+ return domusecase.AccountInsightsBlock{Status: "ok", Message: "尚無帳號洞察資料"}
+ }
+ return domusecase.AccountInsightsBlock{
+ Status: "ok",
+ Message: fmt.Sprintf("%d 項指標", len(metrics)),
+ Metrics: metrics,
+ }
+}
diff --git a/backend/internal/model/threads_account/usecase/publish_credentials.go b/backend/internal/model/threads_account/usecase/publish_credentials.go
index 84c7e87..9f4e701 100644
--- a/backend/internal/model/threads_account/usecase/publish_credentials.go
+++ b/backend/internal/model/threads_account/usecase/publish_credentials.go
@@ -12,6 +12,20 @@ import (
func (u *threadsAccountUseCase) ResolveMemberThreadsPublishCredential(
ctx context.Context,
tenantID, ownerUID string,
+) (*domusecase.ThreadsPublishCredential, error) {
+ return u.resolveMemberThreadsAPICredential(ctx, tenantID, ownerUID)
+}
+
+func (u *threadsAccountUseCase) ResolveMemberThreadsReplyCredential(
+ ctx context.Context,
+ tenantID, ownerUID string,
+) (*domusecase.ThreadsPublishCredential, error) {
+ return u.resolveMemberThreadsAPICredential(ctx, tenantID, ownerUID)
+}
+
+func (u *threadsAccountUseCase) resolveMemberThreadsAPICredential(
+ ctx context.Context,
+ tenantID, ownerUID string,
) (*domusecase.ThreadsPublishCredential, error) {
if err := requireActor(tenantID, ownerUID); err != nil {
return nil, err
@@ -41,10 +55,6 @@ func (u *threadsAccountUseCase) ResolveMemberThreadsPublishCredential(
if !apiConnected {
return nil, app.For(code.ThreadsAccount).InputMissingRequired("請先完成 Threads API 連線後再發送留言")
}
- prefs, _ := u.loadConnectionPrefs(ctx, account.ID)
- if prefs.DevMode && !prefs.PublishViaApi {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("開發模式請在連線設定啟用 API 發文,或改用手動貼上留言")
- }
return &domusecase.ThreadsPublishCredential{
AccountID: account.ID,
ThreadsUserID: userID,
diff --git a/backend/internal/model/threads_account/usecase/token_refresh.go b/backend/internal/model/threads_account/usecase/token_refresh.go
new file mode 100644
index 0000000..1870126
--- /dev/null
+++ b/backend/internal/model/threads_account/usecase/token_refresh.go
@@ -0,0 +1,83 @@
+package usecase
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "haixun-backend/internal/library/clock"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ libthreads "haixun-backend/internal/library/threadsapi"
+ domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
+)
+
+const defaultTokenRefreshLeadTime = 7 * 24 * time.Hour
+
+func (u *threadsAccountUseCase) ResolveAccountAPIAccessToken(
+ ctx context.Context,
+ tenantID, ownerUID, accountID string,
+) (string, error) {
+ account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return "", err
+ }
+ secrets, err := u.secretsRepo.FindByAccountID(ctx, account.ID)
+ if err != nil {
+ return "", err
+ }
+ if secrets == nil || !isValidAPIToken(secrets) {
+ return "", app.For(code.ThreadsAccount).InputMissingRequired("此帳號尚未完成 Threads API 連線或 token 已過期")
+ }
+ return strings.TrimSpace(secrets.APIAccessToken), nil
+}
+
+func (u *threadsAccountUseCase) RefreshAccountAPIAccessToken(
+ ctx context.Context,
+ tenantID, ownerUID, accountID string,
+) (*domusecase.SecretsData, error) {
+ token, err := u.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ result, err := libthreads.RefreshAccessToken(ctx, token)
+ if err != nil {
+ return nil, app.For(code.ThreadsAccount).SvcThirdParty("Threads token refresh 失敗").WithCause(err)
+ }
+ expiresAt := int64(0)
+ if result.ExpiresIn > 0 {
+ expiresAt = libthreads.NsFromNow(result.ExpiresIn)
+ }
+ return u.SaveAPIAccessToken(ctx, accountID, result.AccessToken, expiresAt)
+}
+
+func (u *threadsAccountUseCase) ListTokenRefreshCandidates(
+ ctx context.Context,
+ leadTime time.Duration,
+) ([]domusecase.TokenRefreshCandidate, error) {
+ if leadTime <= 0 {
+ leadTime = defaultTokenRefreshLeadTime
+ }
+ deadline := clock.NowUnixNano() + leadTime.Nanoseconds()
+ secretsList, err := u.secretsRepo.ListAPIAccessTokensExpiringBefore(ctx, deadline)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]domusecase.TokenRefreshCandidate, 0, len(secretsList))
+ for _, secrets := range secretsList {
+ if secrets == nil || strings.TrimSpace(secrets.APIAccessToken) == "" {
+ continue
+ }
+ account, err := u.repo.FindByIDGlobal(ctx, secrets.AccountID)
+ if err != nil || account == nil {
+ continue
+ }
+ out = append(out, domusecase.TokenRefreshCandidate{
+ AccountID: account.ID,
+ TenantID: account.TenantID,
+ OwnerUID: account.OwnerUID,
+ ExpiresAt: secrets.APITokenExpiresAt,
+ })
+ }
+ return out, nil
+}
diff --git a/backend/internal/model/threads_account/usecase/usecase.go b/backend/internal/model/threads_account/usecase/usecase.go
index 297fd34..2c671bc 100644
--- a/backend/internal/model/threads_account/usecase/usecase.go
+++ b/backend/internal/model/threads_account/usecase/usecase.go
@@ -11,8 +11,14 @@ import (
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/library/placement"
+ libthreads "haixun-backend/internal/library/threadsapi"
memberdomain "haixun-backend/internal/model/member/domain/usecase"
personadomain "haixun-backend/internal/model/persona/domain/usecase"
+ analyticsrepo "haixun-backend/internal/model/publish_analytics/domain/repository"
+ guardrepo "haixun-backend/internal/model/publish_guard/domain/repository"
+ inventoryrepo "haixun-backend/internal/model/publish_inventory/domain/repository"
+ queuerepo "haixun-backend/internal/model/publish_queue/domain/repository"
+ eventrepo "haixun-backend/internal/model/publish_queue_event/domain/repository"
settingdomain "haixun-backend/internal/model/setting/domain/usecase"
"haixun-backend/internal/model/threads_account/domain/entity"
domrepo "haixun-backend/internal/model/threads_account/domain/repository"
@@ -32,27 +38,48 @@ const (
type threadsAccountUseCase struct {
repo domrepo.Repository
secretsRepo domrepo.SecretsRepository
+ oauthState domrepo.OAuthStateStore
members memberdomain.UseCase
settings settingdomain.UseCase
personas personadomain.UseCase
+ queueRepo queuerepo.Repository
+ analytics analyticsrepo.Repository
+ inventory inventoryrepo.Repository
+ guard guardrepo.Repository
+ queueEvents eventrepo.Repository
cipher *crypto.Cipher
+ oauth libthreads.OAuthConfig
}
func NewUseCase(
repo domrepo.Repository,
secretsRepo domrepo.SecretsRepository,
+ oauthState domrepo.OAuthStateStore,
members memberdomain.UseCase,
settings settingdomain.UseCase,
personas personadomain.UseCase,
+ queueRepo queuerepo.Repository,
+ analytics analyticsrepo.Repository,
+ inventory inventoryrepo.Repository,
+ guard guardrepo.Repository,
+ queueEvents eventrepo.Repository,
cipher *crypto.Cipher,
+ oauth libthreads.OAuthConfig,
) domusecase.UseCase {
return &threadsAccountUseCase{
repo: repo,
secretsRepo: secretsRepo,
+ oauthState: oauthState,
members: members,
settings: settings,
personas: personas,
+ queueRepo: queueRepo,
+ analytics: analytics,
+ inventory: inventory,
+ guard: guard,
+ queueEvents: queueEvents,
cipher: cipher,
+ oauth: oauth,
}
}
@@ -152,6 +179,59 @@ func (u *threadsAccountUseCase) Activate(ctx context.Context, tenantID, ownerUID
return u.members.SetActiveThreadsAccountID(ctx, tenantID, ownerUID, accountID)
}
+func (u *threadsAccountUseCase) Delete(ctx context.Context, tenantID, ownerUID, accountID string) error {
+ if _, err := u.assertOwned(ctx, tenantID, ownerUID, accountID); err != nil {
+ return err
+ }
+ mediaIDs := []string{}
+ if u.queueRepo != nil {
+ if ids, err := u.queueRepo.ListMediaIDsByAccount(ctx, tenantID, ownerUID, accountID); err == nil {
+ mediaIDs = ids
+ }
+ }
+ if u.analytics != nil {
+ _, _ = u.analytics.DeleteByMediaIDs(ctx, tenantID, ownerUID, mediaIDs)
+ }
+ if u.queueEvents != nil {
+ _ = u.queueEvents.DeleteByAccount(ctx, tenantID, ownerUID, accountID)
+ }
+ if u.queueRepo != nil {
+ _, _ = u.queueRepo.DeleteByAccount(ctx, tenantID, ownerUID, accountID)
+ }
+ if u.inventory != nil {
+ _ = u.inventory.DeleteByAccount(ctx, tenantID, ownerUID, accountID)
+ }
+ if u.guard != nil {
+ _ = u.guard.DeleteByAccount(ctx, tenantID, ownerUID, accountID)
+ }
+ if u.secretsRepo != nil {
+ _ = u.secretsRepo.DeleteByAccountID(ctx, accountID)
+ }
+ if u.settings != nil {
+ settings, _, _, _, err := u.settings.List(ctx, settingScopeAccount, accountID, 1, 200)
+ if err == nil {
+ for _, item := range settings {
+ if item == nil {
+ continue
+ }
+ _ = u.settings.Delete(ctx, settingScopeAccount, accountID, item.Key)
+ }
+ }
+ }
+ if err := u.repo.Delete(ctx, tenantID, ownerUID, accountID); err != nil {
+ return err
+ }
+ remaining, err := u.repo.ListByOwner(ctx, tenantID, ownerUID)
+ if err != nil {
+ return err
+ }
+ nextActiveID := ""
+ if len(remaining) > 0 && remaining[0] != nil {
+ nextActiveID = remaining[0].ID
+ }
+ return u.members.SetActiveThreadsAccountID(ctx, tenantID, ownerUID, nextActiveID)
+}
+
func (u *threadsAccountUseCase) GetConnection(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.ConnectionData, error) {
account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
if err != nil {
@@ -263,17 +343,22 @@ func (u *threadsAccountUseCase) toSummary(ctx context.Context, account *entity.A
if err != nil {
return nil, err
}
+ apiTokenExpiresAt := int64(0)
+ if secrets, err := u.secretsRepo.FindByAccountID(ctx, account.ID); err == nil && secrets != nil {
+ apiTokenExpiresAt = secrets.APITokenExpiresAt
+ }
return &domusecase.AccountSummary{
- ID: account.ID,
- DisplayName: account.DisplayName,
- Username: account.Username,
- ThreadsUserID: account.ThreadsUserID,
- PersonaID: account.PersonaID,
- BrowserConnected: browserConnected,
- ApiConnected: apiConnected,
- Status: string(account.Status),
- CreateAt: account.CreateAt,
- UpdateAt: account.UpdateAt,
+ ID: account.ID,
+ DisplayName: account.DisplayName,
+ Username: account.Username,
+ ThreadsUserID: account.ThreadsUserID,
+ PersonaID: account.PersonaID,
+ BrowserConnected: browserConnected,
+ ApiConnected: apiConnected,
+ APITokenExpiresAt: apiTokenExpiresAt,
+ Status: string(account.Status),
+ CreateAt: account.CreateAt,
+ UpdateAt: account.UpdateAt,
}, nil
}
@@ -356,28 +441,14 @@ func accountLabel(account *entity.Account) string {
}
func defaultConnectionPrefs() domusecase.ConnectionPrefs {
- return deriveConnectionPrefsFromDevMode(false)
+ return formalConnectionPrefs()
}
-// deriveConnectionPrefsFromDevMode maps the single dev_mode switch to concrete routing prefs.
-// dev_mode off → everything via Threads API; dev_mode on → everything via browser crawler.
-func deriveConnectionPrefsFromDevMode(devMode bool) domusecase.ConnectionPrefs {
- if devMode {
- return domusecase.ConnectionPrefs{
- DevMode: true,
- SearchViaApi: false,
- SearchSourceMode: string(placement.SearchSourceThreadsCrawler),
- PublishViaApi: false,
- ScrapeReplies: true,
- RepliesPerPost: defaultRepliesPerPost,
- PublishHeaded: false,
- PlaywrightDebug: false,
- }
- }
+func formalConnectionPrefs() domusecase.ConnectionPrefs {
return domusecase.ConnectionPrefs{
DevMode: false,
SearchViaApi: true,
- SearchSourceMode: string(placement.DefaultSearchSourceMode),
+ SearchSourceMode: string(placement.WithoutCrawler(placement.DefaultSearchSourceMode)),
PublishViaApi: true,
ScrapeReplies: false,
RepliesPerPost: defaultRepliesPerPost,
@@ -387,40 +458,12 @@ func deriveConnectionPrefsFromDevMode(devMode bool) domusecase.ConnectionPrefs {
}
func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.ConnectionPrefsPatch) domusecase.ConnectionPrefs {
- if patch.DevMode != nil {
- if *patch.DevMode {
- next := deriveConnectionPrefsFromDevMode(true)
- if patch.SearchSourceMode != nil {
- next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode)
- } else if strings.TrimSpace(current.SearchSourceMode) != "" {
- next.SearchSourceMode = current.SearchSourceMode
- }
- return normalizeConnectionPrefs(next)
- }
- next := current
- next.DevMode = false
- if patch.SearchSourceMode != nil {
- next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode)
- }
- return normalizeConnectionPrefs(next)
+ next := normalizeConnectionPrefs(current)
+ if patch.ScrapeReplies != nil {
+ next.ScrapeReplies = *patch.ScrapeReplies
}
- // Legacy callers may still send granular fields; normalize by current dev flag.
- devMode := current.DevMode
- if patch.SearchViaApi != nil && !*patch.SearchViaApi {
- devMode = true
- }
- if patch.PublishViaApi != nil && !*patch.PublishViaApi {
- devMode = true
- }
- if patch.ScrapeReplies != nil && *patch.ScrapeReplies {
- devMode = true
- }
- next := current
- if devMode {
- next = deriveConnectionPrefsFromDevMode(true)
- if strings.TrimSpace(current.SearchSourceMode) != "" && patch.SearchSourceMode == nil {
- next.SearchSourceMode = current.SearchSourceMode
- }
+ if patch.RepliesPerPost != nil && *patch.RepliesPerPost > 0 {
+ next.RepliesPerPost = *patch.RepliesPerPost
}
if patch.SearchSourceMode != nil {
next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode)
@@ -429,20 +472,14 @@ func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.C
}
func normalizeConnectionPrefs(prefs domusecase.ConnectionPrefs) domusecase.ConnectionPrefs {
- mode := placement.ParseSearchSourceMode(prefs.SearchSourceMode)
- if prefs.DevMode {
- out := deriveConnectionPrefsFromDevMode(true)
- out.SearchSourceMode = string(mode)
- return out
+ out := formalConnectionPrefs()
+ out.ScrapeReplies = prefs.ScrapeReplies
+ if prefs.RepliesPerPost > 0 {
+ out.RepliesPerPost = prefs.RepliesPerPost
}
- prefs.DevMode = false
- prefs.SearchViaApi = true
- prefs.PublishViaApi = true
- prefs.ScrapeReplies = false
- prefs.PublishHeaded = false
- prefs.PlaywrightDebug = false
- prefs.SearchSourceMode = string(mode)
- return prefs
+ mode := placement.ParseSearchSourceMode(prefs.SearchSourceMode)
+ out.SearchSourceMode = string(placement.WithoutCrawler(mode))
+ return out
}
func mergeConnectionPrefs(defaults domusecase.ConnectionPrefs, value map[string]interface{}) domusecase.ConnectionPrefs {
diff --git a/backend/internal/svc/job_workers.go b/backend/internal/svc/job_workers.go
new file mode 100644
index 0000000..f0e6e2d
--- /dev/null
+++ b/backend/internal/svc/job_workers.go
@@ -0,0 +1,62 @@
+package svc
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ jobworker "haixun-backend/internal/worker/job"
+)
+
+var inlineCopyWorkerTypes = []string{
+ "go-generate-topic-matrix",
+ "go-generate-formula-draft",
+ "go-generate-rewrite-draft",
+}
+
+func registerCopyJobHandlers(runner *jobworker.Runner, sc *ServiceContext) {
+ if runner == nil || sc == nil {
+ return
+ }
+ jobworker.RegisterGenerateTopicMatrixHandler(runner, jobworker.GenerateTopicMatrixDeps{
+ Jobs: sc.Job,
+ Persona: sc.Persona,
+ CopyDraft: sc.CopyDraft,
+ ContentOps: sc.ContentOps,
+ ThreadsAccount: sc.ThreadsAccount,
+ Placement: sc.Placement,
+ AI: sc.AI,
+ })
+ jobworker.RegisterGenerateFormulaDraftHandler(runner, jobworker.GenerateFormulaDraftDeps{
+ Jobs: sc.Job,
+ Persona: sc.Persona,
+ ContentFormula: sc.ContentFormula,
+ CopyDraft: sc.CopyDraft,
+ ThreadsAccount: sc.ThreadsAccount,
+ Placement: sc.Placement,
+ AI: sc.AI,
+ })
+ jobworker.RegisterGenerateRewriteDraftHandler(runner, jobworker.GenerateRewriteDraftDeps{
+ Jobs: sc.Job,
+ Persona: sc.Persona,
+ ContentFormula: sc.ContentFormula,
+ CopyDraft: sc.CopyDraft,
+ ThreadsAccount: sc.ThreadsAccount,
+ Placement: sc.Placement,
+ AI: sc.AI,
+ })
+}
+
+func (sc *ServiceContext) startInlineCopyWorkers(ctx context.Context, hostname string) {
+ if sc == nil || sc.Redis == nil {
+ return
+ }
+ for _, workerType := range inlineCopyWorkerTypes {
+ workerType := workerType
+ workerID := fmt.Sprintf("%s-inline-%s", hostname, workerType)
+ runner := jobworker.NewRunner(workerID, workerType, sc.Job)
+ registerCopyJobHandlers(runner, sc)
+ go runner.Start(ctx)
+ log.Printf("inline copy job worker started: id=%s type=%s", workerID, workerType)
+ }
+}
diff --git a/backend/internal/svc/service_context.go b/backend/internal/svc/service_context.go
index 4e9ecd5..a1596be 100644
--- a/backend/internal/svc/service_context.go
+++ b/backend/internal/svc/service_context.go
@@ -11,6 +11,7 @@ import (
libcrypto "haixun-backend/internal/library/crypto"
libmongo "haixun-backend/internal/library/mongo"
libredis "haixun-backend/internal/library/redis"
+ libthreads "haixun-backend/internal/library/threadsapi"
"haixun-backend/internal/library/validate"
"haixun-backend/internal/middleware"
aisettings "haixun-backend/internal/model/ai/usecase"
@@ -21,9 +22,15 @@ import (
branddomain "haixun-backend/internal/model/brand/domain/usecase"
brandrepo "haixun-backend/internal/model/brand/repository"
brandusecase "haixun-backend/internal/model/brand/usecase"
+ contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase"
+ contentformularepo "haixun-backend/internal/model/content_formula/repository"
+ contentformulausecase "haixun-backend/internal/model/content_formula/usecase"
cmatrixdomain "haixun-backend/internal/model/content_matrix/domain/usecase"
cmatrixrepo "haixun-backend/internal/model/content_matrix/repository"
cmatrixusecase "haixun-backend/internal/model/content_matrix/usecase"
+ contentopsdomain "haixun-backend/internal/model/content_ops/domain/usecase"
+ contentopsrepo "haixun-backend/internal/model/content_ops/repository"
+ contentopsusecase "haixun-backend/internal/model/content_ops/usecase"
copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
copydraftrepo "haixun-backend/internal/model/copy_draft/repository"
copydraftusecase "haixun-backend/internal/model/copy_draft/usecase"
@@ -41,9 +48,14 @@ import (
memberdomain "haixun-backend/internal/model/member/domain/usecase"
memberrepo "haixun-backend/internal/model/member/repository"
memberuc "haixun-backend/internal/model/member/usecase"
+ mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase"
+ mentioninboxrepo "haixun-backend/internal/model/mention_inbox/repository"
+ mentioninboxusecase "haixun-backend/internal/model/mention_inbox/usecase"
outreachdraftdomain "haixun-backend/internal/model/outreach_draft/domain/usecase"
outreachdraftrepo "haixun-backend/internal/model/outreach_draft/repository"
outreachdraftusecase "haixun-backend/internal/model/outreach_draft/usecase"
+ ownpostformularepo "haixun-backend/internal/model/own_post_formula/repository"
+ ownpostformuladomain "haixun-backend/internal/model/own_post_formula/usecase"
permissiondomain "haixun-backend/internal/model/permission/domain/usecase"
permissionrepo "haixun-backend/internal/model/permission/repository"
permissionuc "haixun-backend/internal/model/permission/usecase"
@@ -57,11 +69,27 @@ import (
publishanalyticsdomain "haixun-backend/internal/model/publish_analytics/domain/usecase"
publishanalyticsrepo "haixun-backend/internal/model/publish_analytics/repository"
publishanalyticsusecase "haixun-backend/internal/model/publish_analytics/usecase"
+ publishguarddomain "haixun-backend/internal/model/publish_guard/domain/usecase"
+ publishguardrepo "haixun-backend/internal/model/publish_guard/repository"
+ publishguardusecase "haixun-backend/internal/model/publish_guard/usecase"
+ publishinventorydomain "haixun-backend/internal/model/publish_inventory/domain/usecase"
+ publishinventoryrepo "haixun-backend/internal/model/publish_inventory/repository"
+ publishinventoryusecase "haixun-backend/internal/model/publish_inventory/usecase"
+ publishqueuedomain "haixun-backend/internal/model/publish_queue/domain/usecase"
+ publishqueuerepo "haixun-backend/internal/model/publish_queue/repository"
+ publishqueueusecase "haixun-backend/internal/model/publish_queue/usecase"
+ publishqueueeventdomain "haixun-backend/internal/model/publish_queue_event/domain/usecase"
+ publishqueueeventrepo "haixun-backend/internal/model/publish_queue_event/repository"
+ publishqueueeventusecase "haixun-backend/internal/model/publish_queue_event/usecase"
scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase"
scanpostrepo "haixun-backend/internal/model/scan_post/repository"
scanpostusecase "haixun-backend/internal/model/scan_post/usecase"
settingrepo "haixun-backend/internal/model/setting/repository"
settingusecase "haixun-backend/internal/model/setting/usecase"
+ stylepresetdomain "haixun-backend/internal/model/style_preset/domain/usecase"
+ stylepresetrepo "haixun-backend/internal/model/style_preset/repository"
+ stylepresetusecase "haixun-backend/internal/model/style_preset/usecase"
+ threadsaccountrepodomain "haixun-backend/internal/model/threads_account/domain/repository"
threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
threadsaccountrepo "haixun-backend/internal/model/threads_account/repository"
threadsaccountusecase "haixun-backend/internal/model/threads_account/usecase"
@@ -77,34 +105,45 @@ type ServiceContext struct {
Mongo *libmongo.Client
Redis *goredis.Client
- Setting settingusecase.UseCase
- AI aisettings.UseCase
- Job jobusecase.UseCase
- AuthToken authdomain.TokenUseCase
- Member memberdomain.UseCase
- Permission permissiondomain.UseCase
- Persona personadomain.UseCase
- CopyMission copymissiondomain.UseCase
- Brand branddomain.UseCase
- PlacementTopic placementtopicdomain.UseCase
- KnowledgeGraph kgdomain.UseCase
- Placement placementusecase.UseCase
- ScanPost scanpostdomain.UseCase
- OutreachDraft outreachdraftdomain.UseCase
- ContentMatrix cmatrixdomain.UseCase
- CopyDraft copydraftdomain.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
- CrmContact crmcontactdomain.UseCase
- PublishAnalytics publishanalyticsdomain.UseCase
+ Setting settingusecase.UseCase
+ AI aisettings.UseCase
+ Job jobusecase.UseCase
+ AuthToken authdomain.TokenUseCase
+ Member memberdomain.UseCase
+ Permission permissiondomain.UseCase
+ Persona personadomain.UseCase
+ CopyMission copymissiondomain.UseCase
+ Brand branddomain.UseCase
+ PlacementTopic placementtopicdomain.UseCase
+ KnowledgeGraph kgdomain.UseCase
+ Placement placementusecase.UseCase
+ ScanPost scanpostdomain.UseCase
+ OutreachDraft outreachdraftdomain.UseCase
+ ContentMatrix cmatrixdomain.UseCase
+ ContentOps contentopsdomain.UseCase
+ CopyDraft copydraftdomain.UseCase
+ ThreadsAccount threadsaccountdomain.UseCase
+ CrmContact crmcontactdomain.UseCase
+ PublishAnalytics publishanalyticsdomain.UseCase
+ PublishQueue publishqueuedomain.UseCase
+ PublishInventory publishinventorydomain.UseCase
+ PublishGuard publishguarddomain.UseCase
+ PublishQueueEvent publishqueueeventdomain.UseCase
+ StylePreset stylepresetdomain.UseCase
+ OwnPostFormula ownpostformuladomain.UseCase
+ ContentFormula contentformuladomain.UseCase
+ MentionInbox mentioninboxdomain.UseCase
// Middlewares mounted per route group via generate/api `middleware:` directive.
AuthJWT rest.Middleware
MemberAuth rest.Middleware
WorkerSecret rest.Middleware
- stopWorker context.CancelFunc
- stopScheduler context.CancelFunc
- stopReaper context.CancelFunc
+ stopWorker context.CancelFunc
+ stopScheduler context.CancelFunc
+ stopReaper context.CancelFunc
+ stopThreadsTokenSweep context.CancelFunc
+ stopPublishQueueSweep context.CancelFunc
}
func NewServiceContext(c config.Config) *ServiceContext {
@@ -188,16 +227,16 @@ func NewServiceContext(c config.Config) *ServiceContext {
if err := jobUseCase.EnsureScanViralTemplate(ctx); err != nil {
panic(err)
}
- if err := jobUseCase.EnsureAnalyzeCopyMissionTemplate(ctx); err != nil {
+ if err := jobUseCase.EnsureGenerateOutreachDraftTemplate(ctx); err != nil {
panic(err)
}
- if err := jobUseCase.EnsureExpandCopyMissionGraphTemplate(ctx); err != nil {
+ if err := jobUseCase.EnsureGenerateTopicMatrixTemplate(ctx); err != nil {
panic(err)
}
- if err := jobUseCase.EnsureGenerateCopyMatrixTemplate(ctx); err != nil {
+ if err := jobUseCase.EnsureGenerateFormulaDraftTemplate(ctx); err != nil {
panic(err)
}
- if err := jobUseCase.EnsureGenerateCopyDraftTemplate(ctx); err != nil {
+ if err := jobUseCase.EnsureGenerateRewriteDraftTemplate(ctx); err != nil {
panic(err)
}
if err := jobUseCase.EnsureRefreshThreadsTokenTemplate(ctx); err != nil {
@@ -206,7 +245,6 @@ func NewServiceContext(c config.Config) *ServiceContext {
if err := jobUseCase.EnsurePublishAnalyticsTemplate(ctx); err != nil {
panic(err)
}
-
copyMissionRepository := copymissionrepo.NewMongoRepository(mongoClient.Database())
if err := copyMissionRepository.EnsureIndexes(ctx); err != nil {
panic(err)
@@ -266,20 +304,81 @@ func NewServiceContext(c config.Config) *ServiceContext {
)
threadsAccountRepository := threadsaccountrepo.NewMongoRepository(mongoClient.Database())
threadsAccountSecretsRepository := threadsaccountrepo.NewSecretsMongoRepository(mongoClient.Database(), secretsCipher)
+ publishAnalyticsRepository := publishanalyticsrepo.NewMongoRepository(mongoClient.Database())
+ publishQueueRepository := publishqueuerepo.NewMongoRepository(mongoClient.Database())
+ publishInventoryRepository := publishinventoryrepo.NewMongoRepository(mongoClient.Database())
+ publishGuardRepository := publishguardrepo.NewMongoRepository(mongoClient.Database())
+ publishQueueEventRepository := publishqueueeventrepo.NewMongoRepository(mongoClient.Database())
+ stylePresetRepository := stylepresetrepo.NewMongoRepository(mongoClient.Database())
+ ownPostFormulaRepository := ownpostformularepo.NewMongoRepository(mongoClient.Database())
+ contentFormulaRepository := contentformularepo.NewMongoRepository(mongoClient.Database())
+ contentOpsRepository := contentopsrepo.NewMongoRepository(mongoClient.Database())
+ mentionInboxRepository := mentioninboxrepo.NewMongoRepository(mongoClient.Database())
+ var threadsOAuthStateStore threadsaccountrepodomain.OAuthStateStore
+ if redisClient != nil {
+ threadsOAuthStateStore = threadsaccountrepo.NewOAuthStateRedisStore(redisClient)
+ }
+ threadsOAuthConfig := libthreads.OAuthConfig{
+ AppID: c.ThreadsOAuth.AppID,
+ AppSecret: c.ThreadsOAuth.AppSecret,
+ RedirectURI: c.ThreadsOAuth.RedirectURI,
+ SuccessRedirect: c.ThreadsOAuth.SuccessRedirect,
+ }
if err := threadsAccountRepository.EnsureIndexes(ctx); err != nil {
panic(err)
}
if err := threadsAccountSecretsRepository.EnsureIndexes(ctx); err != nil {
panic(err)
}
+ if err := publishAnalyticsRepository.EnsureIndexes(ctx); err != nil {
+ panic(err)
+ }
+ if err := publishQueueRepository.EnsureIndexes(ctx); err != nil {
+ panic(err)
+ }
+ if err := publishInventoryRepository.EnsureIndexes(ctx); err != nil {
+ panic(err)
+ }
+ if err := publishGuardRepository.EnsureIndexes(ctx); err != nil {
+ panic(err)
+ }
+ if err := publishQueueEventRepository.EnsureIndexes(ctx); err != nil {
+ panic(err)
+ }
+ if err := stylePresetRepository.EnsureIndexes(ctx); err != nil {
+ panic(err)
+ }
+ if err := ownPostFormulaRepository.EnsureIndexes(ctx); err != nil {
+ panic(err)
+ }
+ if err := contentFormulaRepository.EnsureIndexes(ctx); err != nil {
+ panic(err)
+ }
+ if err := contentOpsRepository.EnsureIndexes(ctx); err != nil {
+ panic(err)
+ }
+ if err := mentionInboxRepository.EnsureIndexes(ctx); err != nil {
+ panic(err)
+ }
+ ownPostFormulaUseCase := ownpostformuladomain.NewUseCase(ownPostFormulaRepository)
+ contentFormulaUseCase := contentformulausecase.NewUseCase(contentFormulaRepository)
+ contentOpsUseCase := contentopsusecase.NewUseCase(contentOpsRepository)
threadsAccountUseCase := threadsaccountusecase.NewUseCase(
threadsAccountRepository,
threadsAccountSecretsRepository,
+ threadsOAuthStateStore,
memberUseCase,
settingUseCase,
personaUseCase,
+ publishQueueRepository,
+ publishAnalyticsRepository,
+ publishInventoryRepository,
+ publishGuardRepository,
+ publishQueueEventRepository,
secretsCipher,
+ threadsOAuthConfig,
)
+ mentionInboxUseCase := mentioninboxusecase.NewUseCase(mentionInboxRepository, threadsAccountUseCase)
placementUseCase := placementusecase.NewUseCase(settingUseCase, secretsCipher)
@@ -289,36 +388,54 @@ func NewServiceContext(c config.Config) *ServiceContext {
}
crmContactUseCase := crmcontactusecase.NewUseCase(crmContactRepository)
- publishAnalyticsRepository := publishanalyticsrepo.NewMongoRepository(mongoClient.Database())
- if err := publishAnalyticsRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- publishAnalyticsUseCase := publishanalyticsusecase.NewUseCase(publishAnalyticsRepository)
+ publishAnalyticsUseCase := publishanalyticsusecase.NewUseCase(publishAnalyticsRepository, jobUseCase)
+ publishQueueEventUseCase := publishqueueeventusecase.NewUseCase(publishQueueEventRepository)
+ publishInventoryUseCase := publishinventoryusecase.NewUseCase(publishInventoryRepository, threadsAccountUseCase)
+ publishGuardUseCase := publishguardusecase.NewUseCase(publishGuardRepository, threadsAccountUseCase)
+ stylePresetUseCase := stylepresetusecase.NewUseCase(stylePresetRepository, personaUseCase)
+
+ publishQueueUseCase := publishqueueusecase.NewUseCase(
+ publishQueueRepository,
+ threadsAccountUseCase,
+ publishAnalyticsUseCase,
+ publishGuardUseCase,
+ publishQueueEventUseCase,
+ copyDraftUseCase,
+ )
sc := &ServiceContext{
- Config: c,
- Validator: validate.New(),
- Mongo: mongoClient,
- Redis: redisClient,
- Setting: settingUseCase,
- AI: aisettings.NewUseCase(),
- Job: jobUseCase,
- AuthToken: authTokenUseCase,
- Member: memberUseCase,
- Permission: permissionUseCase,
- Persona: personaUseCase,
- CopyMission: copyMissionUseCase,
- Brand: brandUseCase,
- PlacementTopic: placementTopicUseCase,
- KnowledgeGraph: knowledgeGraphUseCase,
- Placement: placementUseCase,
- ScanPost: scanPostUseCase,
- OutreachDraft: outreachDraftUseCase,
- ContentMatrix: contentMatrixUseCase,
- CopyDraft: copyDraftUseCase,
- ThreadsAccount: threadsAccountUseCase,
- CrmContact: crmContactUseCase,
- PublishAnalytics: publishAnalyticsUseCase,
+ Config: c,
+ Validator: validate.New(),
+ Mongo: mongoClient,
+ Redis: redisClient,
+ Setting: settingUseCase,
+ AI: aisettings.NewUseCase(),
+ Job: jobUseCase,
+ AuthToken: authTokenUseCase,
+ Member: memberUseCase,
+ Permission: permissionUseCase,
+ Persona: personaUseCase,
+ CopyMission: copyMissionUseCase,
+ Brand: brandUseCase,
+ PlacementTopic: placementTopicUseCase,
+ KnowledgeGraph: knowledgeGraphUseCase,
+ Placement: placementUseCase,
+ ScanPost: scanPostUseCase,
+ OutreachDraft: outreachDraftUseCase,
+ ContentMatrix: contentMatrixUseCase,
+ ContentOps: contentOpsUseCase,
+ CopyDraft: copyDraftUseCase,
+ ThreadsAccount: threadsAccountUseCase,
+ CrmContact: crmContactUseCase,
+ PublishAnalytics: publishAnalyticsUseCase,
+ PublishQueue: publishQueueUseCase,
+ PublishInventory: publishInventoryUseCase,
+ PublishGuard: publishGuardUseCase,
+ PublishQueueEvent: publishQueueEventUseCase,
+ StylePreset: stylePresetUseCase,
+ OwnPostFormula: ownPostFormulaUseCase,
+ ContentFormula: contentFormulaUseCase,
+ MentionInbox: mentionInboxUseCase,
}
hostname, _ := os.Hostname()
@@ -362,40 +479,41 @@ func NewServiceContext(c config.Config) *ServiceContext {
Placement: placementUseCase,
AI: sc.AI,
})
- jobworker.RegisterAnalyzeCopyMissionHandler(runner, jobworker.AnalyzeCopyMissionDeps{
+ jobworker.RegisterGenerateOutreachDraftHandler(runner, jobworker.GenerateOutreachDraftDeps{
Jobs: jobUseCase,
- CopyMission: copyMissionUseCase,
+ Brand: brandUseCase,
Persona: personaUseCase,
+ ScanPost: scanPostUseCase,
+ KnowledgeGraph: knowledgeGraphUseCase,
+ ThreadsAccount: threadsAccountUseCase,
+ AI: sc.AI,
+ OutreachDraft: outreachDraftUseCase,
+ })
+ jobworker.RegisterGenerateTopicMatrixHandler(runner, jobworker.GenerateTopicMatrixDeps{
+ Jobs: jobUseCase,
+ Persona: personaUseCase,
+ CopyDraft: copyDraftUseCase,
+ ContentOps: contentOpsUseCase,
ThreadsAccount: threadsAccountUseCase,
Placement: placementUseCase,
AI: sc.AI,
})
- jobworker.RegisterExpandCopyMissionGraphHandler(runner, jobworker.ExpandCopyMissionGraphDeps{
+ jobworker.RegisterGenerateFormulaDraftHandler(runner, jobworker.GenerateFormulaDraftDeps{
Jobs: jobUseCase,
- CopyMission: copyMissionUseCase,
Persona: personaUseCase,
- KnowledgeGraph: knowledgeGraphUseCase,
+ ContentFormula: contentFormulaUseCase,
+ CopyDraft: copyDraftUseCase,
ThreadsAccount: threadsAccountUseCase,
Placement: placementUseCase,
AI: sc.AI,
})
- jobworker.RegisterGenerateCopyMatrixHandler(runner, jobworker.GenerateCopyMatrixDeps{
+ jobworker.RegisterGenerateRewriteDraftHandler(runner, jobworker.GenerateRewriteDraftDeps{
Jobs: jobUseCase,
- CopyMission: copyMissionUseCase,
Persona: personaUseCase,
- ScanPost: scanPostUseCase,
- CopyDraft: copyDraftUseCase,
- KnowledgeGraph: knowledgeGraphUseCase,
- ThreadsAccount: threadsAccountUseCase,
- AI: sc.AI,
- })
- jobworker.RegisterGenerateCopyDraftHandler(runner, jobworker.GenerateCopyDraftDeps{
- Jobs: jobUseCase,
- CopyMission: copyMissionUseCase,
- Persona: personaUseCase,
- ScanPost: scanPostUseCase,
+ ContentFormula: contentFormulaUseCase,
CopyDraft: copyDraftUseCase,
ThreadsAccount: threadsAccountUseCase,
+ Placement: placementUseCase,
AI: sc.AI,
})
jobworker.RegisterRefreshThreadsTokenHandler(runner, jobworker.RefreshThreadsTokenDeps{
@@ -408,6 +526,10 @@ func NewServiceContext(c config.Config) *ServiceContext {
PublishAnalytics: publishAnalyticsUseCase,
})
go runner.Start(workerCtx)
+ } else if redisClient != nil {
+ inlineCtx, cancel := context.WithCancel(context.Background())
+ sc.stopWorker = cancel
+ sc.startInlineCopyWorkers(inlineCtx, hostname)
}
if c.JobScheduler.Enabled && redisClient != nil {
@@ -422,6 +544,44 @@ func NewServiceContext(c config.Config) *ServiceContext {
go scheduler.Start(schedulerCtx, interval)
}
+ if c.ThreadsTokenRefresh.Enabled && redisClient != nil {
+ leadDays := c.ThreadsTokenRefresh.LeadDays
+ if leadDays <= 0 {
+ leadDays = 7
+ }
+ interval := time.Duration(c.ThreadsTokenRefresh.IntervalSeconds) * time.Second
+ if interval <= 0 {
+ interval = time.Hour
+ }
+ sweepCtx, cancel := context.WithCancel(context.Background())
+ sc.stopThreadsTokenSweep = cancel
+ sweeper := jobworker.NewThreadsTokenSweeper(
+ threadsAccountUseCase,
+ jobUseCase,
+ time.Duration(leadDays)*24*time.Hour,
+ )
+ go sweeper.Start(sweepCtx, interval)
+ }
+
+ pqEnabled := c.ThreadsPublishQueue.Enabled
+ if !pqEnabled && c.ThreadsPublishQueue.IntervalSeconds == 0 && c.ThreadsPublishQueue.BatchSize == 0 {
+ pqEnabled = true
+ }
+ if pqEnabled {
+ interval := time.Duration(c.ThreadsPublishQueue.IntervalSeconds) * time.Second
+ if interval <= 0 {
+ interval = time.Minute
+ }
+ batchSize := c.ThreadsPublishQueue.BatchSize
+ if batchSize <= 0 {
+ batchSize = 20
+ }
+ pqCtx, cancel := context.WithCancel(context.Background())
+ sc.stopPublishQueueSweep = cancel
+ pqSweeper := jobworker.NewPublishQueueSweeper(publishQueueUseCase, batchSize)
+ go pqSweeper.Start(pqCtx, interval)
+ }
+
if c.JobReaper.Enabled && redisClient != nil {
interval := time.Duration(c.JobReaper.IntervalSeconds) * time.Second
if interval <= 0 {
@@ -459,6 +619,9 @@ func (sc *ServiceContext) Close(ctx context.Context) {
if sc.stopReaper != nil {
sc.stopReaper()
}
+ if sc.stopThreadsTokenSweep != nil {
+ sc.stopThreadsTokenSweep()
+ }
_ = sc.Mongo.Close(ctx)
if sc.Redis != nil {
_ = sc.Redis.Close()
diff --git a/backend/internal/types/types.go b/backend/internal/types/types.go
index 47efae0..4efaa08 100644
--- a/backend/internal/types/types.go
+++ b/backend/internal/types/types.go
@@ -44,6 +44,35 @@ type AIProvidersData struct {
Providers []AIProviderOption `json:"providers"`
}
+type AnalyzeContentFormulaData struct {
+ Formula ContentFormulaData `json:"formula"`
+ Message string `json:"message"`
+}
+
+type AnalyzeContentFormulaHandlerReq struct {
+ ThreadsAccountPath
+ AnalyzeContentFormulaReq
+}
+
+type AnalyzeContentFormulaReq struct {
+ SourceType string `json:"source_type" validate:"required"`
+ PersonaID string `json:"persona_id,optional"`
+ PostText string `json:"post_text,optional"`
+ MediaID string `json:"media_id,optional"`
+ ScanPostID string `json:"scan_post_id,optional"`
+ Keyword string `json:"keyword,optional"`
+ SaveLabel string `json:"save_label,optional"`
+ AuthorName string `json:"author_name,optional"`
+ Permalink string `json:"permalink,optional"`
+ LikeCount int `json:"like_count,optional"`
+ ReplyCount int `json:"reply_count,optional"`
+ Views int `json:"views,optional"`
+ RepostCount int `json:"repost_count,optional"`
+ QuoteCount int `json:"quote_count,optional"`
+ Shares int `json:"shares,optional"`
+ ForceRefresh bool `json:"force_refresh,optional"`
+}
+
type AnalyzeStyle8DData struct {
PersonaID string `json:"persona_id"`
PostCount int `json:"post_count"`
@@ -134,6 +163,7 @@ type BrandProductData struct {
ID string `json:"id"`
Label string `json:"label"`
ProductContext string `json:"product_context"`
+ PlacementURL string `json:"placement_url,omitempty"`
MatchTags []string `json:"match_tags,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
@@ -171,6 +201,67 @@ type ClaimWorkerJobReq struct {
WorkerID string `json:"worker_id" validate:"required"`
}
+type ContentFormulaData struct {
+ ID string `json:"id"`
+ AccountID string `json:"account_id"`
+ Label string `json:"label"`
+ SourceType string `json:"source_type"`
+ SourceRef string `json:"source_ref,omitempty"`
+ SourcePostText string `json:"source_post_text,omitempty"`
+ SourceAuthor string `json:"source_author,omitempty"`
+ SourcePermalink string `json:"source_permalink,omitempty"`
+ SourceMetrics *ContentFormulaSourceMetricsData `json:"source_metrics,omitempty"`
+ Summary string `json:"summary"`
+ Wins []string `json:"wins,omitempty"`
+ Improvements []string `json:"improvements,omitempty"`
+ Formula string `json:"formula"`
+ PostTemplate string `json:"post_template"`
+ HookPattern string `json:"hook_pattern"`
+ Structure string `json:"structure"`
+ ReplicationTips []string `json:"replication_tips,omitempty"`
+ Avoid []string `json:"avoid,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+}
+
+type ContentFormulaItemPath struct {
+ ID string `path:"id" validate:"required"`
+ FormulaID string `path:"formulaId" validate:"required"`
+}
+
+type ContentFormulaPath struct {
+ ID string `path:"id" validate:"required"`
+ FormulaID string `path:"formulaId" validate:"required"`
+}
+
+type ContentFormulaSearchPostData struct {
+ Text string `json:"text"`
+ Author string `json:"author"`
+ Permalink string `json:"permalink,omitempty"`
+ MediaID string `json:"media_id,omitempty"`
+ LikeCount int `json:"like_count"`
+ ReplyCount int `json:"reply_count"`
+ EngagementScore int `json:"engagement_score"`
+}
+
+type ContentFormulaSourceMetricsData struct {
+ LikeCount int `json:"like_count,omitempty"`
+ ReplyCount int `json:"reply_count,omitempty"`
+ Views int `json:"views,omitempty"`
+ RepostCount int `json:"repost_count,omitempty"`
+ QuoteCount int `json:"quote_count,omitempty"`
+ Shares int `json:"shares,omitempty"`
+}
+
+type ContentInboxItemData struct {
+ CopyDraftData
+ ScheduledAt int64 `json:"scheduled_at,omitempty"`
+ Lifecycle string `json:"lifecycle"`
+ GroupDate int64 `json:"group_date"`
+ FormulaLabel string `json:"formula_label,omitempty"`
+}
+
type ContentMatrixData struct {
ID string `json:"id,omitempty"`
BrandID string `json:"brand_id"`
@@ -191,6 +282,33 @@ type ContentMatrixRowData struct {
Rationale string `json:"rationale"`
}
+type ContentPlanData struct {
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ TopicCandidateID string `json:"topic_candidate_id,omitempty"`
+ Topic string `json:"topic"`
+ Mission string `json:"mission"`
+ TargetAudience string `json:"target_audience,omitempty"`
+ Angle string `json:"angle,omitempty"`
+ OpeningType string `json:"opening_type,omitempty"`
+ BodyType string `json:"body_type,omitempty"`
+ Emotion string `json:"emotion,omitempty"`
+ EndingType string `json:"ending_type,omitempty"`
+ CtaType string `json:"cta_type,omitempty"`
+ RiskLevel string `json:"risk_level,omitempty"`
+ RequiresHumanReview bool `json:"requires_human_review,omitempty"`
+ Avoid []string `json:"avoid,omitempty"`
+ SelectedKnowledge []string `json:"selected_knowledge,omitempty"`
+ Status string `json:"status,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+}
+
+type ContentPlanPath struct {
+ ID string `path:"id" validate:"required"`
+ ContentPlanID string `path:"contentPlanId" validate:"required"`
+}
+
type CopyAudienceSampleData struct {
Username string `json:"username"`
SamplePostId string `json:"sample_post_id,omitempty"`
@@ -203,23 +321,35 @@ type CopyAudienceSampleData struct {
}
type CopyDraftData struct {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- CopyMissionID string `json:"copy_mission_id,omitempty"`
- ScanPostID string `json:"scan_post_id,omitempty"`
- DraftType string `json:"draft_type"`
- SortOrder int `json:"sort_order,omitempty"`
- Text string `json:"text"`
- Angle string `json:"angle,omitempty"`
- Hook string `json:"hook,omitempty"`
- Rationale string `json:"rationale,omitempty"`
- ReferenceNotes string `json:"reference_notes,omitempty"`
- Sources []string `json:"sources,omitempty"`
- Status string `json:"status,omitempty"`
- PublishedMediaID string `json:"published_media_id,omitempty"`
- PublishedPermalink string `json:"published_permalink,omitempty"`
- PublishedAt int64 `json:"published_at,omitempty"`
- CreateAt int64 `json:"create_at"`
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ ContentPlanID string `json:"content_plan_id,omitempty"`
+ CopyMissionID string `json:"copy_mission_id,omitempty"`
+ ScanPostID string `json:"scan_post_id,omitempty"`
+ FormulaID string `json:"formula_id,omitempty"`
+ DraftType string `json:"draft_type"`
+ SortOrder int `json:"sort_order,omitempty"`
+ Text string `json:"text"`
+ TopicTag string `json:"topic_tag,omitempty"`
+ Angle string `json:"angle,omitempty"`
+ Hook string `json:"hook,omitempty"`
+ Rationale string `json:"rationale,omitempty"`
+ ReferenceNotes string `json:"reference_notes,omitempty"`
+ Sources []string `json:"sources,omitempty"`
+ AiScore int `json:"ai_score,omitempty"`
+ FormulaScore int `json:"formula_score,omitempty"`
+ BrandFitScore int `json:"brand_fit_score,omitempty"`
+ RiskScore int `json:"risk_score,omitempty"`
+ SimilarityScore int `json:"similarity_score,omitempty"`
+ EngagementPotential int `json:"engagement_potential,omitempty"`
+ FreshnessScore int `json:"freshness_score,omitempty"`
+ ReviewSuggestion string `json:"review_suggestion,omitempty"`
+ Status string `json:"status,omitempty"`
+ PublishQueueID string `json:"publish_queue_id,omitempty"`
+ PublishedMediaID string `json:"published_media_id,omitempty"`
+ PublishedPermalink string `json:"published_permalink,omitempty"`
+ PublishedAt int64 `json:"published_at,omitempty"`
+ CreateAt int64 `json:"create_at"`
}
type CopyDraftPath struct {
@@ -242,14 +372,25 @@ type CopyMissionData struct {
}
type CopyMissionInspirationData struct {
- Label string `json:"label"`
- SeedQuery string `json:"seed_query"`
- Brief string `json:"brief"`
- TrendReason string `json:"trend_reason,omitempty"`
- TrendKeywords []string `json:"trend_keywords,omitempty"`
- Sources []CopyMissionInspirationSourceData `json:"sources,omitempty"`
- WebSearchUsed bool `json:"web_search_used"`
- Message string `json:"message"`
+ TopicCandidateID string `json:"topic_candidate_id,omitempty"`
+ ContentPlanID string `json:"content_plan_id,omitempty"`
+ Label string `json:"label"`
+ SeedQuery string `json:"seed_query"`
+ Brief string `json:"brief"`
+ TrendReason string `json:"trend_reason,omitempty"`
+ TrendKeywords []string `json:"trend_keywords,omitempty"`
+ Angles []string `json:"angles,omitempty"`
+ Mission string `json:"mission,omitempty"`
+ TargetAudience string `json:"target_audience,omitempty"`
+ OpeningType string `json:"opening_type,omitempty"`
+ BodyType string `json:"body_type,omitempty"`
+ Emotion string `json:"emotion,omitempty"`
+ CtaType string `json:"cta_type,omitempty"`
+ RiskLevel string `json:"risk_level,omitempty"`
+ Avoid []string `json:"avoid,omitempty"`
+ Sources []CopyMissionInspirationSourceData `json:"sources,omitempty"`
+ WebSearchUsed bool `json:"web_search_used"`
+ Message string `json:"message"`
}
type CopyMissionInspirationHandlerReq struct {
@@ -258,7 +399,10 @@ type CopyMissionInspirationHandlerReq struct {
}
type CopyMissionInspirationReq struct {
- Keyword string `json:"keyword,optional"`
+ Keyword string `json:"keyword,optional"`
+ ContentDirection string `json:"content_direction,optional"`
+ UseWebSearch bool `json:"use_web_search,optional"`
+ AvoidTopics []string `json:"avoid_topics,optional"`
}
type CopyMissionInspirationSourceData struct {
@@ -306,6 +450,21 @@ type CopyMissionScanScheduleData struct {
LastRunAt int64 `json:"last_run_at,omitempty"`
}
+type CopyMissionScheduleCopyDraftsData struct {
+ Scheduled int `json:"scheduled"`
+ List []PublishQueueItemData `json:"list"`
+ Message string `json:"message"`
+}
+
+type CopyMissionScheduleCopyDraftsReq struct {
+ AccountID string `json:"account_id" validate:"required"`
+ DraftIDs []string `json:"draft_ids" validate:"required,min=1"`
+ StartAt int64 `json:"start_at,optional"`
+ Timezone string `json:"timezone,optional"`
+ Slots []PublishSlotData `json:"slots,optional"`
+ Mode string `json:"mode,optional"`
+}
+
type CopyMissionSimilarAccountPath struct {
PersonaID string `path:"personaId" validate:"required"`
ID string `path:"id" validate:"required"`
@@ -355,6 +514,7 @@ type CreateBrandProductHandlerReq struct {
type CreateBrandProductReq struct {
Label string `json:"label" validate:"required"`
ProductContext string `json:"product_context" validate:"required"`
+ PlacementURL string `json:"placement_url,optional"`
MatchTags []string `json:"match_tags,optional"`
}
@@ -362,6 +522,28 @@ type CreateBrandReq struct {
DisplayName string `json:"display_name,optional"`
}
+type CreateContentPlanHandlerReq struct {
+ PersonaPath
+ CreateContentPlanReq
+}
+
+type CreateContentPlanReq struct {
+ TopicCandidateID string `json:"topic_candidate_id,optional"`
+ Topic string `json:"topic" validate:"required"`
+ Mission string `json:"mission" validate:"required"`
+ TargetAudience string `json:"target_audience,optional"`
+ Angle string `json:"angle,optional"`
+ OpeningType string `json:"opening_type,optional"`
+ BodyType string `json:"body_type,optional"`
+ Emotion string `json:"emotion,optional"`
+ EndingType string `json:"ending_type,optional"`
+ CtaType string `json:"cta_type,optional"`
+ RiskLevel string `json:"risk_level,optional"`
+ RequiresHumanReview bool `json:"requires_human_review,optional"`
+ Avoid []string `json:"avoid,optional"`
+ SelectedKnowledge []string `json:"selected_knowledge,optional"`
+}
+
type CreateCopyMissionHandlerReq struct {
PersonaCopyMissionsPath
CreateCopyMissionReq
@@ -373,6 +555,33 @@ type CreateCopyMissionReq struct {
Brief string `json:"brief" validate:"required"`
}
+type CreateFeedbackEventHandlerReq struct {
+ PersonaPath
+ CreateFeedbackEventReq
+}
+
+type CreateFeedbackEventReq struct {
+ ContentPlanID string `json:"content_plan_id,optional"`
+ DraftID string `json:"draft_id,optional"`
+ Decision string `json:"decision" validate:"required"`
+ Note string `json:"note,optional"`
+ Snapshot string `json:"snapshot,optional"`
+}
+
+type CreateFormulaPoolHandlerReq struct {
+ PersonaPath
+ CreateFormulaPoolReq
+}
+
+type CreateFormulaPoolReq struct {
+ Type string `json:"type" validate:"required"`
+ Name string `json:"name" validate:"required"`
+ Pattern string `json:"pattern,optional"`
+ UseCases []string `json:"use_cases,optional"`
+ Avoid []string `json:"avoid,optional"`
+ Weight int `json:"weight,optional"`
+}
+
type CreateJobReq struct {
TemplateType string `json:"template_type" validate:"required"` // job template type
Scope string `json:"scope" validate:"required,oneof=user account system persona brand"` // job scope
@@ -390,6 +599,19 @@ type CreateJobScheduleReq struct {
Enabled bool `json:"enabled"` // enabled flag
}
+type CreateKnowledgeSourceHandlerReq struct {
+ PersonaPath
+ CreateKnowledgeSourceReq
+}
+
+type CreateKnowledgeSourceReq struct {
+ SourceType string `json:"source_type" validate:"required"`
+ Title string `json:"title,optional"`
+ Filename string `json:"filename,optional"`
+ Content string `json:"content,optional"`
+ ContentBase64 string `json:"content_base64,optional"`
+}
+
type CreatePersonaReq struct {
DisplayName string `json:"display_name,optional"`
}
@@ -406,11 +628,27 @@ type CreatePlacementTopicReq struct {
ProductID string `json:"product_id,optional"`
}
+type CreatePublishQueueHandlerReq struct {
+ ThreadsAccountPath
+ CreatePublishQueueReq
+}
+
+type CreatePublishQueueReq struct {
+ Text string `json:"text" validate:"required"`
+ TopicTag string `json:"topic_tag,optional"`
+ ScheduledAt int64 `json:"scheduled_at,optional"`
+}
+
type CreateThreadsAccountReq struct {
DisplayName string `json:"display_name,optional"`
Activate *bool `json:"activate,optional"`
}
+type DeleteContentFormulaData struct {
+ FormulaID string `json:"formula_id"`
+ Message string `json:"message"`
+}
+
type DeleteCopyDraftData struct {
DraftID string `json:"draft_id"`
Message string `json:"message"`
@@ -435,6 +673,12 @@ type DeletePlacementTopicScanPostHandlerReq struct {
PostID string `path:"postId" validate:"required"`
}
+type DeleteThreadsAccountData struct {
+ DeletedID string `json:"deleted_id"`
+ ActiveAccountID string `json:"active_account_id,omitempty"`
+ Message string `json:"message"`
+}
+
type ErrorDetail struct {
BizCode string `json:"biz_code,optional"`
Scope int64 `json:"scope,optional"`
@@ -470,6 +714,30 @@ type ExpandPlacementTopicGraphHandlerReq struct {
ExpandKnowledgeGraphReq
}
+type FeedbackEventData struct {
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ ContentPlanID string `json:"content_plan_id,omitempty"`
+ DraftID string `json:"draft_id,omitempty"`
+ Decision string `json:"decision"`
+ Note string `json:"note,omitempty"`
+ Snapshot string `json:"snapshot,omitempty"`
+ CreateAt int64 `json:"create_at"`
+}
+
+type FormulaPoolData struct {
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Pattern string `json:"pattern,omitempty"`
+ UseCases []string `json:"use_cases,omitempty"`
+ Avoid []string `json:"avoid,omitempty"`
+ Weight int `json:"weight,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+}
+
type GenerateContentMatrixHandlerReq struct {
BrandPath
GenerateContentMatrixReq
@@ -493,6 +761,24 @@ type GenerateCopyMissionMatrixReq struct {
Count int `json:"count,optional"`
}
+type GenerateFromContentFormulaData struct {
+ List []CopyDraftData `json:"list"`
+ Message string `json:"message"`
+}
+
+type GenerateFromContentFormulaHandlerReq struct {
+ ContentFormulaPath
+ GenerateFromContentFormulaReq
+}
+
+type GenerateFromContentFormulaReq struct {
+ AccountID string `json:"account_id" validate:"required"`
+ Topic string `json:"topic" validate:"required"`
+ Brief string `json:"brief,optional"`
+ UseWebSearch bool `json:"use_web_search,optional"`
+ DraftCount int `json:"draft_count,optional"`
+}
+
type GenerateOutreachDraftsData struct {
ID string `json:"id"`
ScanPostID string `json:"scan_post_id"`
@@ -513,6 +799,51 @@ type GenerateOutreachDraftsReq struct {
Count int `json:"count,optional"`
VoicePersonaID string `json:"voice_persona_id,optional"`
ProductID string `json:"product_id,optional"`
+ Regenerate bool `json:"regenerate,optional"`
+}
+
+type GenerateOwnPostFormulaData struct {
+ OwnPostFormulaReviewData
+}
+
+type GenerateOwnPostFormulaHandlerReq struct {
+ ThreadsAccountPath
+ GenerateOwnPostFormulaReq
+}
+
+type GenerateOwnPostFormulaReq struct {
+ MediaID string `json:"media_id"`
+ PersonaID string `json:"persona_id,optional"`
+ PostText string `json:"post_text,optional"`
+ TopicTag string `json:"topic_tag,optional"`
+ MediaType string `json:"media_type,optional"`
+ Timestamp string `json:"timestamp,optional"`
+ LikeCount int `json:"like_count,optional"`
+ ReplyCount int `json:"reply_count,optional"`
+ Views int `json:"views,optional"`
+ RepostCount int `json:"repost_count,optional"`
+ QuoteCount int `json:"quote_count,optional"`
+ Shares int `json:"shares,optional"`
+ Force bool `json:"force,optional"`
+}
+
+type GenerateOwnPostReplyDraftData struct {
+ Text string `json:"text"`
+}
+
+type GenerateOwnPostReplyDraftHandlerReq struct {
+ ThreadsAccountPath
+ GenerateOwnPostReplyDraftReq
+}
+
+type GenerateOwnPostReplyDraftReq struct {
+ MediaID string `json:"media_id"`
+ PersonaID string `json:"persona_id,optional"`
+ ReplyToID string `json:"reply_to_id,optional"`
+ PostText string `json:"post_text,optional"`
+ ReplyText string `json:"reply_text,optional"`
+ ThreadPostText string `json:"thread_post_text,optional"`
+ ParentReplyText string `json:"parent_reply_text,optional"`
}
type GeneratePersonaCopyDraftData struct {
@@ -529,6 +860,24 @@ type GeneratePersonaCopyDraftReq struct {
ScanPostID string `json:"scan_post_id" validate:"required"`
}
+type GeneratePersonaTopicMatrixData struct {
+ List []CopyDraftData `json:"list"`
+ Message string `json:"message"`
+}
+
+type GeneratePersonaTopicMatrixHandlerReq struct {
+ PersonaPath
+ GeneratePersonaTopicMatrixReq
+}
+
+type GeneratePersonaTopicMatrixReq struct {
+ Topic string `json:"topic" validate:"required"`
+ ContentPlanID string `json:"content_plan_id,optional"`
+ Brief string `json:"brief,optional"`
+ UseWebSearch bool `json:"use_web_search,optional"`
+ DraftCount int `json:"draft_count,optional"`
+}
+
type GeneratePlacementTopicContentMatrixHandlerReq struct {
PlacementTopicPath
GenerateContentMatrixReq
@@ -539,10 +888,24 @@ type GeneratePlacementTopicOutreachDraftsHandlerReq struct {
GenerateOutreachDraftsReq
}
+type GetContentFormulaHandlerReq struct {
+ ContentFormulaItemPath
+}
+
type HealthData struct {
Pong string `json:"pong"`
}
+type ImportOwnPostFormulaHandlerReq struct {
+ ThreadsAccountPath
+ ImportOwnPostFormulaReq
+}
+
+type ImportOwnPostFormulaReq struct {
+ MediaID string `json:"media_id" validate:"required"`
+ Label string `json:"label,optional"`
+}
+
type ImportThreadsAccountSessionData struct {
Success bool `json:"success"`
Valid bool `json:"valid"`
@@ -694,6 +1057,17 @@ type JobTemplateStepData struct {
Cancelable bool `json:"cancelable"`
}
+type KnowledgeChunkData struct {
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ SourceID string `json:"source_id"`
+ Content string `json:"content"`
+ Topics []string `json:"topics,omitempty"`
+ StyleTags []string `json:"style_tags,omitempty"`
+ RiskLevel string `json:"risk_level,omitempty"`
+ CreateAt int64 `json:"create_at"`
+}
+
type KnowledgeGraphData struct {
ID string `json:"id"`
BrandID string `json:"brand_id"`
@@ -742,6 +1116,17 @@ type KnowledgeGraphNodeUpdate struct {
RecencyTags []string `json:"recency_tags,optional"`
}
+type KnowledgeSourceData struct {
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ SourceType string `json:"source_type"`
+ Title string `json:"title,omitempty"`
+ Filename string `json:"filename,omitempty"`
+ ParsedStatus string `json:"parsed_status,omitempty"`
+ ChunkCount int `json:"chunk_count,omitempty"`
+ CreateAt int64 `json:"create_at"`
+}
+
type ListBrandProductsData struct {
List []BrandProductData `json:"list"`
}
@@ -767,6 +1152,27 @@ type ListBrandsData struct {
List []BrandData `json:"list"`
}
+type ListContentFormulasData struct {
+ Pagination PaginationData `json:"pagination"`
+ List []ContentFormulaData `json:"list"`
+}
+
+type ListContentFormulasHandlerReq struct {
+ ThreadsAccountPath
+ ListContentFormulasReq
+}
+
+type ListContentFormulasReq struct {
+ Page int64 `form:"page,optional"`
+ PageSize int64 `form:"pageSize,optional"`
+ SourceType string `form:"source_type,optional"`
+ Tag string `form:"tag,optional"`
+}
+
+type ListContentPlansData struct {
+ List []ContentPlanData `json:"list"`
+}
+
type ListCopyMissionCopyDraftsData struct {
List []CopyDraftData `json:"list"`
Total int `json:"total"`
@@ -785,6 +1191,10 @@ type ListCopyMissionsData struct {
List []CopyMissionData `json:"list"`
}
+type ListFormulaPoolsData struct {
+ List []FormulaPoolData `json:"list"`
+}
+
type ListJobEventsReq struct {
ID string `path:"id" validate:"required"` // job run id
Limit int64 `form:"limit,optional"` // max events
@@ -804,6 +1214,57 @@ type ListJobsReq struct {
PageSize int64 `form:"pageSize,optional"` // page size
}
+type ListKnowledgeChunksData struct {
+ List []KnowledgeChunkData `json:"list"`
+}
+
+type ListKnowledgeSourcesData struct {
+ List []KnowledgeSourceData `json:"list"`
+}
+
+type ListMentionInboxData struct {
+ List []MentionInboxItemData `json:"list"`
+ Pagination PaginationData `json:"pagination"`
+ ReadyTotal int64 `json:"ready_total"`
+ NewestSyncedAt int64 `json:"newest_synced_at,omitempty"`
+}
+
+type ListMentionInboxHandlerReq struct {
+ ThreadsAccountPath
+ ListMentionInboxQuery
+}
+
+type ListMentionInboxQuery struct {
+ Page int `form:"page,optional"`
+ PageSize int `form:"pageSize,optional"`
+}
+
+type ListOwnPostFormulasData struct {
+ List []OwnPostFormulaReviewData `json:"list"`
+}
+
+type ListOwnPostFormulasHandlerReq struct {
+ ThreadsAccountPath
+}
+
+type ListPersonaContentInboxData struct {
+ Pagination PaginationData `json:"pagination"`
+ List []ContentInboxItemData `json:"list"`
+}
+
+type ListPersonaContentInboxHandlerReq struct {
+ PersonaPath
+ ListPersonaContentInboxReq
+}
+
+type ListPersonaContentInboxReq struct {
+ Page int64 `form:"page,optional"`
+ PageSize int64 `form:"pageSize,optional"`
+ Status string `form:"status,optional"`
+ RangeStart int64 `form:"rangeStart,optional"`
+ RangeEnd int64 `form:"rangeEnd,optional"`
+}
+
type ListPersonaCopyDraftsData struct {
List []CopyDraftData `json:"list"`
Total int `json:"total"`
@@ -836,6 +1297,21 @@ type ListPlacementTopicsData struct {
List []PlacementTopicData `json:"list"`
}
+type ListPublishQueueHandlerReq struct {
+ ThreadsAccountPath
+ ListPublishQueueQuery
+}
+
+type ListPublishQueueQuery struct {
+ Status string `form:"status,optional"`
+ Page int `form:"page,optional"`
+ PageSize int `form:"pageSize,optional"`
+}
+
+type ListStylePresetsData struct {
+ List []StylePresetData `json:"list"`
+}
+
type ListThreadsAccountAiProviderModelsReq struct {
ApiKey string `json:"api_key,optional"` // 選填;未帶則用已儲存的 key
}
@@ -845,6 +1321,33 @@ type ListThreadsAccountsData struct {
ActiveAccountID string `json:"active_account_id"`
}
+type ListThreadsOAuthLogsData struct {
+ List []ThreadsOAuthLogItem `json:"list"`
+}
+
+type ListThreadsPostPerformanceHandlerReq struct {
+ ThreadsAccountPath
+ ListThreadsPostPerformanceQuery
+}
+
+type ListThreadsPostPerformanceQuery struct {
+ Limit int `form:"limit,optional"`
+}
+
+type ListThreadsPublishHealthHandlerReq struct {
+ ThreadsAccountPath
+ ListThreadsPublishHealthQuery
+}
+
+type ListThreadsPublishHealthQuery struct {
+ Page int `form:"page,optional"`
+ PageSize int `form:"pageSize,optional"`
+}
+
+type ListTopicCandidatesData struct {
+ List []TopicCandidateData `json:"list"`
+}
+
type LogoutData struct {
OK bool `json:"ok"`
}
@@ -901,6 +1404,27 @@ type MemberPlacementSettingsData struct {
BraveSearchLang string `json:"brave_search_lang"`
ExaUserLocation string `json:"exa_user_location"`
ExpandStrategy string `json:"expand_strategy"` // brave | llm | hybrid
+ DevModeEnabled bool `json:"dev_mode_enabled"`
+}
+
+type MentionInboxItemData struct {
+ MediaID string `json:"media_id"`
+ AuthorUsername string `json:"author_username,omitempty"`
+ Text string `json:"text,omitempty"`
+ Permalink string `json:"permalink,omitempty"`
+ Shortcode string `json:"shortcode,omitempty"`
+ Timestamp string `json:"timestamp,omitempty"`
+ MediaType string `json:"media_type,omitempty"`
+ IsReply bool `json:"is_reply"`
+ IsQuotePost bool `json:"is_quote_post"`
+ HasReplies bool `json:"has_replies"`
+ RootPostID string `json:"root_post_id,omitempty"`
+ ParentID string `json:"parent_id,omitempty"`
+ ThreadPostText string `json:"thread_post_text,omitempty"`
+ ParentReplyText string `json:"parent_reply_text,omitempty"`
+ ReplyReady bool `json:"reply_ready"`
+ ReplyReadyMsg string `json:"reply_ready_msg,omitempty"`
+ SyncedAt int64 `json:"synced_at"`
}
type OutreachDraftItemData struct {
@@ -909,6 +1433,21 @@ type OutreachDraftItemData struct {
Rationale string `json:"rationale"`
}
+type OwnPostFormulaReviewData struct {
+ MediaID string `json:"media_id"`
+ ReviewedAt int64 `json:"reviewed_at"`
+ FromCache bool `json:"from_cache,omitempty"`
+ Summary string `json:"summary"`
+ Wins []string `json:"wins"`
+ Improvements []string `json:"improvements"`
+ Formula string `json:"formula"`
+ PostTemplate string `json:"post_template"`
+ HookPattern string `json:"hook_pattern"`
+ Structure string `json:"structure"`
+ ReplicationTips []string `json:"replication_tips"`
+ Avoid []string `json:"avoid"`
+}
+
type PaginationData struct {
Total int64 `json:"total"`
Page int64 `json:"page"`
@@ -925,6 +1464,20 @@ type PatchAudienceSampleReq struct {
Status string `json:"status"`
}
+type PatchContentFormulaHandlerReq struct {
+ ContentFormulaItemPath
+ PatchContentFormulaReq
+}
+
+type PatchContentFormulaReq struct {
+ Label *string `json:"label,optional"`
+ Formula *string `json:"formula,optional"`
+ PostTemplate *string `json:"post_template,optional"`
+ HookPattern *string `json:"hook_pattern,optional"`
+ Structure *string `json:"structure,optional"`
+ Tags []string `json:"tags,optional"`
+}
+
type PatchCopyMissionGraphNodesHandlerReq struct {
CopyMissionPath
PatchKnowledgeGraphNodesReq
@@ -950,6 +1503,17 @@ type PatchPlacementTopicScanPostOutreachHandlerReq struct {
PatchScanPostOutreachReq
}
+type PatchPublishQueueHandlerReq struct {
+ PublishQueueItemPath
+ PatchPublishQueueReq
+}
+
+type PatchPublishQueueReq struct {
+ Text *string `json:"text,optional"`
+ TopicTag *string `json:"topic_tag,optional"`
+ ScheduledAt *int64 `json:"scheduled_at,optional"`
+}
+
type PatchScanPostOutreachHandlerReq struct {
BrandPath
PostID string `path:"postId"`
@@ -1029,6 +1593,34 @@ type PlacementTopicPath struct {
ID string `path:"id" validate:"required"`
}
+type PrunePersonaCopyDraftsData struct {
+ Deleted int64 `json:"deleted"`
+ Kept int `json:"kept"`
+ Message string `json:"message"`
+}
+
+type PrunePersonaCopyDraftsHandlerReq struct {
+ PersonaPath
+ PrunePersonaCopyDraftsReq
+}
+
+type PrunePersonaCopyDraftsReq struct {
+ Keep int `json:"keep,optional"`
+}
+
+type PublishAlertData struct {
+ Type string `json:"type"`
+ Severity string `json:"severity"`
+ Message string `json:"message"`
+ QueueID string `json:"queue_id,omitempty"`
+ AccountID string `json:"account_id"`
+ CreateAt int64 `json:"create_at"`
+}
+
+type PublishAlertsData struct {
+ List []PublishAlertData `json:"list"`
+}
+
type PublishCopyDraftData struct {
DraftID string `json:"draft_id"`
MediaID string `json:"media_id"`
@@ -1043,8 +1635,73 @@ type PublishCopyDraftHandlerReq struct {
}
type PublishCopyDraftReq struct {
- Text string `json:"text,optional"`
- Confirm bool `json:"confirm"`
+ Text string `json:"text,optional"`
+ TopicTag string `json:"topic_tag,optional"`
+ Confirm bool `json:"confirm"`
+}
+
+type PublishDashboardAccountSummary struct {
+ AccountID string `json:"account_id"`
+ AccountName string `json:"account_name"`
+ PendingScheduled int64 `json:"pending_scheduled"`
+ FailedCount int64 `json:"failed_count"`
+ Published7d int64 `json:"published_7d"`
+ TotalLikes7d int `json:"total_likes_7d"`
+ TotalReplies7d int `json:"total_replies_7d"`
+ Paused bool `json:"paused"`
+ BestSlot string `json:"best_slot,omitempty"`
+ LowSlot string `json:"low_slot,omitempty"`
+}
+
+type PublishDashboardSummaryData struct {
+ List []PublishDashboardAccountSummary `json:"list"`
+ TotalPending int64 `json:"total_pending"`
+ TotalFailed int64 `json:"total_failed"`
+ TotalPublished7d int64 `json:"total_published_7d"`
+ TotalLikes7d int `json:"total_likes_7d"`
+ TotalReplies7d int `json:"total_replies_7d"`
+}
+
+type PublishGuardPolicyData struct {
+ AccountID string `json:"account_id"`
+ Enabled bool `json:"enabled"`
+ MaxDailyPosts int `json:"max_daily_posts"`
+ MinIntervalMinutes int `json:"min_interval_minutes"`
+ ConsecutiveFailurePauseLimit int `json:"consecutive_failure_pause_limit"`
+ Paused bool `json:"paused"`
+ PausedReason string `json:"paused_reason,omitempty"`
+ UpdateAt int64 `json:"update_at"`
+}
+
+type PublishInventoryPolicyData struct {
+ AccountID string `json:"account_id"`
+ Enabled bool `json:"enabled"`
+ TargetDailyCount int `json:"target_daily_count"`
+ LowStockThreshold int `json:"low_stock_threshold"`
+ LookaheadDays int `json:"lookahead_days"`
+ Timezone string `json:"timezone"`
+ Slots []PublishSlotData `json:"slots"`
+ SourceRefs []PublishSourceRefData `json:"source_refs"`
+ UpdateAt int64 `json:"update_at"`
+}
+
+type PublishMentionReplyData struct {
+ MediaID string `json:"media_id,omitempty"`
+ Permalink string `json:"permalink,omitempty"`
+}
+
+type PublishMentionReplyHandlerReq struct {
+ PublishMentionReplyPath
+ PublishMentionReplyReq
+}
+
+type PublishMentionReplyPath struct {
+ ID string `path:"id" validate:"required"`
+ MediaID string `path:"media_id" validate:"required"`
+}
+
+type PublishMentionReplyReq struct {
+ Text string `json:"text"`
}
type PublishOutreachDraftData struct {
@@ -1072,6 +1729,96 @@ type PublishPlacementTopicOutreachDraftHandlerReq struct {
PublishOutreachDraftReq
}
+type PublishQueueEventData struct {
+ ID string `json:"id"`
+ QueueID string `json:"queue_id"`
+ EventType string `json:"event_type"`
+ FromStatus string `json:"from_status,omitempty"`
+ ToStatus string `json:"to_status,omitempty"`
+ Message string `json:"message,omitempty"`
+ CreateAt int64 `json:"create_at"`
+}
+
+type PublishQueueEventsData struct {
+ List []PublishQueueEventData `json:"list"`
+}
+
+type PublishQueueItemData struct {
+ ID string `json:"id"`
+ AccountID string `json:"account_id"`
+ PersonaID string `json:"persona_id,omitempty"`
+ CopyMissionID string `json:"copy_mission_id,omitempty"`
+ CopyDraftID string `json:"copy_draft_id,omitempty"`
+ Text string `json:"text"`
+ TopicTag string `json:"topic_tag,omitempty"`
+ ScheduledAt int64 `json:"scheduled_at"`
+ Status string `json:"status"`
+ MediaID string `json:"media_id,optional"`
+ Permalink string `json:"permalink,optional"`
+ PublishedAt int64 `json:"published_at,optional"`
+ ErrorMessage string `json:"error_message,optional"`
+ RetryCount int `json:"retry_count,omitempty"`
+ LastAttemptAt int64 `json:"last_attempt_at,omitempty"`
+ NextAttemptAt int64 `json:"next_attempt_at,omitempty"`
+ MissedAt int64 `json:"missed_at,omitempty"`
+ PausedReason string `json:"paused_reason,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+}
+
+type PublishQueueItemPath struct {
+ ID string `path:"id" validate:"required"`
+ QID string `path:"qid" validate:"required"`
+}
+
+type PublishQueueListData struct {
+ Pagination PaginationData `json:"pagination"`
+ List []PublishQueueItemData `json:"list"`
+}
+
+type PublishQueueRangeHandlerReq struct {
+ ThreadsAccountPath
+ PublishQueueRangeQuery
+}
+
+type PublishQueueRangeQuery struct {
+ StartAt int64 `form:"startAt,optional"`
+ EndAt int64 `form:"endAt,optional"`
+ Status string `form:"status,optional"`
+}
+
+type PublishSlotData struct {
+ Weekday int `json:"weekday"`
+ Time string `json:"time"`
+}
+
+type PublishSlotInsightData struct {
+ Weekday int `json:"weekday"`
+ Time string `json:"time"`
+ AvgLikes1h int `json:"avg_likes_1h"`
+ AvgReplies1h int `json:"avg_replies_1h"`
+ AvgLikes24h int `json:"avg_likes_24h"`
+ AvgReplies24h int `json:"avg_replies_24h"`
+ AvgLikes7d int `json:"avg_likes_7d"`
+ AvgReplies7d int `json:"avg_replies_7d"`
+ SampleSize int `json:"sample_size"`
+ Recommendation string `json:"recommendation"`
+}
+
+type PublishSlotInsightsData struct {
+ AccountID string `json:"account_id"`
+ Timezone string `json:"timezone"`
+ Slots []PublishSlotInsightData `json:"slots"`
+}
+
+type PublishSourceRefData struct {
+ Type string `json:"type"`
+ PersonaID string `json:"persona_id,omitempty"`
+ CopyMissionID string `json:"copy_mission_id,omitempty"`
+ ScanPostID string `json:"scan_post_id,omitempty"`
+ ManualSeed string `json:"manual_seed,omitempty"`
+}
+
type ResearchItemData struct {
Title string `json:"title,omitempty"`
URL string `json:"url,omitempty"`
@@ -1090,6 +1837,36 @@ type ResearchMapData struct {
PatrolKeywords []string `json:"patrol_keywords,omitempty"`
}
+type RunThreadsAPIPlaygroundHandlerReq struct {
+ ThreadsAccountPath
+ RunThreadsAPIPlaygroundReq
+}
+
+type RunThreadsAPIPlaygroundReq struct {
+ Action string `json:"action" validate:"required"`
+ Query string `json:"query,optional"`
+ Username string `json:"username,optional"`
+ MediaID string `json:"media_id,optional"`
+ ReplyID string `json:"reply_id,optional"`
+ ReplyToID string `json:"reply_to_id,optional"`
+ Text string `json:"text,optional"`
+ Permalink string `json:"permalink,optional"`
+ HintText string `json:"hint_text,optional"`
+ SkipPrime bool `json:"skip_prime,optional"`
+ LocationQuery string `json:"location_query,optional"`
+ Limit int `json:"limit,optional"`
+ Hide bool `json:"hide,optional"`
+ SearchType string `json:"search_type,optional"`
+}
+
+type RunThreadsAPISmokeTestData struct {
+ List []ThreadsAPISmokeTestItem `json:"list"`
+ Passed int `json:"passed"`
+ Failed int `json:"failed"`
+ Skipped int `json:"skipped"`
+ Total int `json:"total"`
+}
+
type ScanPostData struct {
ID string `json:"id"`
GraphNodeID string `json:"graph_node_id"`
@@ -1110,6 +1887,7 @@ type ScanPostData struct {
PublishedPermalink string `json:"published_permalink,omitempty"`
OutreachUpdateAt int64 `json:"outreach_update_at,omitempty"`
PostedAt string `json:"posted_at,omitempty"`
+ RecencyDays int `json:"recency_days,omitempty"`
Replies []ScanReplyData `json:"replies,omitempty"`
LatestDraft *GenerateOutreachDraftsData `json:"latest_draft,omitempty"`
CreateAt int64 `json:"create_at"`
@@ -1124,6 +1902,58 @@ type ScanReplyData struct {
PostedAt string `json:"posted_at,omitempty"`
}
+type ScheduleCopyDraftsData struct {
+ Scheduled int `json:"scheduled"`
+ List []PublishQueueItemData `json:"list"`
+ Message string `json:"message"`
+}
+
+type ScheduleCopyDraftsReq struct {
+ AccountID string `json:"account_id" validate:"required"`
+ DraftIDs []string `json:"draft_ids" validate:"required,min=1"`
+ StartAt int64 `json:"start_at,optional"`
+ Timezone string `json:"timezone,optional"`
+ Slots []PublishSlotData `json:"slots,optional"`
+ Mode string `json:"mode,optional"`
+}
+
+type ScheduleCopyMissionDraftsHandlerReq struct {
+ CopyMissionPath
+ CopyMissionScheduleCopyDraftsReq
+}
+
+type SchedulePersonaCopyDraftHandlerReq struct {
+ CopyDraftPath
+ SchedulePersonaCopyDraftReq
+}
+
+type SchedulePersonaCopyDraftReq struct {
+ AccountID string `json:"account_id" validate:"required"`
+ TopicTag string `json:"topic_tag,optional"`
+ ScheduledAt int64 `json:"scheduled_at,optional"`
+}
+
+type SchedulePersonaDraftsHandlerReq struct {
+ PersonaPath
+ ScheduleCopyDraftsReq
+}
+
+type SearchContentFormulaPostsData struct {
+ List []ContentFormulaSearchPostData `json:"list"`
+ Message string `json:"message"`
+}
+
+type SearchContentFormulaPostsHandlerReq struct {
+ ThreadsAccountPath
+ SearchContentFormulaPostsReq
+}
+
+type SearchContentFormulaPostsReq struct {
+ Keyword string `json:"keyword" validate:"required"`
+ SearchType string `json:"search_type,optional"`
+ Limit int `json:"limit,optional"`
+}
+
type SettingData struct {
ID string `json:"id"`
Scope string `json:"scope"`
@@ -1177,6 +2007,7 @@ type StartBrandScanJobReq struct {
NodeIDs []string `json:"node_ids,optional"`
DualTrack bool `json:"dual_track,optional"`
PatrolMode bool `json:"patrol_mode,optional"`
+ TestPatrol bool `json:"test_patrol,optional"`
PatrolKeywords []string `json:"patrol_keywords,optional"`
}
@@ -1222,12 +2053,74 @@ type StartCopyMissionScanJobData struct {
Message string `json:"message"`
}
+type StartPersonaFormulaDraftJobData struct {
+ JobID string `json:"job_id"`
+ Status string `json:"status"`
+ AiProvider string `json:"ai_provider"`
+ AiModel string `json:"ai_model"`
+ Message string `json:"message"`
+}
+
+type StartPersonaFormulaDraftJobHandlerReq struct {
+ PersonaPath
+ StartPersonaFormulaDraftJobReq
+}
+
+type StartPersonaFormulaDraftJobReq struct {
+ AccountID string `json:"account_id" validate:"required"`
+ FormulaID string `json:"formula_id" validate:"required"`
+ Topic string `json:"topic" validate:"required"`
+ Brief string `json:"brief,optional"`
+ UseWebSearch bool `json:"use_web_search,optional"`
+ DraftCount int `json:"draft_count,optional"`
+}
+
+type StartPersonaRewriteDraftJobData struct {
+ JobID string `json:"job_id"`
+ Status string `json:"status"`
+ AiProvider string `json:"ai_provider"`
+ AiModel string `json:"ai_model"`
+ Message string `json:"message"`
+}
+
+type StartPersonaRewriteDraftJobHandlerReq struct {
+ PersonaPath
+ StartPersonaRewriteDraftJobReq
+}
+
+type StartPersonaRewriteDraftJobReq struct {
+ AccountID string `json:"account_id" validate:"required"`
+ ReferenceText string `json:"reference_text" validate:"required"`
+ Topic string `json:"topic" validate:"required"`
+ Brief string `json:"brief,optional"`
+ SaveLabel string `json:"save_label,optional"`
+ UseWebSearch bool `json:"use_web_search,optional"`
+ DraftCount int `json:"draft_count,optional"`
+}
+
type StartPersonaStyleAnalysisData struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`
}
+type StartPersonaStyleAnalysisFromTextData struct {
+ Persona PersonaData `json:"persona"`
+ PostCount int `json:"post_count"`
+ Message string `json:"message"`
+}
+
+type StartPersonaStyleAnalysisFromTextHandlerReq struct {
+ PersonaPath
+ StartPersonaStyleAnalysisFromTextReq
+}
+
+type StartPersonaStyleAnalysisFromTextReq struct {
+ ReferenceTexts []string `json:"reference_texts,optional"`
+ RawText string `json:"raw_text,optional"`
+ SourceLabel string `json:"source_label,optional"`
+}
+
type StartPersonaStyleAnalysisHandlerReq struct {
PersonaPath
StartPersonaStyleAnalysisReq
@@ -1237,6 +2130,19 @@ type StartPersonaStyleAnalysisReq struct {
BenchmarkUsername string `json:"benchmark_username" validate:"required"`
}
+type StartPersonaTopicMatrixJobData struct {
+ JobID string `json:"job_id"`
+ Status string `json:"status"`
+ AiProvider string `json:"ai_provider"`
+ AiModel string `json:"ai_model"`
+ Message string `json:"message"`
+}
+
+type StartPersonaTopicMatrixJobHandlerReq struct {
+ PersonaPath
+ GeneratePersonaTopicMatrixReq
+}
+
type StartPersonaViralScanJobData struct {
JobID string `json:"job_id"`
Status string `json:"status"`
@@ -1252,11 +2158,55 @@ type StartPersonaViralScanJobReq struct {
Keywords []string `json:"keywords,optional"`
}
+type StartPlacementTopicOutreachDraftJobHandlerReq struct {
+ PlacementTopicPath
+ StartPlacementTopicOutreachDraftJobReq
+}
+
+type StartPlacementTopicOutreachDraftJobReq struct {
+ ScanPostID string `json:"scan_post_id,optional"`
+ ScanPostIDs []string `json:"scan_post_ids,optional"`
+ Count int `json:"count,optional"`
+ VoicePersonaID string `json:"voice_persona_id,optional"`
+ ProductID string `json:"product_id,optional"`
+ Regenerate bool `json:"regenerate,optional"`
+}
+
+type StartPlacementTopicOutreachDraftJobsData struct {
+ Jobs []StartBrandScanJobData `json:"jobs"`
+ Message string `json:"message"`
+}
+
type StartPlacementTopicScanJobHandlerReq struct {
PlacementTopicPath
StartBrandScanJobReq
}
+type StartPublishInventoryRefillJobData struct {
+ JobID string `json:"job_id"`
+ Status string `json:"status"`
+ Message string `json:"message"`
+}
+
+type StartPublishInventoryRefillJobHandlerReq struct {
+ ThreadsAccountPath
+ StartPublishInventoryRefillJobReq
+}
+
+type StartPublishInventoryRefillJobReq struct {
+ Count int `json:"count,optional"`
+}
+
+type StartThreadsOAuthData struct {
+ AuthorizeURL string `json:"authorize_url"`
+ State string `json:"state"`
+ AccountID string `json:"account_id"`
+}
+
+type StartThreadsOAuthQuery struct {
+ AccountID string `form:"account_id,optional"`
+}
+
type Status struct {
Code int64 `json:"code"`
Message string `json:"message"`
@@ -1277,6 +2227,54 @@ type StorePersonaStyleProfileReq struct {
StyleBenchmark string `json:"style_benchmark,optional"`
}
+type StylePresetData struct {
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ Name string `json:"name"`
+ Tone string `json:"tone,omitempty"`
+ CTA []string `json:"cta,omitempty"`
+ BannedWords []string `json:"banned_words,omitempty"`
+ Notes string `json:"notes,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+}
+
+type StylePresetPath struct {
+ ID string `path:"id" validate:"required"`
+ PresetID string `path:"presetId" validate:"required"`
+}
+
+type SyncMentionInboxData struct {
+ Synced int `json:"synced"`
+ Ready int `json:"ready"`
+ Total int64 `json:"total"`
+}
+
+type SyncMentionInboxHandlerReq struct {
+ ThreadsAccountPath
+ SyncMentionInboxReq
+}
+
+type SyncMentionInboxReq struct {
+ Limit int `json:"limit,optional"`
+ MaxPages int `json:"max_pages,optional"`
+}
+
+type ThreadsAPIPlaygroundData struct {
+ Action string `json:"action"`
+ Ok bool `json:"ok"`
+ Message string `json:"message"`
+ Data string `json:"data,optional"`
+}
+
+type ThreadsAPISmokeTestItem struct {
+ Scope string `json:"scope"`
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Message string `json:"message"`
+ Count int `json:"count"`
+}
+
type ThreadsAccountAiProviderModelsData struct {
ID string `json:"id"`
Label string `json:"label"`
@@ -1305,28 +2303,6 @@ type ThreadsAccountAiSettingsData struct {
ApiKeysConfigured map[string]interface{} `json:"api_keys_configured"`
}
-type ThreadsAccountAiProviderPath struct {
- ID string `path:"id" validate:"required"`
- Provider string `path:"provider" validate:"required,oneof=opencode-go xai"`
-}
-
-type ListThreadsAccountAiProviderModelsReq struct {
- ApiKey string `json:"api_key,optional"`
-}
-
-type ThreadsAccountAiProviderModelsHandlerReq struct {
- ThreadsAccountAiProviderPath
- ListThreadsAccountAiProviderModelsReq
-}
-
-type ThreadsAccountAiProviderModelsData struct {
- ID string `json:"id"`
- Label string `json:"label"`
- Models []string `json:"models"`
- Streams bool `json:"streams"`
- Error string `json:"error,optional"`
-}
-
type ThreadsAccountConnectionData struct {
AccountID string `json:"account_id"`
AccountName string `json:"account_name"`
@@ -1348,22 +2324,164 @@ type ThreadsAccountConnectionPrefs struct {
}
type ThreadsAccountData struct {
- ID string `json:"id"`
- DisplayName string `json:"display_name,omitempty"`
- Username string `json:"username,omitempty"`
- ThreadsUserID string `json:"threads_user_id,omitempty"`
- PersonaID string `json:"persona_id,omitempty"` // deprecated: persona is chosen per publish, not bound to account
- BrowserConnected bool `json:"browser_connected"`
- ApiConnected bool `json:"api_connected"`
- Status string `json:"status"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
+ ID string `json:"id"`
+ DisplayName string `json:"display_name,omitempty"`
+ Username string `json:"username,omitempty"`
+ ThreadsUserID string `json:"threads_user_id,omitempty"`
+ PersonaID string `json:"persona_id,omitempty"` // deprecated: persona is chosen per publish, not bound to account
+ BrowserConnected bool `json:"browser_connected"`
+ ApiConnected bool `json:"api_connected"`
+ APITokenExpiresAt int64 `json:"api_token_expires_at,omitempty"`
+ Status string `json:"status"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+}
+
+type ThreadsAccountInsightsBlock struct {
+ Status string `json:"status"`
+ Message string `json:"message,optional"`
+ Metrics []ThreadsInsightMetricItem `json:"metrics,optional"`
}
type ThreadsAccountPath struct {
ID string `path:"id" validate:"required"`
}
+type ThreadsDiagnosticsData struct {
+ AccountID string `json:"account_id"`
+ CheckedAt int64 `json:"checked_at"`
+ ApiConnected bool `json:"api_connected"`
+ TokenExpiresAt int64 `json:"token_expires_at,omitempty"`
+ Scopes []string `json:"scopes"`
+ Items []ThreadsAPISmokeTestItem `json:"items"`
+ Message string `json:"message"`
+}
+
+type ThreadsInsightMetricItem struct {
+ Name string `json:"name"`
+ Value int `json:"value"`
+}
+
+type ThreadsOAuthCallbackQuery struct {
+ Code string `form:"code,optional"`
+ State string `form:"state,optional"`
+ Error string `form:"error,optional"`
+ ErrorReason string `form:"error_reason,optional"`
+ ErrorDescription string `form:"error_description,optional"`
+}
+
+type ThreadsOAuthConfigData struct {
+ RedirectURI string `json:"redirect_uri"`
+ SuccessRedirect string `json:"success_redirect"`
+ Enabled bool `json:"enabled"`
+ RequiresHTTPS bool `json:"requires_https"`
+}
+
+type ThreadsOAuthLogItem struct {
+ At int64 `json:"at"`
+ Level string `json:"level"`
+ Stage string `json:"stage"`
+ AccountID string `json:"account_id,omitempty"`
+ Message string `json:"message"`
+}
+
+type ThreadsPostMetricCapability struct {
+ Scope string `json:"scope"`
+ Name string `json:"name"`
+ Metrics []string `json:"metrics"`
+ Trackable bool `json:"trackable"`
+ Note string `json:"note,optional"`
+}
+
+type ThreadsPostPerformanceData struct {
+ AccountID string `json:"account_id"`
+ Username string `json:"username,optional"`
+ ThreadsUserID string `json:"threads_user_id,optional"`
+ FetchedAt int64 `json:"fetched_at"`
+ Capabilities []ThreadsPostMetricCapability `json:"capabilities"`
+ AccountInsights ThreadsAccountInsightsBlock `json:"account_insights"`
+ Posts []ThreadsPostPerformanceItem `json:"posts"`
+}
+
+type ThreadsPostPerformanceItem struct {
+ MediaID string `json:"media_id"`
+ Text string `json:"text,optional"`
+ Permalink string `json:"permalink,optional"`
+ Timestamp string `json:"timestamp,optional"`
+ MediaType string `json:"media_type,optional"`
+ MediaURL string `json:"media_url,optional"`
+ ThumbnailURL string `json:"thumbnail_url,optional"`
+ TopicTag string `json:"topic_tag,optional"`
+ Shortcode string `json:"shortcode,optional"`
+ LikeCount int `json:"like_count"`
+ ReplyCount int `json:"reply_count"`
+ RepostCount int `json:"repost_count,optional"`
+ QuoteCount int `json:"quote_count,optional"`
+ Views int `json:"views,optional"`
+ Shares int `json:"shares,optional"`
+ InsightsStatus string `json:"insights_status,optional"`
+ InsightsMessage string `json:"insights_message,optional"`
+}
+
+type ThreadsPublishHealthCheckpoint struct {
+ Checkpoint string `json:"checkpoint"`
+ LikeCount int `json:"like_count"`
+ ReplyCount int `json:"reply_count"`
+ RepostCount int `json:"repost_count"`
+ QuoteCount int `json:"quote_count"`
+ Views int `json:"views,optional"`
+ CheckedAt int64 `json:"checked_at"`
+}
+
+type ThreadsPublishHealthData struct {
+ Summary ThreadsPublishHealthSummary `json:"summary"`
+ Pagination PaginationData `json:"pagination"`
+ List []ThreadsPublishHealthItem `json:"list"`
+}
+
+type ThreadsPublishHealthItem struct {
+ MediaID string `json:"media_id"`
+ Permalink string `json:"permalink,optional"`
+ Text string `json:"text,optional"`
+ PublishedAt int64 `json:"published_at"`
+ LikeCount int `json:"like_count"`
+ ReplyCount int `json:"reply_count"`
+ RepostCount int `json:"repost_count,optional"`
+ QuoteCount int `json:"quote_count,optional"`
+ Views int `json:"views,optional"`
+ Shares int `json:"shares,optional"`
+ Checkpoints []ThreadsPublishHealthCheckpoint `json:"checkpoints"`
+}
+
+type ThreadsPublishHealthSummary struct {
+ PendingScheduled int64 `json:"pending_scheduled"`
+ FailedCount int64 `json:"failed_count"`
+ Published7d int64 `json:"published_7d"`
+ TotalLikes7d int `json:"total_likes_7d"`
+ TotalReplies7d int `json:"total_replies_7d"`
+}
+
+type TopicCandidateData struct {
+ ID string `json:"id"`
+ PersonaID string `json:"persona_id"`
+ Name string `json:"name"`
+ Source string `json:"source,omitempty"`
+ Category string `json:"category,omitempty"`
+ SeedQuery string `json:"seed_query,omitempty"`
+ TrendReason string `json:"trend_reason,omitempty"`
+ TrendKeywords []string `json:"trend_keywords,omitempty"`
+ HeatScore int `json:"heat_score,omitempty"`
+ FitScore int `json:"fit_score,omitempty"`
+ InteractionScore int `json:"interaction_score,omitempty"`
+ ExtendScore int `json:"extend_score,omitempty"`
+ RiskScore int `json:"risk_score,omitempty"`
+ FinalScore float64 `json:"final_score,omitempty"`
+ RecommendedMissions []string `json:"recommended_missions,omitempty"`
+ Status string `json:"status,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+}
+
type UpdateBrandHandlerReq struct {
BrandPath
UpdateBrandReq
@@ -1377,6 +2495,7 @@ type UpdateBrandProductHandlerReq struct {
type UpdateBrandProductReq struct {
Label *string `json:"label,optional"`
ProductContext *string `json:"product_context,optional"`
+ PlacementURL string `json:"placement_url,optional"`
MatchTags []string `json:"match_tags,optional"`
}
@@ -1398,16 +2517,40 @@ type UpdateBrandReq struct {
PatrolKeywords []string `json:"patrol_keywords,optional"`
}
+type UpdateContentPlanHandlerReq struct {
+ ContentPlanPath
+ UpdateContentPlanReq
+}
+
+type UpdateContentPlanReq struct {
+ Topic *string `json:"topic,optional"`
+ Mission *string `json:"mission,optional"`
+ TargetAudience *string `json:"target_audience,optional"`
+ Angle *string `json:"angle,optional"`
+ OpeningType *string `json:"opening_type,optional"`
+ BodyType *string `json:"body_type,optional"`
+ Emotion *string `json:"emotion,optional"`
+ EndingType *string `json:"ending_type,optional"`
+ CtaType *string `json:"cta_type,optional"`
+ RiskLevel *string `json:"risk_level,optional"`
+ RequiresHumanReview *bool `json:"requires_human_review,optional"`
+ Avoid []string `json:"avoid,optional"`
+ SelectedKnowledge []string `json:"selected_knowledge,optional"`
+ Status *string `json:"status,optional"`
+}
+
type UpdateCopyDraftHandlerReq struct {
CopyDraftPath
UpdateCopyDraftReq
}
type UpdateCopyDraftReq struct {
- Text *string `json:"text,optional"`
- Hook *string `json:"hook,optional"`
- Angle *string `json:"angle,optional"`
- Status *string `json:"status,optional"`
+ Text *string `json:"text,optional"`
+ TopicTag *string `json:"topic_tag,optional"`
+ Hook *string `json:"hook,optional"`
+ Angle *string `json:"angle,optional"`
+ Rationale *string `json:"rationale,optional"`
+ Status *string `json:"status,optional"`
}
type UpdateCopyMissionHandlerReq struct {
@@ -1455,6 +2598,18 @@ type UpdateMemberPlacementSettingsReq struct {
BraveSearchLang *string `json:"brave_search_lang,optional"`
ExaUserLocation *string `json:"exa_user_location,optional"`
ExpandStrategy *string `json:"expand_strategy,optional"`
+ DevModeEnabled *bool `json:"dev_mode_enabled,optional"`
+}
+
+type UpdateOutreachDraftHandlerReq struct {
+ BrandPath
+ DraftID string `path:"draftId" validate:"required"`
+ UpdateOutreachDraftReq
+}
+
+type UpdateOutreachDraftReq struct {
+ DraftIndex int `json:"draft_index"`
+ Text string `json:"text" validate:"required"`
}
type UpdatePersonaHandlerReq struct {
@@ -1475,6 +2630,12 @@ type UpdatePlacementTopicHandlerReq struct {
UpdatePlacementTopicReq
}
+type UpdatePlacementTopicOutreachDraftHandlerReq struct {
+ PlacementTopicPath
+ DraftID string `path:"draftId" validate:"required"`
+ UpdateOutreachDraftReq
+}
+
type UpdatePlacementTopicReq struct {
BrandID *string `json:"brand_id,optional"`
TopicName *string `json:"topic_name,optional"`
@@ -1570,95 +2731,47 @@ type UpsertPlacementTopicScanScheduleHandlerReq struct {
UpsertBrandScanScheduleReq
}
-type CrmContactData struct {
- ID string `json:"id"`
- BrandID string `json:"brand_id,omitempty"`
- AuthorID string `json:"author_id"`
- AuthorName string `json:"author_name"`
- AuthorAvatar string `json:"author_avatar,omitempty"`
- AuthorFollowers int `json:"author_followers,omitempty"`
- ScanPostID string `json:"scan_post_id,omitempty"`
- Notes string `json:"notes,omitempty"`
- Tags []string `json:"tags,omitempty"`
- Status string `json:"status"`
- OutreachCount int `json:"outreach_count,omitempty"`
- LastContactedAt int64 `json:"last_contacted_at,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
+type UpsertPublishGuardPolicyHandlerReq struct {
+ ThreadsAccountPath
+ UpsertPublishGuardPolicyReq
}
-type CrmContactListData struct {
- Pagination PaginationData `json:"pagination"`
- List []CrmContactData `json:"list"`
+type UpsertPublishGuardPolicyReq struct {
+ Enabled bool `json:"enabled"`
+ MaxDailyPosts int `json:"max_daily_posts,optional"`
+ MinIntervalMinutes int `json:"min_interval_minutes,optional"`
+ ConsecutiveFailurePauseLimit int `json:"consecutive_failure_pause_limit,optional"`
+ Paused *bool `json:"paused,optional"`
+ PausedReason string `json:"paused_reason,optional"`
}
-type CrmContactIDPath struct {
- ID string `path:"id" validate:"required"`
+type UpsertPublishInventoryPolicyHandlerReq struct {
+ ThreadsAccountPath
+ UpsertPublishInventoryPolicyReq
}
-type ListCrmContactsReq struct {
- BrandID string `form:"brand_id,optional"`
- Status string `form:"status,optional"`
- Tag string `form:"tag,optional"`
- Page int `form:"page,optional"`
- PageSize int `form:"pageSize,optional"`
+type UpsertPublishInventoryPolicyReq struct {
+ Enabled bool `json:"enabled"`
+ TargetDailyCount int `json:"target_daily_count,optional"`
+ LowStockThreshold int `json:"low_stock_threshold,optional"`
+ LookaheadDays int `json:"lookahead_days,optional"`
+ Timezone string `json:"timezone,optional"`
+ Slots []PublishSlotData `json:"slots,optional"`
+ SourceRefs []PublishSourceRefData `json:"source_refs,optional"`
}
-type CreateCrmContactReq struct {
- BrandID string `json:"brand_id,omitempty"`
- AuthorID string `json:"author_id" validate:"required"`
- AuthorName string `json:"author_name" validate:"required"`
+type UpsertStylePresetHandlerReq struct {
+ StylePresetPath
+ UpsertStylePresetReq
}
-type UpdateCrmContactReq struct {
- CrmContactIDPath
- Notes *string `json:"notes,optional"`
- Tags *[]string `json:"tags,optional"`
- Status *string `json:"status,optional"`
-}
-
-type PublishAnalyticsData struct {
- ID string `json:"id"`
- MediaID string `json:"media_id"`
- Permalink string `json:"permalink,omitempty"`
- PublishedAt int64 `json:"published_at"`
- Checkpoint string `json:"checkpoint"`
- LikeCount int `json:"like_count,omitempty"`
- ReplyCount int `json:"reply_count,omitempty"`
- RepostCount int `json:"repost_count,omitempty"`
- QuoteCount int `json:"quote_count,omitempty"`
- ImpressionCount int `json:"impression_count,omitempty"`
- ReachCount int `json:"reach_count,omitempty"`
- CheckedAt int64 `json:"checked_at"`
- CreateAt int64 `json:"create_at"`
-}
-
-type PublishAnalyticsListData struct {
- List []PublishAnalyticsData `json:"list"`
-}
-
-type LookalikeReq struct {
- SourcePostID string `json:"source_post_id" validate:"required"`
- BrandID string `json:"brand_id,optional"`
- Limit int `json:"limit,optional"`
-}
-
-type LookalikeItemData struct {
- PostID string `json:"post_id"`
- AuthorID string `json:"author_id"`
- AuthorName string `json:"author_name"`
- AuthorAvatar string `json:"author_avatar,omitempty"`
- Permalink string `json:"permalink"`
- Text string `json:"text"`
- PlacementScore int `json:"placement_score"`
- SemanticScore int `json:"semantic_score"`
- EngagementPredicted int `json:"engagement_predicted"`
- AudienceQuality int `json:"audience_quality"`
- Similarity float64 `json:"similarity"`
-}
-
-type LookalikeData struct {
- List []LookalikeItemData `json:"list"`
+type UpsertStylePresetReq struct {
+ Name string `json:"name" validate:"required"`
+ Tone string `json:"tone,optional"`
+ CTA []string `json:"cta,optional"`
+ BannedWords []string `json:"banned_words,optional"`
+ Notes string `json:"notes,optional"`
+ Apply bool `json:"apply,optional"`
}
type ViralScanPostData struct {
diff --git a/backend/internal/worker/job/analyze_copy_mission.go b/backend/internal/worker/job/analyze_copy_mission.go
deleted file mode 100644
index cf94d4b..0000000
--- a/backend/internal/worker/job/analyze_copy_mission.go
+++ /dev/null
@@ -1,390 +0,0 @@
-package job
-
-import (
- "context"
- "fmt"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/library/placement"
- libviral "haixun-backend/internal/library/viral"
- "haixun-backend/internal/library/websearch"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
- missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
- placementusecase "haixun-backend/internal/model/placement/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-type AnalyzeCopyMissionDeps struct {
- Jobs jobdom.UseCase
- CopyMission missiondomain.UseCase
- Persona personadomain.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
- Placement placementusecase.UseCase
- AI aiusecase.UseCase
-}
-
-func RegisterAnalyzeCopyMissionHandler(runner *Runner, deps AnalyzeCopyMissionDeps) {
- if runner == nil {
- return
- }
- runner.RegisterStepHandler("copy_mission_map", func(ctx context.Context, step StepContext) error {
- return runAnalyzeCopyMission(ctx, step, deps)
- })
-}
-
-func runAnalyzeCopyMission(ctx context.Context, step StepContext, deps AnalyzeCopyMissionDeps) error {
- payload := step.Run.Payload
- tenantID, ownerUID := runActorFromPayload(payload, step.Run)
- personaID := stringField(payload, "persona_id")
- missionID := stringField(payload, "copy_mission_id")
- if tenantID == "" || ownerUID == "" || personaID == "" || missionID == "" {
- return fmt.Errorf("analyze-copy-mission payload missing tenant_id, owner_uid, persona_id, or copy_mission_id")
- }
-
- mission, err := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID)
- if err != nil {
- return err
- }
- persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID)
- if err != nil {
- return err
- }
-
- updateProgress := func(summary string, percentage int) {
- _ = step.Heartbeat(ctx)
- _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Phase: "copy_mission_map",
- Summary: summary,
- Percentage: percentage,
- })
- }
-
- updateProgress("產生拷貝任務研究地圖…", 15)
-
- credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID)
- if err != nil {
- return err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return err
- }
-
- systemPrompt := libviral.BuildMissionResearchMapSystemPrompt()
- userPrompt := libviral.BuildMissionResearchMapUserPrompt(libviral.CopyResearchMapInput{
- Label: mission.Label,
- SeedQuery: mission.SeedQuery,
- Brief: mission.Brief,
- Persona: persona.Persona,
- StyleBenchmark: persona.StyleBenchmark,
- PersonaAudienceSummary: persona.CopyResearchMap.AudienceSummary,
- PersonaContentGoal: persona.CopyResearchMap.ContentGoal,
- PersonaQuestions: append([]string(nil), persona.CopyResearchMap.Questions...),
- PersonaPillars: append([]string(nil), persona.CopyResearchMap.Pillars...),
- })
- // ResearchGenerateRequest raises max_tokens so the full research map JSON is
- // not truncated mid-object (truncation is the main cause of parse failures).
- result, err := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: systemPrompt,
- Messages: []domai.Message{
- {Role: "user", Content: userPrompt},
- },
- }))
- if err != nil {
- return err
- }
-
- parsed, err := libviral.ParseMissionResearchMapOutput(result.Text)
- if err != nil {
- // Model sometimes replies with reasoning/prose and no JSON object.
- // Retry once, explicitly demanding a raw JSON object.
- jsonOnlyResult, retryErr := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: systemPrompt,
- Messages: []domai.Message{
- {Role: "user", Content: userPrompt},
- {Role: "assistant", Content: result.Text},
- {Role: "user", Content: libviral.MissionResearchMapJSONOnlyRetryPrompt()},
- },
- }))
- if retryErr != nil {
- return app.For(code.AI).SvcThirdParty("拷貝任務研究地圖 LLM 回傳無法解析:" + err.Error())
- }
- parsed, err = libviral.ParseMissionResearchMapOutput(jsonOnlyResult.Text)
- if err != nil {
- return app.For(code.AI).SvcThirdParty("拷貝任務研究地圖 LLM 回傳無法解析:" + err.Error())
- }
- }
-
- entityTags := make([]missionentity.SuggestedTag, 0, len(parsed.SuggestedTags))
- for _, tag := range parsed.SuggestedTags {
- entityTags = append(entityTags, missionentity.SuggestedTag{
- Tag: tag.Tag,
- Reason: tag.Reason,
- SearchIntent: tag.SearchIntent,
- SearchType: tag.SearchType,
- })
- }
- researchMap := missionentity.ResearchMap{
- AudienceSummary: parsed.AudienceSummary,
- ContentGoal: parsed.ContentGoal,
- Questions: parsed.Questions,
- Pillars: parsed.Pillars,
- Exclusions: parsed.Exclusions,
- KnowledgeItems: baseKnowledgeItems(parsed),
- BenchmarkNotes: parsed.BenchmarkNotes,
- SimilarAccounts: similarAccountsFromSummary(mission.ResearchMap.SimilarAccounts),
- }
- researchMap.SuggestedTags = entityTags
- updateProgress("補充相似帳號與延伸知識…", 70)
- enrichMissionResearchMap(ctx, deps, tenantID, ownerUID, mission, &researchMap)
- if len(researchMap.SelectedKnowledgeItems) == 0 {
- researchMap.SelectedKnowledgeItems = defaultSelectedKnowledgeItems(researchMap.KnowledgeItems)
- }
- selected := libviral.PickDefaultSelectedTags(parsed.SuggestedTags)
- selected = ensureSeedQuerySelected(mission.SeedQuery, selected)
-
- mapped := missionentity.StatusMapped
- updateProgress("儲存研究地圖與預設標籤…", 85)
- _, err = deps.CopyMission.Update(ctx, missiondomain.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- PersonaID: personaID,
- MissionID: missionID,
- Patch: missiondomain.MissionPatch{
- ResearchMap: &researchMap,
- SelectedTagsSet: true,
- SelectedTags: selected,
- Status: &mapped,
- },
- })
- if err != nil {
- return err
- }
-
- handoff := map[string]any{
- "flow": "copy",
- "persona_id": personaID,
- "copy_mission_id": missionID,
- "summary": fmt.Sprintf("研究地圖就緒,已預選 %d 個搜尋標籤", len(selected)),
- "next_route": fmt.Sprintf("/matrix/missions/%s", missionID),
- }
-
- _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Result: map[string]any{
- "tag_count": len(entityTags),
- "account_count": 0,
- "selected_count": len(selected),
- "handoff": handoff,
- },
- })
- return err
-}
-
-func enrichMissionResearchMap(
- ctx context.Context,
- deps AnalyzeCopyMissionDeps,
- tenantID, ownerUID string,
- mission *missiondomain.MissionSummary,
- researchMap *missionentity.ResearchMap,
-) {
- if mission == nil || researchMap == nil || deps.Placement == nil || deps.ThreadsAccount == nil {
- return
- }
- settings, err := deps.Placement.ResearchSettings(ctx, tenantID, ownerUID)
- if err != nil || !placement.WebSearchAvailable(settings) {
- return
- }
- memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, ownerUID, settings)
- if err != nil || memberCtx.WebSearchAPIKey() == "" {
- return
- }
- client := websearch.New(memberCtx.WebSearchConfig())
- if !client.Enabled() {
- return
- }
- if len(researchMap.SimilarAccounts) < libviral.MaxSimilarAccounts {
- webAccounts, err := libviral.DiscoverSimilarAccounts(ctx, client, libviral.DiscoverAccountsInput{
- SeedQuery: mission.SeedQuery,
- Brief: mission.Brief,
- Pillars: researchMap.Pillars,
- })
- if err == nil && len(webAccounts) > 0 {
- researchMap.SimilarAccounts = libviral.MergeSimilarAccounts(
- researchMap.SimilarAccounts,
- similarAccountEntitiesFromWeb(webAccounts),
- )
- }
- }
- if notes := collectMissionKnowledgeNotes(ctx, client, memberCtx, mission, researchMap); len(notes) > 0 {
- researchMap.KnowledgeItems = mergeStringLists(researchMap.KnowledgeItems, notes)
- }
-}
-
-func similarAccountEntitiesFromWeb(in []libviral.SimilarAccount) []missionentity.SimilarAccount {
- out := make([]missionentity.SimilarAccount, 0, len(in))
- for _, item := range in {
- out = append(out, missionentity.SimilarAccount{
- Username: item.Username,
- Reason: item.Reason,
- Source: item.Source,
- MatchedSource: item.MatchedSource,
- Confidence: item.Confidence,
- Status: missionentity.SimilarAccountStatusRecommended,
- ProfileURL: item.ProfileURL,
- })
- }
- return out
-}
-
-func collectMissionKnowledgeNotes(
- ctx context.Context,
- client websearch.Client,
- member placement.MemberContext,
- mission *missiondomain.MissionSummary,
- researchMap *missionentity.ResearchMap,
-) []string {
- seed := strings.TrimSpace(mission.SeedQuery)
- if seed == "" {
- return nil
- }
- queries := []string{seed + " 重點 懶人包"}
- if len(researchMap.Questions) > 0 {
- if q := strings.TrimSpace(researchMap.Questions[0]); q != "" && q != seed {
- queries = append(queries, q)
- }
- }
- seen := map[string]struct{}{}
- lines := []string{}
- for _, query := range queries {
- if len(lines) >= 5 {
- break
- }
- res, err := client.Search(ctx, websearch.SearchOptions{
- Query: query,
- Limit: 4,
- Mode: websearch.ModeKnowledgeExpand,
- Country: member.BraveCountry,
- SearchLang: member.BraveSearchLang,
- UserLocation: member.ExaUserLocation,
- })
- if err != nil || res.Status != "success" {
- continue
- }
- for _, item := range res.Results {
- if len(lines) >= 5 {
- break
- }
- title := strings.TrimSpace(item.Title)
- snippet := strings.TrimSpace(item.Snippet)
- if title == "" && snippet == "" {
- continue
- }
- key := strings.ToLower(title + "|" + snippet)
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- line := title
- if snippet != "" {
- if len([]rune(snippet)) > 120 {
- snippet = string([]rune(snippet)[:120])
- }
- line += ":" + snippet
- }
- lines = append(lines, line)
- }
- }
- if len(lines) == 0 {
- return nil
- }
- return lines
-}
-
-func baseKnowledgeItems(parsed libviral.MissionResearchMap) []string {
- items := []string{}
- for _, pillar := range parsed.Pillars {
- if strings.TrimSpace(pillar) != "" {
- items = append(items, "內容支柱:"+strings.TrimSpace(pillar))
- }
- }
- for _, question := range parsed.Questions {
- if strings.TrimSpace(question) != "" {
- items = append(items, "受眾問題:"+strings.TrimSpace(question))
- }
- }
- if strings.TrimSpace(parsed.BenchmarkNotes) != "" {
- items = append(items, "研究筆記:"+strings.TrimSpace(parsed.BenchmarkNotes))
- }
- return mergeStringLists(nil, items)
-}
-
-func defaultSelectedKnowledgeItems(items []string) []string {
- if len(items) == 0 {
- return nil
- }
- if len(items) > 6 {
- return append([]string(nil), items[:6]...)
- }
- return append([]string(nil), items...)
-}
-
-func mergeStringLists(base []string, extra []string) []string {
- seen := map[string]struct{}{}
- out := make([]string, 0, len(base)+len(extra))
- for _, item := range append(append([]string{}, base...), extra...) {
- item = strings.TrimSpace(item)
- if item == "" {
- continue
- }
- if _, ok := seen[item]; ok {
- continue
- }
- seen[item] = struct{}{}
- out = append(out, item)
- }
- return out
-}
-
-func ensureSeedQuerySelected(seed string, selected []string) []string {
- seed = strings.TrimSpace(seed)
- if seed == "" {
- return selected
- }
- for _, item := range selected {
- if strings.EqualFold(strings.TrimSpace(item), seed) {
- return selected
- }
- }
- out := append([]string{seed}, selected...)
- if len(out) > libviral.MaxScanTags {
- out = out[:libviral.MaxScanTags]
- }
- return out
-}
-
-func copyMissionIDFromPayload(payload map[string]any) string {
- if id := stringField(payload, "copy_mission_id"); id != "" {
- return id
- }
- return strings.TrimSpace(stringField(payload, "mission_id"))
-}
diff --git a/backend/internal/worker/job/audience_samples.go b/backend/internal/worker/job/audience_samples.go
deleted file mode 100644
index 41d2507..0000000
--- a/backend/internal/worker/job/audience_samples.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package job
-
-import (
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
- missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
-)
-
-func audienceSamplesFromSummary(in []missiondomain.AudienceSampleSummary) []missionentity.AudienceSample {
- if len(in) == 0 {
- return nil
- }
- out := make([]missionentity.AudienceSample, 0, len(in))
- for _, item := range in {
- out = append(out, missionentity.AudienceSample{
- Username: item.Username,
- SamplePostID: item.SamplePostID,
- SampleText: item.SampleText,
- ReplyLikeCount: item.ReplyLikeCount,
- Appearances: item.Appearances,
- FirstSeenAt: item.FirstSeenAt,
- LastSeenAt: item.LastSeenAt,
- Status: item.Status,
- })
- }
- return out
-}
diff --git a/backend/internal/worker/job/expand_copy_mission_graph.go b/backend/internal/worker/job/expand_copy_mission_graph.go
deleted file mode 100644
index 1da4840..0000000
--- a/backend/internal/worker/job/expand_copy_mission_graph.go
+++ /dev/null
@@ -1,387 +0,0 @@
-package job
-
-import (
- "context"
- "fmt"
- "strings"
-
- "haixun-backend/internal/library/clock"
- libcopy "haixun-backend/internal/library/copymission"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libkg "haixun-backend/internal/library/knowledge"
- "haixun-backend/internal/library/placement"
- libprompt "haixun-backend/internal/library/prompt"
- "haixun-backend/internal/library/websearch"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
- missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
- placementusecase "haixun-backend/internal/model/placement/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-type ExpandCopyMissionGraphDeps struct {
- Jobs jobdom.UseCase
- CopyMission missiondomain.UseCase
- Persona personadomain.UseCase
- KnowledgeGraph kgusecase.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
- Placement placementusecase.UseCase
- AI aiusecase.UseCase
-}
-
-func RegisterExpandCopyMissionGraphHandler(runner *Runner, deps ExpandCopyMissionGraphDeps) {
- if runner == nil {
- return
- }
- runner.RegisterStepHandler("expand_copy_knowledge", func(ctx context.Context, step StepContext) error {
- return runExpandCopyMissionGraph(ctx, step, deps)
- })
-}
-
-func runExpandCopyMissionGraph(ctx context.Context, step StepContext, deps ExpandCopyMissionGraphDeps) error {
- payload := step.Run.Payload
- tenantID, ownerUID := runActorFromPayload(payload, step.Run)
- personaID := stringField(payload, "persona_id")
- missionID := copyMissionIDFromPayload(payload)
- seed := stringField(payload, "seed_query")
- supplemental := boolField(payload, "supplemental")
- if tenantID == "" || ownerUID == "" || personaID == "" || missionID == "" {
- return fmt.Errorf("expand-copy-mission-graph payload missing tenant_id, owner_uid, persona_id, or copy_mission_id")
- }
- if seed == "" {
- return fmt.Errorf("expand-copy-mission-graph payload missing seed_query")
- }
-
- mission, err := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID)
- if err != nil {
- return err
- }
- if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" {
- return app.For(code.Persona).InputMissingRequired("請先產生研究地圖再擴展延伸知識")
- }
- persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID)
- if err != nil {
- return err
- }
-
- research, err := deps.Placement.ResearchSettings(ctx, tenantID, ownerUID)
- if err != nil {
- return err
- }
- expandStrategy := placement.EffectiveExpandStrategy(research)
- if reqStrategy := strings.TrimSpace(stringField(payload, "expand_strategy")); reqStrategy != "" {
- expandStrategy = libkg.ParseExpandStrategy(reqStrategy)
- }
- if supplemental && placement.WebSearchAvailable(research) {
- expandStrategy = libkg.ExpandStrategyBrave
- }
- memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, ownerUID, research)
- if err != nil {
- return err
- }
- webClient := websearch.New(memberCtx.WebSearchConfig())
-
- credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID)
- if err != nil {
- return err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return err
- }
-
- entityMap := summaryResearchMap(mission.ResearchMap)
- missionCtx := libcopy.MissionContext{
- Label: mission.Label,
- SeedQuery: mission.SeedQuery,
- Brief: mission.Brief,
- ResearchMap: entityMap,
- }
- personaCtx := libcopy.PersonaContext{
- DisplayName: persona.DisplayName,
- Persona: persona.Persona,
- }
-
- updateProgress := func(summary string, percentage int) {
- _ = step.Heartbeat(ctx)
- _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Phase: "expand_copy_knowledge",
- Summary: summary,
- Percentage: percentage,
- })
- }
-
- var existing *kgusecase.GraphSummary
- if supplemental {
- existing, _ = deps.KnowledgeGraph.GetByCopyMission(ctx, tenantID, ownerUID, missionID)
- }
-
- braveSources := []libkg.BraveSource{}
- var systemPrompt string
- var userPrompt string
-
- switch expandStrategy {
- case libkg.ExpandStrategyLLM:
- updateProgress("整理延伸知識…", 25)
- systemPrompt, err = libprompt.KnowledgeGraphLLMSystem()
- if err != nil {
- return app.For(code.AI).SysInternal("knowledge graph llm prompt load failed")
- }
- kgVars := map[string]string{
- "seed": seed,
- "product_brief_line": libkg.OptionalPromptLine("任務補充", mission.Brief),
- "target_audience_line": libkg.OptionalPromptLine("目標受眾", mission.ResearchMap.AudienceSummary),
- "persona_line": libkg.OptionalPromptLine("人設", persona.Persona),
- "research_pillars_line": libkg.BulletPromptLine("內容支柱", mission.ResearchMap.Pillars),
- "research_questions_line": libkg.BulletPromptLine("受眾提問", mission.ResearchMap.Questions),
- }
- userPrompt, err = libprompt.KnowledgeGraphLLMUser(kgVars)
- if err != nil {
- return app.For(code.AI).SysInternal("knowledge graph llm user prompt load failed")
- }
- default:
- updateProgress("蒐集參考資料…", 10)
- l1Labels := []string{}
- if existing != nil {
- l1Labels = libkg.L1LabelsFromNodes(existing.Nodes)
- }
- planIn := libcopy.PlanInput(missionCtx, seed, l1Labels, supplemental, expandStrategy)
- queries := libkg.PlanQueries(planIn)
- updateProgress(fmt.Sprintf("蒐集參考資料(%d 項查詢)…", len(queries)), 25)
- braveSources, err = runWebKnowledgeExpand(ctx, webClient, memberCtx, queries, expandStrategy, func(i, total int) {
- pct := 25 + ((i + 1) * 30 / max(total, 1))
- updateProgress(fmt.Sprintf("蒐集參考資料 %d/%d…", i+1, total), pct)
- }, func() error {
- cancelled, _ := deps.Jobs.IsCancelRequested(ctx, step.JobID)
- if cancelled {
- return errJobCancelled
- }
- return ctx.Err()
- })
- if err != nil {
- return err
- }
- updateProgress("整理延伸知識…", 60)
- systemPrompt, err = libprompt.KnowledgeGraphSystem()
- if err != nil {
- return app.For(code.AI).SysInternal("knowledge graph prompt load failed")
- }
- userPrompt, err = libkg.BuildUserPrompt(libcopy.SynthInput(missionCtx, personaCtx, braveSources))
- if err != nil {
- return app.For(code.AI).SysInternal("knowledge graph user prompt load failed")
- }
- }
-
- genReq := placement.ResearchGenerateRequest(domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: systemPrompt,
- Messages: []domai.Message{
- {Role: "user", Content: userPrompt},
- },
- })
- result, err := deps.AI.GenerateText(ctx, genReq)
- if err != nil {
- return err
- }
-
- synthIn := libkg.SynthInput{
- Seed: seed,
- TargetAudience: mission.ResearchMap.AudienceSummary,
- }
- graph, err := libkg.ParseSynthOutput(result.Text, synthIn, braveSources)
- if err != nil {
- // Model occasionally replies with reasoning/prose and no JSON object.
- // Retry once, explicitly demanding a raw JSON object.
- jsonOnlyReq := placement.ResearchGenerateRequest(domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: systemPrompt,
- Messages: []domai.Message{
- {Role: "user", Content: userPrompt},
- {Role: "assistant", Content: result.Text},
- {Role: "user", Content: libkg.KnowledgeGraphJSONOnlyRetryPrompt()},
- },
- })
- retryResult, retryErr := deps.AI.GenerateText(ctx, jsonOnlyReq)
- if retryErr != nil {
- return app.For(code.AI).SvcThirdParty("延伸知識產生失敗,請重試:" + err.Error())
- }
- retryGraph, retryParseErr := libkg.ParseSynthOutput(retryResult.Text, synthIn, braveSources)
- if retryParseErr != nil {
- return app.For(code.AI).SvcThirdParty("延伸知識產生失敗,請重試:" + retryParseErr.Error())
- }
- graph = retryGraph
- result = retryResult
- }
- if libkg.GraphTooThin(graph) {
- retryReq := placement.ResearchGenerateRequest(domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: systemPrompt,
- Messages: []domai.Message{
- {Role: "user", Content: userPrompt},
- {Role: "assistant", Content: result.Text},
- {Role: "user", Content: libkg.KnowledgeGraphRetryUserPrompt()},
- },
- })
- if retryResult, retryErr := deps.AI.GenerateText(ctx, retryReq); retryErr == nil {
- if retryGraph, parseErr := libkg.ParseSynthOutput(retryResult.Text, synthIn, braveSources); parseErr == nil && !libkg.GraphTooThin(retryGraph) {
- graph = retryGraph
- }
- }
- }
-
- if supplemental && existing != nil {
- graph = mergeGraphs(existing, graph, braveSources)
- }
- if libkg.GraphNeedsBootstrap(graph) {
- libkg.SupplementGraphFromResearchMap(&graph, seed, mission.ResearchMap.Pillars, mission.ResearchMap.Questions)
- }
- preserveSelection := map[string]bool{}
- if existing != nil {
- for _, node := range existing.Nodes {
- preserveSelection[node.ID] = node.SelectedForScan
- }
- }
- libcopy.ApplyDefaultNodeSelectionPreserving(graph.Nodes, preserveSelection)
- libkg.DeriveSearchTagsFromGraph(&graph, libcopy.PatrolTagInput(missionCtx))
-
- updateProgress("儲存延伸知識…", 90)
- graph.BraveSources = braveSources
- now := clock.NowUnixNano()
- saved, err := deps.KnowledgeGraph.Upsert(ctx, kgusecase.UpsertRequest{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- BrandID: personaID,
- CopyMissionID: missionID,
- Seed: graph.Seed,
- Nodes: graph.Nodes,
- Edges: graph.Edges,
- BraveSources: graph.BraveSources,
- ExpandStrategy: expandStrategy.String(),
- PainTagCount: graph.PainTagCount,
- GeneratedAt: now,
- })
- if err != nil {
- return err
- }
- if err := syncCopyMissionKnowledgeFromGraph(ctx, deps, tenantID, ownerUID, personaID, missionID, entityMap, saved.Nodes, braveSources); err != nil {
- return err
- }
-
- handoff := map[string]any{
- "flow": "copy",
- "persona_id": personaID,
- "copy_mission_id": missionID,
- "summary": fmt.Sprintf("延伸知識 %d 節點,痛點候選 %d", len(saved.Nodes), saved.PainTagCount),
- "next_route": fmt.Sprintf("/matrix/missions/%s", missionID),
- }
- _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Result: map[string]any{
- "graph_id": saved.ID,
- "seed": saved.Seed,
- "pain_tag_count": saved.PainTagCount,
- "node_count": len(saved.Nodes),
- "handoff": handoff,
- },
- })
- return err
-}
-
-func summaryResearchMap(summary missiondomain.ResearchMapSummary) missionentity.ResearchMap {
- tags := make([]missionentity.SuggestedTag, 0, len(summary.SuggestedTags))
- for _, tag := range summary.SuggestedTags {
- tags = append(tags, missionentity.SuggestedTag{
- Tag: tag.Tag,
- Reason: tag.Reason,
- SearchIntent: tag.SearchIntent,
- SearchType: tag.SearchType,
- })
- }
- accounts := make([]missionentity.SimilarAccount, 0, len(summary.SimilarAccounts))
- for _, acc := range summary.SimilarAccounts {
- accounts = append(accounts, missionentity.SimilarAccount{
- Username: acc.Username,
- Reason: acc.Reason,
- Source: acc.Source,
- MatchedSource: append([]string(nil), acc.MatchedSource...),
- Confidence: acc.Confidence,
- Status: acc.Status,
- TopicRelevance: acc.TopicRelevance,
- LastSeenAt: acc.LastSeenAt,
- ProfileURL: acc.ProfileURL,
- AuthorVerified: acc.AuthorVerified,
- FollowerCount: acc.FollowerCount,
- EngagementScore: acc.EngagementScore,
- LikeCount: acc.LikeCount,
- ReplyCount: acc.ReplyCount,
- PostCount: acc.PostCount,
- })
- }
- samples := make([]missionentity.AudienceSample, 0, len(summary.AudienceSamples))
- for _, sample := range summary.AudienceSamples {
- samples = append(samples, missionentity.AudienceSample{
- Username: sample.Username,
- SamplePostID: sample.SamplePostID,
- SampleText: sample.SampleText,
- ReplyLikeCount: sample.ReplyLikeCount,
- Appearances: sample.Appearances,
- FirstSeenAt: sample.FirstSeenAt,
- LastSeenAt: sample.LastSeenAt,
- Status: sample.Status,
- })
- }
- return missionentity.ResearchMap{
- AudienceSummary: summary.AudienceSummary,
- ContentGoal: summary.ContentGoal,
- Questions: append([]string(nil), summary.Questions...),
- Pillars: append([]string(nil), summary.Pillars...),
- Exclusions: append([]string(nil), summary.Exclusions...),
- KnowledgeItems: append([]string(nil), summary.KnowledgeItems...),
- SelectedKnowledgeItems: append([]string(nil), summary.SelectedKnowledgeItems...),
- SuggestedTags: tags,
- SimilarAccounts: accounts,
- AudienceSamples: samples,
- BenchmarkNotes: summary.BenchmarkNotes,
- }
-}
-
-func syncCopyMissionKnowledgeFromGraph(
- ctx context.Context,
- deps ExpandCopyMissionGraphDeps,
- tenantID, ownerUID, personaID, missionID string,
- prev missionentity.ResearchMap,
- nodes []libkg.Node,
- sources []libkg.BraveSource,
-) error {
- researchMap := libcopy.BuildResearchMapFromGraph(prev, nodes, sources)
- _, err := deps.CopyMission.Update(ctx, missiondomain.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- PersonaID: personaID,
- MissionID: missionID,
- Patch: missiondomain.MissionPatch{
- ResearchMap: &researchMap,
- },
- })
- return err
-}
diff --git a/backend/internal/worker/job/expand_graph.go b/backend/internal/worker/job/expand_graph.go
index 4dc6691..31b5138 100644
--- a/backend/internal/worker/job/expand_graph.go
+++ b/backend/internal/worker/job/expand_graph.go
@@ -175,6 +175,14 @@ func runExpandGraph(ctx context.Context, step StepContext, deps ExpandGraphDeps)
var existing *kgusecase.GraphSummary
if supplemental {
+ if scope.TopicID != "" {
+ existing, _ = deps.KnowledgeGraph.GetByTopic(ctx, tenantID, ownerUID, scope.TopicID, brandID)
+ } else {
+ existing, _ = deps.KnowledgeGraph.Get(ctx, tenantID, ownerUID, brandID)
+ }
+ } else if scope.TopicID != "" {
+ existing, _ = deps.KnowledgeGraph.GetByTopic(ctx, tenantID, ownerUID, scope.TopicID, brandID)
+ } else {
existing, _ = deps.KnowledgeGraph.Get(ctx, tenantID, ownerUID, brandID)
}
@@ -379,7 +387,11 @@ func runExpandGraph(ctx context.Context, step StepContext, deps ExpandGraphDeps)
libkg.SupplementGraphFromResearchMap(&graph, seed, brand.ResearchMap.Pillars, brand.ResearchMap.Questions)
}
patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief)
+ libkg.RescoreGraphIntentFit(&graph, patrolInput)
libkg.DeriveSearchTagsFromGraph(&graph, patrolInput)
+ if existing != nil && len(existing.Nodes) > 0 {
+ libkg.PreserveNodeSelectionByLabel(graph.Nodes, existing.Nodes)
+ }
updateProgress("整理海巡關鍵字…", 88)
if err := syncAutoPatrolKeywords(ctx, deps, tenantID, ownerUID, scope, graph.Nodes, productBrief); err != nil {
@@ -809,13 +821,18 @@ func ensureResearchMap(
}
}
+ patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief)
entityMap := brandentity.ResearchMap{
AudienceSummary: parsed.AudienceSummary,
ContentGoal: parsed.ContentGoal,
- Questions: parsed.Questions,
- Pillars: parsed.Pillars,
+ Questions: libkg.RankStringsByIntent(parsed.Questions, patrolInput),
+ Pillars: libkg.RankStringsByIntent(parsed.Pillars, patrolInput),
Exclusions: parsed.Exclusions,
- PatrolKeywords: libkg.SanitizePatrolKeywordList(parsed.PatrolKeywords),
+ PatrolKeywords: libkg.FilterPatrolKeywordsByIntent(
+ libkg.SanitizePatrolKeywordList(parsed.PatrolKeywords),
+ patrolInput,
+ 18,
+ ),
}
targetAudience := strings.TrimSpace(brand.TargetAudience)
if targetAudience == "" {
diff --git a/backend/internal/worker/job/generate_copy_draft.go b/backend/internal/worker/job/generate_copy_draft.go
deleted file mode 100644
index 29c7ea3..0000000
--- a/backend/internal/worker/job/generate_copy_draft.go
+++ /dev/null
@@ -1,210 +0,0 @@
-package job
-
-import (
- "context"
- "fmt"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/library/style8d"
- libviral "haixun-backend/internal/library/viral"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
- scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-type GenerateCopyDraftDeps struct {
- Jobs jobdom.UseCase
- CopyMission missiondomain.UseCase
- Persona personadomain.UseCase
- ScanPost scanpostdomain.UseCase
- CopyDraft copydraftdomain.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
- AI aiusecase.UseCase
-}
-
-func RegisterGenerateCopyDraftHandler(runner *Runner, deps GenerateCopyDraftDeps) {
- if runner == nil {
- return
- }
- runner.RegisterStepHandler("copy_draft_generate", func(ctx context.Context, step StepContext) error {
- return runGenerateCopyDraft(ctx, step, deps)
- })
-}
-
-func runGenerateCopyDraft(ctx context.Context, step StepContext, deps GenerateCopyDraftDeps) error {
- payload := step.Run.Payload
- tenantID, ownerUID := runActorFromPayload(payload, step.Run)
- personaID := stringField(payload, "persona_id")
- missionID := copyMissionIDFromPayload(payload)
- scanPostID := strings.TrimSpace(stringField(payload, "scan_post_id"))
- if tenantID == "" || ownerUID == "" || personaID == "" || scanPostID == "" {
- return fmt.Errorf("generate-copy-draft payload missing tenant_id, owner_uid, persona_id, or scan_post_id")
- }
-
- updateProgress := func(summary string, percentage int) {
- _ = step.Heartbeat(ctx)
- _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Phase: "copy_draft_generate",
- Summary: summary,
- Percentage: percentage,
- })
- }
-
- updateProgress("讀取爆款原文…", 12)
-
- persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID)
- if err != nil {
- return err
- }
- post, err := deps.ScanPost.GetForPersona(ctx, tenantID, ownerUID, personaID, scanPostID)
- if err != nil {
- return err
- }
- if missionID == "" {
- missionID = strings.TrimSpace(post.CopyMissionID)
- }
-
- topicLabel := strings.TrimSpace(post.SearchTag)
- topicBrief := strings.TrimSpace(persona.Brief)
- if missionID != "" {
- if mission, missionErr := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID); missionErr == nil {
- if label := strings.TrimSpace(mission.Label); label != "" {
- topicLabel = label
- }
- if brief := strings.TrimSpace(mission.Brief); brief != "" {
- topicBrief = brief
- }
- }
- }
-
- if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
- return app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析")
- }
- personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
-
- credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID)
- if err != nil {
- return err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return err
- }
-
- updateProgress("分析爆款結構…", 35)
-
- analysisText := ""
- analyzeResult, analyzeErr := deps.AI.GenerateText(ctx, domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: libviral.BuildAnalyzeViralSystemPrompt(),
- Messages: []domai.Message{
- {
- Role: "user",
- Content: libviral.BuildAnalyzeViralUserPrompt(libviral.AnalyzeViralInput{
- PostText: post.Text,
- AuthorName: post.Author,
- LikeCount: post.LikeCount,
- ReplyCount: post.ReplyCount,
- SearchTag: post.SearchTag,
- TopicLabel: topicLabel,
- TopicBrief: topicBrief,
- Persona: personaBlock,
- }),
- },
- },
- })
- if analyzeErr == nil {
- if parsed, parseErr := libviral.ParseAnalyzeViralOutput(analyzeResult.Text); parseErr == nil {
- analysisText = libviral.FormatAnalysisForReplicate(parsed)
- }
- }
-
- updateProgress("深仿寫產文中…", 65)
-
- result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: libviral.BuildSystemPrompt(),
- Messages: []domai.Message{
- {
- Role: "user",
- Content: libviral.BuildUserPrompt(libviral.ReplicateInput{
- TopicLabel: topicLabel,
- TopicBrief: topicBrief,
- Persona: personaBlock,
- StyleProfile: "",
- OriginalText: post.Text,
- AuthorName: post.Author,
- StructureAnalysis: analysisText,
- }),
- },
- },
- })
- if err != nil {
- return err
- }
-
- parsed, err := libviral.ParseReplicateOutput(result.Text)
- if err != nil {
- return app.For(code.AI).SvcThirdParty("仿寫 LLM 回傳無法解析:" + err.Error())
- }
-
- updateProgress("儲存仿寫草稿…", 90)
-
- saved, err := deps.CopyDraft.Create(ctx, copydraftdomain.CreateRequest{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- PersonaID: personaID,
- CopyMissionID: post.CopyMissionID,
- ScanPostID: scanPostID,
- DraftType: "replicate",
- Text: parsed.Text,
- Angle: parsed.Angle,
- Hook: parsed.Hook,
- Rationale: parsed.Rationale,
- ReferenceNotes: parsed.StructureNotes,
- Sources: []string{post.Permalink},
- })
- if err != nil {
- return err
- }
-
- nextRoute := "/matrix"
- if missionID != "" {
- nextRoute = fmt.Sprintf("/matrix/missions/%s#copy-output", missionID)
- }
- handoff := map[string]any{
- "flow": "copy",
- "persona_id": personaID,
- "copy_mission_id": missionID,
- "scan_post_id": scanPostID,
- "summary": "已產出深仿寫草稿",
- "next_route": nextRoute,
- }
-
- _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Result: map[string]any{
- "draft_id": saved.ID,
- "handoff": handoff,
- },
- })
- return err
-}
diff --git a/backend/internal/worker/job/generate_copy_matrix.go b/backend/internal/worker/job/generate_copy_matrix.go
deleted file mode 100644
index 47ac1a5..0000000
--- a/backend/internal/worker/job/generate_copy_matrix.go
+++ /dev/null
@@ -1,290 +0,0 @@
-package job
-
-import (
- "context"
- "fmt"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libcopy "haixun-backend/internal/library/copymission"
- libkg "haixun-backend/internal/library/knowledge"
- libmatrix "haixun-backend/internal/library/matrix"
- libweb "haixun-backend/internal/library/webpage"
- libprompt "haixun-backend/internal/library/prompt"
- "haixun-backend/internal/library/style8d"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity"
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
- missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
- kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
- scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-type GenerateCopyMatrixDeps struct {
- Jobs jobdom.UseCase
- CopyMission missiondomain.UseCase
- Persona personadomain.UseCase
- ScanPost scanpostdomain.UseCase
- CopyDraft copydraftdomain.UseCase
- KnowledgeGraph kgusecase.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
- AI aiusecase.UseCase
-}
-
-func RegisterGenerateCopyMatrixHandler(runner *Runner, deps GenerateCopyMatrixDeps) {
- if runner == nil {
- return
- }
- runner.RegisterStepHandler("copy_matrix_generate", func(ctx context.Context, step StepContext) error {
- return runGenerateCopyMatrix(ctx, step, deps)
- })
-}
-
-func runGenerateCopyMatrix(ctx context.Context, step StepContext, deps GenerateCopyMatrixDeps) error {
- payload := step.Run.Payload
- tenantID, ownerUID := runActorFromPayload(payload, step.Run)
- personaID := stringField(payload, "persona_id")
- missionID := copyMissionIDFromPayload(payload)
- if tenantID == "" || ownerUID == "" || personaID == "" || missionID == "" {
- return fmt.Errorf("generate-copy-matrix payload missing tenant_id, owner_uid, persona_id, or copy_mission_id")
- }
-
- mission, err := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID)
- if err != nil {
- return err
- }
- if mission.Status != string(missionentity.StatusMapped) &&
- mission.Status != string(missionentity.StatusScanned) &&
- mission.Status != string(missionentity.StatusDrafted) {
- return app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣")
- }
- if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" {
- return app.For(code.Persona).InputMissingRequired("請先完成研究地圖")
- }
-
- persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID)
- if err != nil {
- return err
- }
- if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
- return app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析")
- }
- personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
-
- count := intField(payload, "count")
- if count <= 0 {
- count = 5
- }
- if count > 12 {
- count = 12
- }
-
- updateProgress := func(summary string, percentage int) {
- _ = step.Heartbeat(ctx)
- _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Phase: "copy_matrix_generate",
- Summary: summary,
- Percentage: percentage,
- })
- }
-
- updateProgress("讀取爆款樣本…", 15)
-
- posts, err := deps.ScanPost.ListForPersona(ctx, scanpostdomain.PersonaListRequest{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- PersonaID: personaID,
- CopyMissionID: missionID,
- Limit: 12,
- })
- if err != nil {
- return err
- }
- samples := matrixSamplesFromPosts(posts)
- graphNodes, graphSources := loadCopyMissionGraph(ctx, deps, tenantID, ownerUID, missionID)
- knowledgeItems := libcopy.MatrixKnowledgeItems(
- mission.ResearchMap.SelectedKnowledgeItems,
- mission.ResearchMap.KnowledgeItems,
- graphNodes,
- mission.SelectedTags,
- )
- researchSources := libcopy.ResearchSourcesFromSummaries(mission.ResearchMap.ResearchItems)
- refURLs := libcopy.MergeReferenceURLs(
- libcopy.ReferenceURLsFromSelection(graphNodes, graphSources),
- researchSources,
- )
- researchItemsBlock := libcopy.FormatResearchItemsForMatrix(researchSources)
- var referenceMarkdown string
- if len(refURLs) > 0 {
- updateProgress(fmt.Sprintf("抓取 %d 個參考網頁並轉 Markdown…", len(refURLs)), 32)
- digests := libweb.FetchDigests(ctx, refURLs, libweb.FetchOptions{})
- referenceMarkdown = libmatrix.BuildReferenceMarkdown(digests)
- }
- researchBlock := libmatrix.FormatCopyResearchMapBlockWithReferences(
- mission.ResearchMap.AudienceSummary,
- mission.ResearchMap.ContentGoal,
- mission.ResearchMap.Questions,
- mission.ResearchMap.Pillars,
- mission.ResearchMap.Exclusions,
- knowledgeItems,
- researchItemsBlock,
- referenceMarkdown,
- )
- userPrompt, err := libmatrix.BuildCopyUserPrompt(libmatrix.CopyGenerateInput{
- Count: count,
- TopicLabel: mission.Label,
- TopicBrief: mission.Brief,
- ResearchMap: researchBlock,
- SelectedTags: mission.SelectedTags,
- ViralSamples: samples,
- PersonaBlock: personaBlock,
- })
- if err != nil {
- return app.For(code.AI).SysInternal("matrix user prompt load failed")
- }
- systemPrompt, err := libprompt.MatrixCopySystem()
- if err != nil {
- return app.For(code.AI).SysInternal("matrix system prompt load failed")
- }
-
- matrixSummary := "產出內容矩陣草稿…"
- if len(knowledgeItems) > 0 {
- matrixSummary = fmt.Sprintf("依 %d 項延伸知識產出矩陣…", len(knowledgeItems))
- }
- updateProgress(matrixSummary, 45)
-
- credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID)
- if err != nil {
- return err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return err
- }
- parsed, err := libmatrix.GenerateCopyOutput(ctx, deps.AI, domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: systemPrompt,
- Messages: []domai.Message{
- {Role: "user", Content: userPrompt},
- },
- }, count)
- if err != nil {
- return app.For(code.AI).SvcThirdParty("內容矩陣 LLM 回傳無法解析:" + err.Error())
- }
-
- updateProgress("儲存矩陣草稿…", 88)
-
- createReqs := make([]copydraftdomain.CreateRequest, 0, len(parsed.Rows))
- for _, row := range parsed.Rows {
- createReqs = append(createReqs, copydraftdomain.CreateRequest{
- CopyMissionID: missionID,
- DraftType: copydraftentity.DraftTypeMatrix,
- SortOrder: row.SortOrder,
- Text: row.Text,
- Angle: row.Angle,
- Hook: row.Hook,
- Rationale: row.Rationale,
- ReferenceNotes: row.ReferenceNotes,
- Sources: row.SourcePermalinks,
- })
- }
- saved, err := deps.CopyDraft.ReplaceMissionMatrix(ctx, tenantID, ownerUID, personaID, missionID, createReqs)
- if err != nil {
- return err
- }
-
- drafted := missionentity.StatusDrafted
- _, _ = deps.CopyMission.Update(ctx, missiondomain.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- PersonaID: personaID,
- MissionID: missionID,
- Patch: missiondomain.MissionPatch{
- Status: &drafted,
- },
- })
-
- handoff := map[string]any{
- "flow": "copy",
- "persona_id": personaID,
- "copy_mission_id": missionID,
- "summary": fmt.Sprintf("已產出 %d 篇矩陣草稿", len(saved)),
- "next_route": fmt.Sprintf("/matrix/missions/%s#copy-output", missionID),
- }
-
- _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Result: map[string]any{
- "draft_count": len(saved),
- "handoff": handoff,
- },
- })
- return err
-}
-
-func loadCopyMissionGraph(
- ctx context.Context,
- deps GenerateCopyMatrixDeps,
- tenantID, ownerUID, missionID string,
-) ([]libkg.Node, []libkg.BraveSource) {
- if deps.KnowledgeGraph == nil {
- return nil, nil
- }
- graph, err := deps.KnowledgeGraph.GetByCopyMission(ctx, tenantID, ownerUID, missionID)
- if err != nil || graph == nil {
- return nil, nil
- }
- return graph.Nodes, graph.BraveSources
-}
-
-func matrixSamplesFromPosts(posts []scanpostdomain.ScanPostSummary) string {
- samples := make([]libmatrix.ViralPostSample, 0, len(posts))
- for _, post := range posts {
- replies := make([]libmatrix.ViralReplySample, 0, len(post.Replies))
- for _, reply := range post.Replies {
- replies = append(replies, libmatrix.ViralReplySample{
- Author: reply.Author,
- Text: reply.Text,
- })
- }
- samples = append(samples, libmatrix.ViralPostSample{
- Author: post.Author,
- LikeCount: post.LikeCount,
- SearchTag: post.SearchTag,
- Text: post.Text,
- Replies: replies,
- })
- }
- return libmatrix.FormatViralSamples(samples)
-}
-
-func intField(payload map[string]any, key string) int {
- if payload == nil {
- return 0
- }
- switch v := payload[key].(type) {
- case int:
- return v
- case int32:
- return int(v)
- case int64:
- return int(v)
- case float64:
- return int(v)
- default:
- return 0
- }
-}
diff --git a/backend/internal/worker/job/generate_formula_draft.go b/backend/internal/worker/job/generate_formula_draft.go
new file mode 100644
index 0000000..742b2de
--- /dev/null
+++ b/backend/internal/worker/job/generate_formula_draft.go
@@ -0,0 +1,81 @@
+package job
+
+import (
+ "context"
+ "fmt"
+
+ libpersonacopy "haixun-backend/internal/library/personacopy"
+ aiusecase "haixun-backend/internal/model/ai/usecase"
+ contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase"
+ copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
+ jobdom "haixun-backend/internal/model/job/domain/usecase"
+ personadomain "haixun-backend/internal/model/persona/domain/usecase"
+ placementusecase "haixun-backend/internal/model/placement/usecase"
+ threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
+)
+
+type GenerateFormulaDraftDeps struct {
+ Jobs jobdom.UseCase
+ Persona personadomain.UseCase
+ ContentFormula contentformuladomain.UseCase
+ CopyDraft copydraftdomain.UseCase
+ ThreadsAccount threadsaccountdomain.UseCase
+ Placement placementusecase.UseCase
+ AI aiusecase.UseCase
+}
+
+func RegisterGenerateFormulaDraftHandler(runner *Runner, deps GenerateFormulaDraftDeps) {
+ if runner == nil {
+ return
+ }
+ runner.RegisterStepHandler("formula_draft_generate", func(ctx context.Context, step StepContext) error {
+ return runGenerateFormulaDraft(ctx, step, deps)
+ })
+}
+
+func runGenerateFormulaDraft(ctx context.Context, step StepContext, deps GenerateFormulaDraftDeps) error {
+ payload := step.Run.Payload
+ tenantID, ownerUID := runActorFromPayload(payload, step.Run)
+ personaID := stringField(payload, "persona_id")
+ accountID := stringField(payload, "account_id")
+ formulaID := stringField(payload, "formula_id")
+ topic := stringField(payload, "topic")
+ if tenantID == "" || ownerUID == "" || personaID == "" || accountID == "" || formulaID == "" || topic == "" {
+ return fmt.Errorf("generate-formula-draft payload missing required fields")
+ }
+
+ updateProgress := func(summary string, percentage int) {
+ _ = step.Heartbeat(ctx)
+ _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{
+ JobID: step.JobID,
+ WorkerID: step.WorkerID,
+ Phase: "formula_draft_generate",
+ Summary: summary,
+ Percentage: percentage,
+ })
+ }
+
+ count, err := libpersonacopy.RunFormulaDraft(ctx, libpersonacopy.FormulaDraftDeps{
+ Persona: deps.Persona,
+ ContentFormula: deps.ContentFormula,
+ CopyDraft: deps.CopyDraft,
+ ThreadsAccount: deps.ThreadsAccount,
+ Placement: deps.Placement,
+ AI: deps.AI,
+ }, libpersonacopy.FormulaDraftInput{
+ TenantID: tenantID,
+ OwnerUID: ownerUID,
+ PersonaID: personaID,
+ AccountID: accountID,
+ FormulaID: formulaID,
+ Topic: topic,
+ Brief: stringField(payload, "brief"),
+ UseWebSearch: boolField(payload, "use_web_search"),
+ DraftCount: intField(payload, "draft_count"),
+ }, updateProgress)
+ if err != nil {
+ return err
+ }
+ _ = count
+ return nil
+}
diff --git a/backend/internal/worker/job/generate_outreach_draft.go b/backend/internal/worker/job/generate_outreach_draft.go
new file mode 100644
index 0000000..64cee8c
--- /dev/null
+++ b/backend/internal/worker/job/generate_outreach_draft.go
@@ -0,0 +1,97 @@
+package job
+
+import (
+ "context"
+ "fmt"
+
+ liboutreach "haixun-backend/internal/library/outreach"
+ aiusecase "haixun-backend/internal/model/ai/usecase"
+ branddomain "haixun-backend/internal/model/brand/domain/usecase"
+ jobdom "haixun-backend/internal/model/job/domain/usecase"
+ kgdomain "haixun-backend/internal/model/knowledge_graph/domain/usecase"
+ outreachdraftdomain "haixun-backend/internal/model/outreach_draft/domain/usecase"
+ personadomain "haixun-backend/internal/model/persona/domain/usecase"
+ scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase"
+ threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
+)
+
+type GenerateOutreachDraftDeps struct {
+ Jobs jobdom.UseCase
+ Brand branddomain.UseCase
+ Persona personadomain.UseCase
+ ScanPost scanpostdomain.UseCase
+ KnowledgeGraph kgdomain.UseCase
+ ThreadsAccount threadsaccountdomain.UseCase
+ AI aiusecase.UseCase
+ OutreachDraft outreachdraftdomain.UseCase
+}
+
+func RegisterGenerateOutreachDraftHandler(runner *Runner, deps GenerateOutreachDraftDeps) {
+ if runner == nil {
+ return
+ }
+ runner.RegisterStepHandler("outreach_draft_generate", func(ctx context.Context, step StepContext) error {
+ return runGenerateOutreachDraft(ctx, step, deps)
+ })
+}
+
+func runGenerateOutreachDraft(ctx context.Context, step StepContext, deps GenerateOutreachDraftDeps) error {
+ payload := step.Run.Payload
+ tenantID, ownerUID := runActorFromPayload(payload, step.Run)
+ brandID := stringField(payload, "brand_id")
+ topicID := stringField(payload, "topic_id")
+ scanPostID := stringField(payload, "scan_post_id")
+ voicePersonaID := stringField(payload, "voice_persona_id")
+ if tenantID == "" || ownerUID == "" || brandID == "" || scanPostID == "" || voicePersonaID == "" {
+ return fmt.Errorf("generate-outreach-draft payload missing tenant_id, owner_uid, brand_id, scan_post_id, or voice_persona_id")
+ }
+
+ updateProgress := func(summary string, percentage int) {
+ _ = step.Heartbeat(ctx)
+ _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{
+ JobID: step.JobID,
+ WorkerID: step.WorkerID,
+ Phase: "outreach_draft_generate",
+ Summary: summary,
+ Percentage: percentage,
+ })
+ }
+
+ updateProgress("讀取貼文與產品脈絡…", 15)
+ updateProgress("呼叫 AI 生成獲客草稿…", 45)
+
+ count := intField(payload, "count")
+ if count <= 0 {
+ count = 3
+ }
+
+ regenerate := false
+ if v, ok := payload["regenerate"].(bool); ok {
+ regenerate = v
+ }
+
+ _, err := liboutreach.GenerateDraft(ctx, liboutreach.GenerateDraftDeps{
+ Brand: deps.Brand,
+ Persona: deps.Persona,
+ ScanPost: deps.ScanPost,
+ KnowledgeGraph: deps.KnowledgeGraph,
+ ThreadsAccount: deps.ThreadsAccount,
+ AI: deps.AI,
+ OutreachDraft: deps.OutreachDraft,
+ }, liboutreach.GenerateDraftRequest{
+ TenantID: tenantID,
+ OwnerUID: ownerUID,
+ BrandID: brandID,
+ TopicID: topicID,
+ ScanPostID: scanPostID,
+ Count: count,
+ VoicePersonaID: voicePersonaID,
+ ProductID: stringField(payload, "product_id"),
+ Regenerate: regenerate,
+ })
+ if err != nil {
+ return err
+ }
+ updateProgress("草稿已寫入", 100)
+ return nil
+}
diff --git a/backend/internal/worker/job/generate_rewrite_draft.go b/backend/internal/worker/job/generate_rewrite_draft.go
new file mode 100644
index 0000000..157cd67
--- /dev/null
+++ b/backend/internal/worker/job/generate_rewrite_draft.go
@@ -0,0 +1,82 @@
+package job
+
+import (
+ "context"
+ "fmt"
+
+ libpersonacopy "haixun-backend/internal/library/personacopy"
+ aiusecase "haixun-backend/internal/model/ai/usecase"
+ contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase"
+ copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
+ jobdom "haixun-backend/internal/model/job/domain/usecase"
+ personadomain "haixun-backend/internal/model/persona/domain/usecase"
+ placementusecase "haixun-backend/internal/model/placement/usecase"
+ threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
+)
+
+type GenerateRewriteDraftDeps struct {
+ Jobs jobdom.UseCase
+ Persona personadomain.UseCase
+ ContentFormula contentformuladomain.UseCase
+ CopyDraft copydraftdomain.UseCase
+ ThreadsAccount threadsaccountdomain.UseCase
+ Placement placementusecase.UseCase
+ AI aiusecase.UseCase
+}
+
+func RegisterGenerateRewriteDraftHandler(runner *Runner, deps GenerateRewriteDraftDeps) {
+ if runner == nil {
+ return
+ }
+ runner.RegisterStepHandler("rewrite_draft_generate", func(ctx context.Context, step StepContext) error {
+ return runGenerateRewriteDraft(ctx, step, deps)
+ })
+}
+
+func runGenerateRewriteDraft(ctx context.Context, step StepContext, deps GenerateRewriteDraftDeps) error {
+ payload := step.Run.Payload
+ tenantID, ownerUID := runActorFromPayload(payload, step.Run)
+ personaID := stringField(payload, "persona_id")
+ accountID := stringField(payload, "account_id")
+ referenceText := stringField(payload, "reference_text")
+ topic := stringField(payload, "topic")
+ if tenantID == "" || ownerUID == "" || personaID == "" || accountID == "" || referenceText == "" || topic == "" {
+ return fmt.Errorf("generate-rewrite-draft payload missing required fields")
+ }
+
+ updateProgress := func(summary string, percentage int) {
+ _ = step.Heartbeat(ctx)
+ _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{
+ JobID: step.JobID,
+ WorkerID: step.WorkerID,
+ Phase: "rewrite_draft_generate",
+ Summary: summary,
+ Percentage: percentage,
+ })
+ }
+
+ count, err := libpersonacopy.RunRewriteDraft(ctx, libpersonacopy.RewriteDraftDeps{
+ Persona: deps.Persona,
+ ContentFormula: deps.ContentFormula,
+ CopyDraft: deps.CopyDraft,
+ ThreadsAccount: deps.ThreadsAccount,
+ Placement: deps.Placement,
+ AI: deps.AI,
+ }, libpersonacopy.RewriteDraftInput{
+ TenantID: tenantID,
+ OwnerUID: ownerUID,
+ PersonaID: personaID,
+ AccountID: accountID,
+ ReferenceText: referenceText,
+ Topic: topic,
+ Brief: stringField(payload, "brief"),
+ SaveLabel: stringField(payload, "save_label"),
+ UseWebSearch: boolField(payload, "use_web_search"),
+ DraftCount: intField(payload, "draft_count"),
+ }, updateProgress)
+ if err != nil {
+ return err
+ }
+ _ = count
+ return nil
+}
diff --git a/backend/internal/worker/job/generate_topic_matrix.go b/backend/internal/worker/job/generate_topic_matrix.go
new file mode 100644
index 0000000..700b416
--- /dev/null
+++ b/backend/internal/worker/job/generate_topic_matrix.go
@@ -0,0 +1,78 @@
+package job
+
+import (
+ "context"
+ "fmt"
+
+ libpersonacopy "haixun-backend/internal/library/personacopy"
+ aiusecase "haixun-backend/internal/model/ai/usecase"
+ contentopsdomain "haixun-backend/internal/model/content_ops/domain/usecase"
+ copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
+ jobdom "haixun-backend/internal/model/job/domain/usecase"
+ personadomain "haixun-backend/internal/model/persona/domain/usecase"
+ placementusecase "haixun-backend/internal/model/placement/usecase"
+ threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
+)
+
+type GenerateTopicMatrixDeps struct {
+ Jobs jobdom.UseCase
+ Persona personadomain.UseCase
+ CopyDraft copydraftdomain.UseCase
+ ContentOps contentopsdomain.UseCase
+ ThreadsAccount threadsaccountdomain.UseCase
+ Placement placementusecase.UseCase
+ AI aiusecase.UseCase
+}
+
+func RegisterGenerateTopicMatrixHandler(runner *Runner, deps GenerateTopicMatrixDeps) {
+ if runner == nil {
+ return
+ }
+ runner.RegisterStepHandler("topic_matrix_generate", func(ctx context.Context, step StepContext) error {
+ return runGenerateTopicMatrix(ctx, step, deps)
+ })
+}
+
+func runGenerateTopicMatrix(ctx context.Context, step StepContext, deps GenerateTopicMatrixDeps) error {
+ payload := step.Run.Payload
+ tenantID, ownerUID := runActorFromPayload(payload, step.Run)
+ personaID := stringField(payload, "persona_id")
+ topic := stringField(payload, "topic")
+ if tenantID == "" || ownerUID == "" || personaID == "" || topic == "" {
+ return fmt.Errorf("generate-topic-matrix payload missing tenant_id, owner_uid, persona_id, or topic")
+ }
+
+ updateProgress := func(summary string, percentage int) {
+ _ = step.Heartbeat(ctx)
+ _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{
+ JobID: step.JobID,
+ WorkerID: step.WorkerID,
+ Phase: "topic_matrix_generate",
+ Summary: summary,
+ Percentage: percentage,
+ })
+ }
+
+ created, err := libpersonacopy.RunTopicMatrix(ctx, libpersonacopy.TopicMatrixDeps{
+ Persona: deps.Persona,
+ CopyDraft: deps.CopyDraft,
+ ContentOps: deps.ContentOps,
+ ThreadsAccount: deps.ThreadsAccount,
+ Placement: deps.Placement,
+ AI: deps.AI,
+ }, libpersonacopy.TopicMatrixInput{
+ TenantID: tenantID,
+ OwnerUID: ownerUID,
+ PersonaID: personaID,
+ ContentPlanID: stringField(payload, "content_plan_id"),
+ Topic: topic,
+ Brief: stringField(payload, "brief"),
+ UseWebSearch: boolField(payload, "use_web_search"),
+ DraftCount: intField(payload, "draft_count"),
+ }, updateProgress)
+ if err != nil {
+ return err
+ }
+ _ = created
+ return nil
+}
diff --git a/backend/internal/worker/job/payload_helpers.go b/backend/internal/worker/job/payload_helpers.go
new file mode 100644
index 0000000..7524168
--- /dev/null
+++ b/backend/internal/worker/job/payload_helpers.go
@@ -0,0 +1,89 @@
+package job
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
+ missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
+)
+
+func intField(payload map[string]any, key string) int {
+ if payload == nil {
+ return 0
+ }
+ switch v := payload[key].(type) {
+ case int:
+ return v
+ case int64:
+ return int(v)
+ case float64:
+ return int(v)
+ case string:
+ n, _ := strconv.Atoi(strings.TrimSpace(v))
+ return n
+ default:
+ return 0
+ }
+}
+
+func copyMissionIDFromPayload(payload map[string]any) string {
+ if id := stringField(payload, "copy_mission_id"); id != "" {
+ return id
+ }
+ return stringField(payload, "mission_id")
+}
+
+func similarAccountsFromSummary(items []missiondomain.SimilarAccountSummary) []missionentity.SimilarAccount {
+ out := make([]missionentity.SimilarAccount, 0, len(items))
+ for _, item := range items {
+ username := strings.TrimSpace(strings.TrimPrefix(item.Username, "@"))
+ if username == "" {
+ continue
+ }
+ out = append(out, missionentity.SimilarAccount{
+ Username: username,
+ Reason: item.Reason,
+ Source: item.Source,
+ MatchedSource: append([]string(nil), item.MatchedSource...),
+ Confidence: item.Confidence,
+ Status: item.Status,
+ TopicRelevance: item.TopicRelevance,
+ LastSeenAt: item.LastSeenAt,
+ ProfileURL: item.ProfileURL,
+ AuthorVerified: item.AuthorVerified,
+ FollowerCount: item.FollowerCount,
+ EngagementScore: item.EngagementScore,
+ LikeCount: item.LikeCount,
+ ReplyCount: item.ReplyCount,
+ PostCount: item.PostCount,
+ })
+ }
+ return out
+}
+
+func audienceSamplesFromSummary(items []missiondomain.AudienceSampleSummary) []missionentity.AudienceSample {
+ out := make([]missionentity.AudienceSample, 0, len(items))
+ for _, item := range items {
+ username := strings.TrimSpace(strings.TrimPrefix(item.Username, "@"))
+ if username == "" {
+ continue
+ }
+ out = append(out, missionentity.AudienceSample{
+ Username: username,
+ SamplePostID: item.SamplePostID,
+ SampleText: item.SampleText,
+ ReplyLikeCount: item.ReplyLikeCount,
+ Appearances: item.Appearances,
+ FirstSeenAt: item.FirstSeenAt,
+ LastSeenAt: item.LastSeenAt,
+ Status: item.Status,
+ })
+ }
+ return out
+}
+
+func debugPayloadValue(value any) string {
+ return fmt.Sprintf("%v", value)
+}
diff --git a/backend/internal/worker/job/publish_analytics.go b/backend/internal/worker/job/publish_analytics.go
index 8520bf6..6067352 100644
--- a/backend/internal/worker/job/publish_analytics.go
+++ b/backend/internal/worker/job/publish_analytics.go
@@ -37,15 +37,19 @@ func runPublishAnalyticsCheck(ctx context.Context, step StepContext, deps Publis
return fmt.Errorf("publish-analytics payload missing media_id or checkpoint")
}
- cred, err := deps.ThreadsAccount.ResolveMemberThreadsPublishCredential(ctx, tenantID, ownerUID)
- if err != nil {
- return fmt.Errorf("resolve publish credential: %w", err)
+ accountID := strings.TrimSpace(stringField(payload, "account_id"))
+ if accountID == "" {
+ accountID = strings.TrimSpace(step.Run.ScopeID)
}
- if cred == nil || cred.AccessToken == "" {
+ token, err := deps.ThreadsAccount.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return fmt.Errorf("resolve api token: %w", err)
+ }
+ if token == "" {
return fmt.Errorf("no access token found")
}
- stats, err := libthreads.GetMediaStats(ctx, cred.AccessToken, mediaID)
+ stats, err := libthreads.GetMediaStats(ctx, token, mediaID)
if err != nil {
return fmt.Errorf("get media stats: %w", err)
}
@@ -59,6 +63,8 @@ func runPublishAnalyticsCheck(ctx context.Context, step StepContext, deps Publis
ReplyCount: stats.ReplyCount,
RepostCount: stats.RepostCount,
QuoteCount: stats.QuoteCount,
+ ViewCount: stats.Views,
+ ShareCount: stats.Shares,
})
if err != nil {
return fmt.Errorf("record checkpoint: %w", err)
@@ -74,6 +80,8 @@ func runPublishAnalyticsCheck(ctx context.Context, step StepContext, deps Publis
"reply_count": saved.ReplyCount,
"repost_count": saved.RepostCount,
"quote_count": saved.QuoteCount,
+ "view_count": saved.ViewCount,
+ "share_count": saved.ShareCount,
"analytics_id": saved.ID,
},
})
diff --git a/backend/internal/worker/job/publish_queue_sweeper.go b/backend/internal/worker/job/publish_queue_sweeper.go
new file mode 100644
index 0000000..746ccbb
--- /dev/null
+++ b/backend/internal/worker/job/publish_queue_sweeper.go
@@ -0,0 +1,56 @@
+package job
+
+import (
+ "context"
+ "log"
+ "time"
+
+ publishqueuedomain "haixun-backend/internal/model/publish_queue/domain/usecase"
+)
+
+type PublishQueueSweeper struct {
+ queue publishqueuedomain.UseCase
+ batchSize int
+}
+
+func NewPublishQueueSweeper(queue publishqueuedomain.UseCase, batchSize int) *PublishQueueSweeper {
+ if batchSize <= 0 {
+ batchSize = 20
+ }
+ return &PublishQueueSweeper{queue: queue, batchSize: batchSize}
+}
+
+func (s *PublishQueueSweeper) Start(ctx context.Context, interval time.Duration) {
+ if s == nil || s.queue == nil {
+ return
+ }
+ if interval <= 0 {
+ interval = time.Minute
+ }
+ log.Printf("publish queue sweeper started: interval=%s batch=%d", interval, s.batchSize)
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ log.Printf("publish queue sweeper stopped")
+ return
+ case <-ticker.C:
+ missed, err := s.queue.MarkMissed(ctx, int64(10*time.Minute), s.batchSize)
+ if err != nil {
+ log.Printf("publish queue missed sweep error: %v", err)
+ } else if missed > 0 {
+ log.Printf("publish queue marked %d missed item(s)", missed)
+ }
+ dispatched, err := s.queue.DispatchDue(ctx, s.batchSize)
+ if err != nil {
+ log.Printf("publish queue sweeper tick error: %v", err)
+ continue
+ }
+ if dispatched > 0 {
+ log.Printf("publish queue sweeper dispatched %d item(s)", dispatched)
+ }
+ }
+ }
+}
diff --git a/backend/internal/worker/job/refresh_threads_token.go b/backend/internal/worker/job/refresh_threads_token.go
index d33668d..500f74f 100644
--- a/backend/internal/worker/job/refresh_threads_token.go
+++ b/backend/internal/worker/job/refresh_threads_token.go
@@ -5,7 +5,6 @@ import (
"fmt"
"strings"
- libthreads "haixun-backend/internal/library/threadsapi"
jobdom "haixun-backend/internal/model/job/domain/usecase"
threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
)
@@ -32,31 +31,17 @@ func runRefreshThreadsToken(ctx context.Context, step StepContext, deps RefreshT
return fmt.Errorf("refresh-threads-token payload missing tenant_id, owner_uid, or account_id")
}
- cred, err := deps.ThreadsAccount.ResolveMemberThreadsPublishCredential(ctx, tenantID, ownerUID)
+ saved, err := deps.ThreadsAccount.RefreshAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID)
if err != nil {
- return fmt.Errorf("resolve publish credential: %w", err)
- }
- if cred == nil || cred.AccessToken == "" {
- return fmt.Errorf("no access token found for account %s", accountID)
- }
-
- result, err := libthreads.RefreshAccessToken(ctx, cred.AccessToken)
- if err != nil {
- return fmt.Errorf("refresh token: %w", err)
- }
-
- expiresAt := libthreads.NsFromNow(result.ExpiresIn)
- if _, err := deps.ThreadsAccount.SaveAPIAccessToken(ctx, accountID, result.AccessToken, expiresAt); err != nil {
- return fmt.Errorf("save refreshed token: %w", err)
+ return fmt.Errorf("refresh account token: %w", err)
}
_, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{
JobID: step.JobID,
WorkerID: step.WorkerID,
Result: map[string]any{
- "account_id": accountID,
- "expires_at": expiresAt,
- "token_short": result.AccessToken[:min(len(result.AccessToken), 8)] + "...",
+ "account_id": accountID,
+ "expires_at": saved.APITokenExpiresAt,
},
})
return err
diff --git a/backend/internal/worker/job/scan_placement.go b/backend/internal/worker/job/scan_placement.go
index 12d9fab..3500db5 100644
--- a/backend/internal/worker/job/scan_placement.go
+++ b/backend/internal/worker/job/scan_placement.go
@@ -50,6 +50,7 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD
}
patrolMode := boolField(payload, "patrol_mode")
+ testPatrol := boolField(payload, "test_patrol")
brand, brandErr := deps.Brand.Get(ctx, tenantID, ownerUID, brandID)
if brandErr != nil {
@@ -105,14 +106,22 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD
if graphSummary != nil {
patrolNodes = graphSummary.Nodes
}
+ patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief)
+ patrolInput = libkg.OverlayResearchMap(
+ patrolInput,
+ researchMap.AudienceSummary,
+ researchMap.Questions,
+ researchMap.Pillars,
+ researchMap.PatrolKeywords,
+ )
patrolKeywords = libkg.ResolveScanPatrolKeywords(
stringSliceField(payload, "patrol_keywords"),
researchMap.PatrolKeywords,
- libkg.PatrolTagInputFromBrand(brand, productBrief),
- patrolNodes,
+ patrolInput,
+ libkg.NodesForPatrolKeywordDerivation(patrolNodes),
)
if len(patrolKeywords) == 0 {
- return fmt.Errorf("請先在研究地圖填寫要回覆的海巡關鍵字")
+ return fmt.Errorf("無法從產品與研究地圖產出足夠相關的搜尋關鍵字,請補充研究地圖或勾選圖譜節點")
}
}
@@ -142,21 +151,25 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD
if err != nil {
return err
}
- if !memberCtx.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler {
- return fmt.Errorf("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session 設定")
- }
- if placement.MemberNeedsWebSearchKey(memberCtx) && strings.TrimSpace(memberCtx.WebSearchAPIKey()) == "" {
- return fmt.Errorf("%s", placement.WebSearchKeyRequiredMessage(placement.ResearchSettings{
- WebSearchProvider: memberCtx.WebSearchProvider,
- BraveAPIKey: memberCtx.BraveAPIKey,
- ExaAPIKey: memberCtx.ExaAPIKey,
- }))
- }
- if memberCtx.DevMode && !memberCtx.BrowserConnected {
- return fmt.Errorf("開發模式需先同步 Chrome Session")
- }
- if !memberCtx.DevMode && memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected {
- return fmt.Errorf("正式模式需先完成 Threads API 連線")
+ if testPatrol {
+ if !memberCtx.BrowserConnected {
+ return fmt.Errorf("測試海巡需先同步 Chrome Extension Session")
+ }
+ memberCtx = placement.MemberContextForCrawlerOnly(memberCtx)
+ } else {
+ if !memberCtx.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler {
+ return fmt.Errorf("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session 設定")
+ }
+ if placement.MemberNeedsWebSearchKey(memberCtx) && strings.TrimSpace(memberCtx.WebSearchAPIKey()) == "" {
+ return fmt.Errorf("%s", placement.WebSearchKeyRequiredMessage(placement.ResearchSettings{
+ WebSearchProvider: memberCtx.WebSearchProvider,
+ BraveAPIKey: memberCtx.BraveAPIKey,
+ ExaAPIKey: memberCtx.ExaAPIKey,
+ }))
+ }
+ if memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected {
+ return fmt.Errorf("請先完成 Threads API 連線後再開始海巡")
+ }
}
updateProgress := func(summary string, percentage int) {
@@ -171,17 +184,27 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD
}
exclusions := append([]string{}, researchMap.Exclusions...)
+ patrolInput := libkg.PatrolTagInputFromBrand(brand, strings.TrimSpace(brand.ProductBrief))
+ if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" {
+ patrolInput.ProductFeatures = formatted
+ }
+ patrolInput = libkg.OverlayResearchMap(
+ patrolInput,
+ researchMap.AudienceSummary,
+ researchMap.Questions,
+ researchMap.Pillars,
+ researchMap.PatrolKeywords,
+ )
+ scanContext := placement.PostScanContextFromPatrolInput(patrolInput, exclusions)
- if len(patrolKeywords) > 0 {
+ if testPatrol {
+ updateProgress(fmt.Sprintf("測試海巡(爬蟲):依 %d 組關鍵字準備搜尋…", len(patrolKeywords)), 5)
+ } else if len(patrolKeywords) > 0 {
updateProgress(fmt.Sprintf("依 %d 組海巡關鍵字準備雙軌搜尋…", len(patrolKeywords)), 5)
} else {
updateProgress("準備置入海巡…", 5)
}
- if err := deps.ScanPost.ClearPlacementScan(ctx, tenantID, ownerUID, brandID, topicID); err != nil {
- return err
- }
-
webClient := websearch.New(memberCtx.WebSearchConfig())
crawlerFn := placement.WrapPoliteCrawler(makeCrawlerSearchFn(deps, tenantID, ownerUID))
graphNodes := []libkg.Node{}
@@ -200,6 +223,7 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD
Nodes: graphNodes,
PatrolKeywords: patrolKeywords,
Exclusions: exclusions,
+ PatrolContext: scanContext,
Member: memberCtx,
WebSearch: webClient,
Crawler: crawlerFn,
@@ -267,12 +291,22 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD
}
}
+ summaryLabel := "雙軌海巡"
+ discoverNote := fmt.Sprintf(
+ "Threads API + %s Search API 雙管道搜尋;候選 %d 篇已寫入",
+ memberCtx.WebSearchProviderLabel(),
+ count,
+ )
+ if testPatrol {
+ summaryLabel = "測試海巡(爬蟲)"
+ discoverNote = fmt.Sprintf("Chrome 爬蟲搜尋;候選 %d 篇已寫入", count)
+ }
handoff := map[string]any{
"flow": "placement",
"brand_id": brandID,
"summary": fmt.Sprintf(
- "雙軌海巡完成:%d 篇(gold %d、近期軌 %d、產品可解 %d)",
- count, gold, recent, solved,
+ "%s完成:%d 篇(gold %d、近期軌 %d、產品可解 %d)",
+ summaryLabel, count, gold, recent, solved,
),
"pain_breakdown": map[string]any{
"posts": count,
@@ -281,6 +315,8 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD
"solved_by_prod": solved,
"replies": replyCount,
},
+ "discover_note": discoverNote,
+ "test_patrol": testPatrol,
"next_route": "/outreach?brand=" + brandID,
"needs_supplemental_expand": false,
"search_source_mode": string(memberCtx.SearchSourceMode),
diff --git a/backend/internal/worker/job/similar_accounts.go b/backend/internal/worker/job/similar_accounts.go
deleted file mode 100644
index 19f9b7a..0000000
--- a/backend/internal/worker/job/similar_accounts.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package job
-
-import (
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
- missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
-)
-
-// similarAccountsFromSummary converts the read-side usecase summary back into a
-// write-side entity slice so that worker jobs can preserve previously
-// discovered accounts (including MatchedSource provenance) across re-runs
-// without losing information. Fields that the summary does not carry are left
-// at their zero value.
-func similarAccountsFromSummary(in []missiondomain.SimilarAccountSummary) []missionentity.SimilarAccount {
- if len(in) == 0 {
- return nil
- }
- out := make([]missionentity.SimilarAccount, 0, len(in))
- for _, item := range in {
- matched := item.MatchedSource
- if len(matched) == 0 && item.Source != "" {
- matched = []string{item.Source}
- }
- out = append(out, missionentity.SimilarAccount{
- Username: item.Username,
- Reason: item.Reason,
- Source: item.Source,
- MatchedSource: matched,
- Confidence: item.Confidence,
- Status: item.Status,
- TopicRelevance: item.TopicRelevance,
- LastSeenAt: item.LastSeenAt,
- ProfileURL: item.ProfileURL,
- AuthorVerified: item.AuthorVerified,
- FollowerCount: item.FollowerCount,
- EngagementScore: item.EngagementScore,
- LikeCount: item.LikeCount,
- ReplyCount: item.ReplyCount,
- PostCount: item.PostCount,
- })
- }
- return out
-}
diff --git a/backend/internal/worker/job/threads_token_sweeper.go b/backend/internal/worker/job/threads_token_sweeper.go
new file mode 100644
index 0000000..6114037
--- /dev/null
+++ b/backend/internal/worker/job/threads_token_sweeper.go
@@ -0,0 +1,94 @@
+package job
+
+import (
+ "context"
+ "log"
+ "time"
+
+ jobdom "haixun-backend/internal/model/job/domain/usecase"
+ threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
+)
+
+const refreshThreadsTokenTemplateType = "refresh-threads-token"
+
+type ThreadsTokenSweeper struct {
+ accounts threadsaccountdomain.UseCase
+ jobs jobdom.UseCase
+ leadTime time.Duration
+}
+
+func NewThreadsTokenSweeper(
+ accounts threadsaccountdomain.UseCase,
+ jobs jobdom.UseCase,
+ leadTime time.Duration,
+) *ThreadsTokenSweeper {
+ if leadTime <= 0 {
+ leadTime = 7 * 24 * time.Hour
+ }
+ return &ThreadsTokenSweeper{
+ accounts: accounts,
+ jobs: jobs,
+ leadTime: leadTime,
+ }
+}
+
+func (s *ThreadsTokenSweeper) Start(ctx context.Context, interval time.Duration) {
+ if s == nil || s.accounts == nil || s.jobs == nil {
+ return
+ }
+ if interval <= 0 {
+ interval = time.Hour
+ }
+ log.Printf("threads token sweeper started: interval=%s lead_time=%s", interval, s.leadTime)
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ log.Printf("threads token sweeper stopped")
+ return
+ case <-ticker.C:
+ created, err := s.tick(ctx)
+ if err != nil {
+ log.Printf("threads token sweeper tick error: %v", err)
+ continue
+ }
+ if created > 0 {
+ log.Printf("threads token sweeper enqueued %d refresh job(s)", created)
+ }
+ }
+ }
+}
+
+func (s *ThreadsTokenSweeper) tick(ctx context.Context) (int, error) {
+ candidates, err := s.accounts.ListTokenRefreshCandidates(ctx, s.leadTime)
+ if err != nil {
+ return 0, err
+ }
+ created := 0
+ for _, item := range candidates {
+ if item.AccountID == "" || item.TenantID == "" || item.OwnerUID == "" {
+ continue
+ }
+ _, err := s.jobs.CreateRun(ctx, jobdom.CreateRunRequest{
+ TemplateType: refreshThreadsTokenTemplateType,
+ Scope: "threads_account",
+ ScopeID: item.AccountID,
+ TenantID: item.TenantID,
+ OwnerUID: item.OwnerUID,
+ Payload: map[string]any{
+ "tenant_id": item.TenantID,
+ "owner_uid": item.OwnerUID,
+ "account_id": item.AccountID,
+ "expires_at": item.ExpiresAt,
+ },
+ })
+ if err != nil {
+ log.Printf("threads token sweeper skip account=%s: %v", item.AccountID, err)
+ continue
+ }
+ created++
+ }
+ return created, nil
+}
diff --git a/backend/web/Dockerfile b/backend/web/Dockerfile
new file mode 100644
index 0000000..12ace45
--- /dev/null
+++ b/backend/web/Dockerfile
@@ -0,0 +1,13 @@
+FROM node:22-alpine AS build
+
+WORKDIR /src
+COPY backend/web/package*.json ./
+RUN npm ci
+COPY backend/web/ ./
+RUN npm run build
+
+FROM nginx:1.27-alpine
+
+COPY deploy/nginx/default.conf /etc/nginx/conf.d/default.conf
+COPY --from=build /src/dist /usr/share/nginx/html
+EXPOSE 80
diff --git a/backend/web/index.html b/backend/web/index.html
new file mode 100644
index 0000000..212d61b
--- /dev/null
+++ b/backend/web/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ 巡樓 Console
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/web/package-lock.json b/backend/web/package-lock.json
new file mode 100644
index 0000000..1d5090e
--- /dev/null
+++ b/backend/web/package-lock.json
@@ -0,0 +1,1718 @@
+{
+ "name": "haixun-console-web",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "haixun-console-web",
+ "version": "0.1.0",
+ "dependencies": {
+ "@vitejs/plugin-react": "^4.3.4",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "taipei-sans-tc": "^0.1.1",
+ "typescript": "^5.8.2",
+ "vite": "^6.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.10",
+ "@types/react-dom": "^19.0.4"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.40",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
+ "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.4",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
+ "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.38",
+ "caniuse-lite": "^1.0.30001799",
+ "electron-to-chromium": "^1.5.376",
+ "node-releases": "^2.0.48",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001799",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
+ "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.381",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz",
+ "integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==",
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.15",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.50",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
+ "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.16",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
+ "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.7"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/taipei-sans-tc": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/taipei-sans-tc/-/taipei-sans-tc-0.1.1.tgz",
+ "integrity": "sha512-GCvdO+SATITfm128bT+fFmbpVW8+o0zLMVlNskSOk3CBghNzfqj4tvwTbIQ9vmvftd31s5UNypLA3aIw/IG6RQ==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "license": "ISC"
+ }
+ }
+}
diff --git a/backend/web/package.json b/backend/web/package.json
new file mode 100644
index 0000000..686eb95
--- /dev/null
+++ b/backend/web/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "haixun-console-web",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@vitejs/plugin-react": "^4.3.4",
+ "vite": "^6.2.0",
+ "typescript": "^5.8.2",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "taipei-sans-tc": "^0.1.1"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.10",
+ "@types/react-dom": "^19.0.4"
+ }
+}
diff --git a/backend/web/src/App.tsx b/backend/web/src/App.tsx
new file mode 100644
index 0000000..f47d478
--- /dev/null
+++ b/backend/web/src/App.tsx
@@ -0,0 +1,150 @@
+import { useEffect, useState } from "react";
+import { api } from "./api/haixun";
+import { useAuth } from "./auth/AuthContext";
+import { Layout } from "./components/Layout";
+import { Button, Card, PageTitle } from "./components/ui";
+import { AuthPage } from "./pages/AuthPage";
+import { AccountsPage } from "./pages/AccountsPage";
+import { DashboardPage } from "./pages/DashboardPage";
+import { GapsPage } from "./pages/GapsPage";
+import { JobsPage } from "./pages/JobsPage";
+import { MissionsPage } from "./pages/MissionsPage";
+import { PatrolPage } from "./pages/PatrolPage";
+import { PersonasPage } from "./pages/PersonasPage";
+import { PublishPage } from "./pages/PublishPage";
+import { SettingsPage } from "./pages/SettingsPage";
+import { BrandsPage } from "./pages/BrandsPage";
+import { clearOAuthPending, parseOAuthReturn, stashOAuthReturnMessage, stripOAuthReturnFromUrl } from "./lib/threadsOAuth";
+import { persistActiveThreadsAccountId } from "./lib/activeAccount";
+
+const validPages = new Set(["dashboard", "accounts", "brands", "publish", "personas", "missions", "patrol", "jobs", "settings", "gaps"]);
+const accountRequiredPages = new Set(["publish", "personas", "missions", "patrol"]);
+
+export function App() {
+ const { member, loading } = useAuth();
+ const [page, setPage] = useState(() => normalizePage(location.hash.replace("#", "")));
+ const [accountVersion, setAccountVersion] = useState(0);
+ const [accountCount, setAccountCount] = useState(null);
+
+ useEffect(() => {
+ const onHash = () => setPage(normalizePage(location.hash.replace("#", "")));
+ window.addEventListener("hashchange", onHash);
+ return () => window.removeEventListener("hashchange", onHash);
+ }, []);
+
+ useEffect(() => {
+ const onAccountChanged = () => setAccountVersion((value) => value + 1);
+ window.addEventListener("haixun:active-account-changed", onAccountChanged);
+ return () => window.removeEventListener("haixun:active-account-changed", onAccountChanged);
+ }, []);
+
+ useEffect(() => {
+ if (!member) return;
+ const result = parseOAuthReturn(window.location.search);
+ if (!result) return;
+
+ clearOAuthPending();
+ stashOAuthReturnMessage(result.message);
+ if (result.accountId) persistActiveThreadsAccountId(result.accountId);
+ stripOAuthReturnFromUrl();
+ window.location.hash = "accounts";
+ setPage("accounts");
+ setAccountVersion((value) => value + 1);
+ window.dispatchEvent(new CustomEvent("haixun:active-account-changed"));
+ }, [member]);
+
+ useEffect(() => {
+ if (!member) {
+ setAccountCount(null);
+ return;
+ }
+ let alive = true;
+ async function loadAccounts() {
+ try {
+ const data = await api.threadsAccounts();
+ if (alive) setAccountCount(data.list?.length || 0);
+ } catch {
+ if (alive) setAccountCount(0);
+ }
+ }
+ void loadAccounts();
+ return () => {
+ alive = false;
+ };
+ }, [member, accountVersion]);
+
+ function navigate(next: string) {
+ const normalized = normalizePage(next);
+ window.location.hash = normalized;
+ setPage(normalized);
+ }
+
+ if (loading) {
+ return ;
+ }
+
+ if (!member) {
+ return ;
+ }
+
+ return (
+
+ {renderPage(page, navigate, accountCount)}
+
+ );
+}
+
+function normalizePage(raw: string) {
+ return validPages.has(raw) ? raw : "dashboard";
+}
+
+function renderPage(page: string, navigate: (key: string) => void, accountCount: number | null) {
+ if (accountRequiredPages.has(page)) {
+ if (accountCount === null) {
+ return (
+
+ 正在確認 Threads 帳號狀態...
+
+ );
+ }
+ if (accountCount === 0) {
+ return ;
+ }
+ }
+ switch (page) {
+ case "accounts":
+ return ;
+ case "brands":
+ return ;
+ case "publish":
+ return ;
+ case "personas":
+ return ;
+ case "missions":
+ return ;
+ case "patrol":
+ return ;
+ case "jobs":
+ return ;
+ case "settings":
+ return ;
+ case "gaps":
+ return ;
+ default:
+ return ;
+ }
+}
+
+function AccountRequiredPage({ navigate }: { navigate: (key: string) => void }) {
+ return (
+
+
+
+
+ 請先到 Threads 帳號頁建立帳號,完成 OAuth 或 API 連線後,巡樓 Console 才能開始發文、分析與跑任務。
+
+
+
+
+ );
+}
diff --git a/backend/web/src/api/client.ts b/backend/web/src/api/client.ts
new file mode 100644
index 0000000..e11570d
--- /dev/null
+++ b/backend/web/src/api/client.ts
@@ -0,0 +1,196 @@
+import { safeGetItem, safeRemoveItem, safeSetItem } from "../lib/safeStorage";
+
+export type ApiEnvelope = {
+ code: number;
+ message: string;
+ data?: T;
+ error?: {
+ biz_code?: string;
+ scope?: number;
+ category?: number;
+ detail?: number;
+ };
+};
+
+export class ApiError extends Error {
+ status: number;
+ code?: number;
+ bizCode?: string;
+
+ constructor(message: string, status: number, code?: number, bizCode?: string) {
+ super(message);
+ this.name = "ApiError";
+ this.status = status;
+ this.code = code;
+ this.bizCode = bizCode;
+ }
+}
+
+const accessTokenKey = "haixun.access_token";
+const refreshTokenKey = "haixun.refresh_token";
+
+let onUnauthorized: (() => void) | undefined;
+let refreshPromise: Promise | undefined;
+
+export function setUnauthorizedHandler(handler: (() => void) | undefined) {
+ onUnauthorized = handler;
+}
+
+export function getAccessToken() {
+ return safeGetItem(accessTokenKey);
+}
+
+export function getRefreshToken() {
+ return safeGetItem(refreshTokenKey);
+}
+
+export function setTokens(accessToken: string, refreshToken: string) {
+ safeSetItem(accessTokenKey, accessToken);
+ safeSetItem(refreshTokenKey, refreshToken);
+}
+
+export function clearTokens() {
+ safeRemoveItem(accessTokenKey);
+ safeRemoveItem(refreshTokenKey);
+}
+
+type RequestOptions = {
+ method?: string;
+ body?: unknown;
+ auth?: boolean;
+ memberAuth?: boolean;
+ providerToken?: string;
+ headers?: Record;
+ retryOnUnauthorized?: boolean;
+};
+
+function isTransientGatewayError(status: number) {
+ return status === 502 || status === 503 || status === 504;
+}
+
+function gatewayRetryDelayMs(attempt: number) {
+ return 400 + attempt * 600;
+}
+
+async function sleep(ms: number) {
+ await new Promise((resolve) => window.setTimeout(resolve, ms));
+}
+
+async function readApiEnvelope(response: Response): Promise | null> {
+ return (await response.json().catch(() => null)) as ApiEnvelope | null;
+}
+
+function isProxyUnavailable(response: Response, envelope: ApiEnvelope | null) {
+ if (!isTransientGatewayError(response.status)) {
+ return false;
+ }
+ const contentType = response.headers.get("content-type") || "";
+ if (!contentType.includes("application/json")) {
+ return true;
+ }
+ return envelope == null;
+}
+
+export async function apiRequest(path: string, options: RequestOptions = {}): Promise {
+ const maxAttempts = 4;
+ let response: Response | null = null;
+ let envelope: ApiEnvelope | null = null;
+
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
+ response = await fetch(path, buildRequestInit(options));
+ envelope = await readApiEnvelope(response);
+ if (!isProxyUnavailable(response, envelope) || attempt === maxAttempts - 1) {
+ break;
+ }
+ await sleep(gatewayRetryDelayMs(attempt));
+ }
+
+ if (!response) {
+ throw new ApiError("後端服務暫時無法連線,請稍後再試", 502);
+ }
+
+ if (response.status === 401 && options.auth && options.retryOnUnauthorized !== false) {
+ const refreshed = await refreshAccessToken();
+ if (refreshed) {
+ return apiRequest(path, { ...options, retryOnUnauthorized: false });
+ }
+ onUnauthorized?.();
+ }
+
+ if (!response.ok || !envelope || envelope.code !== 102000) {
+ const message = envelope?.message?.trim();
+ const fallback = isProxyUnavailable(response, envelope)
+ ? "後端服務暫時無法連線,請確認 API gateway 與反向代理狀態。"
+ : `API request failed (${response.status})`;
+ const detail =
+ message ||
+ (response.status === 502
+ ? isProxyUnavailable(response, envelope)
+ ? "請求逾時或閘道無回應(HTTP 502)。請稍後重試;若持續失敗請確認 API gateway 與 worker 狀態。"
+ : "Threads 或外部 API 回傳失敗(HTTP 502)。請到帳號頁確認 Threads API 連線與 threads_keyword_search 權限。"
+ : fallback);
+ throw new ApiError(detail, response.status, envelope?.code, envelope?.error?.biz_code);
+ }
+ return envelope.data as T;
+}
+
+function buildRequestInit(options: RequestOptions): RequestInit {
+ const headers: Record = {
+ Accept: "application/json",
+ ...options.headers
+ };
+ if (options.body !== undefined) {
+ headers["Content-Type"] = "application/json";
+ }
+ if (options.auth) {
+ const token = getAccessToken();
+ if (token) {
+ headers.Authorization = `Bearer ${token}`;
+ }
+ }
+ if (options.memberAuth) {
+ const token = getAccessToken();
+ if (token) {
+ headers["X-Member-Authorization"] = `Bearer ${token}`;
+ }
+ }
+ if (options.providerToken) {
+ headers.Authorization = `Bearer ${options.providerToken}`;
+ }
+ return {
+ method: options.method || (options.body === undefined ? "GET" : "POST"),
+ headers,
+ body: options.body === undefined ? undefined : JSON.stringify(options.body)
+ };
+}
+
+async function refreshAccessToken(): Promise {
+ if (refreshPromise) {
+ return refreshPromise;
+ }
+
+ refreshPromise = doRefreshAccessToken().finally(() => {
+ refreshPromise = undefined;
+ });
+ return refreshPromise;
+}
+
+async function doRefreshAccessToken(): Promise {
+ const refreshToken = getRefreshToken();
+ if (!refreshToken) {
+ clearTokens();
+ return false;
+ }
+ try {
+ const data = await apiRequest<{ access_token: string; refresh_token: string }>("/api/v1/auth/refresh", {
+ method: "POST",
+ body: { refresh_token: refreshToken },
+ retryOnUnauthorized: false
+ });
+ setTokens(data.access_token, data.refresh_token);
+ return true;
+ } catch {
+ clearTokens();
+ return false;
+ }
+}
diff --git a/backend/web/src/api/haixun.ts b/backend/web/src/api/haixun.ts
new file mode 100644
index 0000000..59c0cae
--- /dev/null
+++ b/backend/web/src/api/haixun.ts
@@ -0,0 +1,1172 @@
+import { apiRequest } from "./client";
+
+export type Pagination = {
+ total: number;
+ page: number;
+ pageSize: number;
+ totalPages: number;
+};
+
+export type CapabilityData = {
+ discover_ready: boolean;
+ ai_ready: boolean;
+ publish_ready: boolean;
+ discover_hint?: string;
+ ai_hint?: string;
+ publish_hint?: string;
+ active_threads_account_id?: string;
+};
+
+export type ThreadsAccount = {
+ id: string;
+ display_name?: string;
+ username?: string;
+ threads_user_id?: string;
+ persona_id?: string;
+ browser_connected: boolean;
+ api_connected: boolean;
+ api_token_expires_at?: number;
+ status: string;
+};
+
+export type ThreadsAccountList = {
+ list: ThreadsAccount[];
+ active_account_id?: string;
+};
+
+export type DeleteThreadsAccountResult = {
+ deleted_id: string;
+ active_account_id?: string;
+ message: string;
+};
+
+export type ThreadsConnection = {
+ account_id: string;
+ account_name: string;
+ username?: string;
+ browser_connected: boolean;
+ api_connected: boolean;
+ prefs: Record;
+};
+
+export type ThreadsAiSettings = {
+ account_id: string;
+ provider: string;
+ model: string;
+ research_provider?: string;
+ research_model?: string;
+ api_keys?: Record;
+ api_keys_configured?: Record;
+};
+
+export type ThreadsAccountAiProviderModels = {
+ id: string;
+ label: string;
+ models: string[];
+ streams: boolean;
+ error?: string;
+};
+
+export type PublishQueueItem = {
+ id: string;
+ account_id: string;
+ persona_id?: string;
+ copy_mission_id?: string;
+ copy_draft_id?: string;
+ text: string;
+ topic_tag?: string;
+ scheduled_at: number;
+ status: string;
+ media_id?: string;
+ permalink?: string;
+ published_at?: number;
+ error_message?: string;
+ retry_count?: number;
+ last_attempt_at?: number;
+ next_attempt_at?: number;
+ missed_at?: number;
+ paused_reason?: string;
+ create_at: number;
+ update_at: number;
+};
+
+export type PublishQueueList = {
+ pagination: Pagination;
+ list: PublishQueueItem[];
+};
+
+export type PublishHealth = {
+ summary: {
+ pending_scheduled: number;
+ failed_count: number;
+ published_7d: number;
+ total_likes_7d: number;
+ total_replies_7d: number;
+ };
+ pagination: Pagination;
+ list: Array<{
+ media_id: string;
+ permalink?: string;
+ text?: string;
+ published_at: number;
+ like_count: number;
+ reply_count: number;
+ repost_count?: number;
+ quote_count?: number;
+ views?: number;
+ shares?: number;
+ checkpoints?: Array<{ checkpoint: string; like_count: number; reply_count: number; checked_at: number }>;
+ }>;
+};
+
+export type OwnPostPerformanceItem = {
+ media_id: string;
+ text?: string;
+ permalink?: string;
+ timestamp?: string;
+ media_type?: string;
+ media_url?: string;
+ thumbnail_url?: string;
+ topic_tag?: string;
+ shortcode?: string;
+ like_count: number;
+ reply_count: number;
+ repost_count?: number;
+ quote_count?: number;
+ views?: number;
+ shares?: number;
+ insights_status?: string;
+ insights_message?: string;
+};
+
+export type PostPerformance = {
+ account_id: string;
+ username?: string;
+ threads_user_id?: string;
+ fetched_at: number;
+ account_insights?: {
+ status?: string;
+ message?: string;
+ metrics?: Array<{ name: string; value: number }>;
+ };
+ posts: OwnPostPerformanceItem[];
+};
+
+export type OwnPostFormulaReview = {
+ media_id?: string;
+ reviewed_at?: number;
+ from_cache?: boolean;
+ summary: string;
+ wins?: string[];
+ improvements?: string[];
+ formula?: string;
+ post_template?: string;
+ hook_pattern?: string;
+ structure?: string;
+ replication_tips?: string[];
+ avoid?: string[];
+};
+
+export type Persona = {
+ id: string;
+ display_name?: string;
+ persona?: string;
+ brief?: string;
+ style_profile?: string;
+ style_benchmark?: string;
+ create_at: number;
+ update_at: number;
+};
+
+export type CopyDraft = {
+ id: string;
+ persona_id: string;
+ content_plan_id?: string;
+ copy_mission_id?: string;
+ scan_post_id?: string;
+ formula_id?: string;
+ draft_type: string;
+ text: string;
+ topic_tag?: string;
+ angle?: string;
+ hook?: string;
+ rationale?: string;
+ ai_score?: number;
+ formula_score?: number;
+ brand_fit_score?: number;
+ risk_score?: number;
+ similarity_score?: number;
+ engagement_potential?: number;
+ freshness_score?: number;
+ review_suggestion?: string;
+ status?: string;
+ publish_queue_id?: string;
+ published_at?: number;
+ published_permalink?: string;
+ create_at?: number;
+};
+
+export type CopyMissionInspiration = {
+ topic_candidate_id?: string;
+ content_plan_id?: string;
+ label: string;
+ seed_query: string;
+ brief: string;
+ trend_reason?: string;
+ trend_keywords?: string[];
+ angles?: string[];
+ mission?: string;
+ target_audience?: string;
+ opening_type?: string;
+ body_type?: string;
+ emotion?: string;
+ cta_type?: string;
+ risk_level?: string;
+ avoid?: string[];
+ sources?: Array<{ query?: string; title?: string; snippet?: string; url?: string }>;
+ web_search_used: boolean;
+ message: string;
+};
+
+export type TopicCandidate = {
+ id: string;
+ persona_id: string;
+ name: string;
+ source?: string;
+ category?: string;
+ seed_query?: string;
+ trend_reason?: string;
+ trend_keywords?: string[];
+ heat_score?: number;
+ fit_score?: number;
+ interaction_score?: number;
+ extend_score?: number;
+ risk_score?: number;
+ final_score?: number;
+ recommended_missions?: string[];
+ status?: string;
+ create_at: number;
+ update_at: number;
+};
+
+export type ContentPlan = {
+ id: string;
+ persona_id: string;
+ topic_candidate_id?: string;
+ topic: string;
+ mission: string;
+ target_audience?: string;
+ angle?: string;
+ opening_type?: string;
+ body_type?: string;
+ emotion?: string;
+ ending_type?: string;
+ cta_type?: string;
+ risk_level?: string;
+ requires_human_review?: boolean;
+ avoid?: string[];
+ selected_knowledge?: string[];
+ status?: string;
+ create_at: number;
+ update_at: number;
+};
+
+export type FeedbackEvent = {
+ id: string;
+ persona_id: string;
+ content_plan_id?: string;
+ draft_id?: string;
+ decision: string;
+ note?: string;
+ snapshot?: string;
+ create_at: number;
+};
+
+export type KnowledgeSource = { id: string; persona_id: string; source_type: string; title?: string; filename?: string; parsed_status?: string; chunk_count?: number; create_at: number };
+export type KnowledgeChunk = { id: string; persona_id: string; source_id: string; content: string; topics?: string[]; style_tags?: string[]; risk_level?: string; create_at: number };
+export type FormulaPool = { id: string; persona_id: string; type: string; name: string; pattern?: string; use_cases?: string[]; avoid?: string[]; weight?: number; create_at: number; update_at: number };
+
+export type ContentInboxItem = CopyDraft & {
+ scheduled_at?: number;
+ lifecycle: "draft" | "scheduled" | "published" | string;
+ group_date: number;
+ formula_label?: string;
+};
+
+export type ContentFormula = {
+ id: string;
+ account_id: string;
+ label: string;
+ source_type: string;
+ source_ref?: string;
+ source_post_text?: string;
+ source_author?: string;
+ source_permalink?: string;
+ summary?: string;
+ wins?: string[];
+ improvements?: string[];
+ formula: string;
+ post_template?: string;
+ hook_pattern?: string;
+ structure?: string;
+ replication_tips?: string[];
+ avoid?: string[];
+ tags?: string[];
+ create_at: number;
+ update_at: number;
+};
+
+export type ViralScanPost = {
+ id: string;
+ search_tag: string;
+ permalink: string;
+ author: string;
+ text: string;
+ like_count: number;
+ reply_count: number;
+ engagement_score: number;
+ source: string;
+ create_at: number;
+};
+
+export type ContentFormulaSearchPost = {
+ text: string;
+ author: string;
+ permalink?: string;
+ media_id?: string;
+ like_count: number;
+ reply_count: number;
+ engagement_score: number;
+};
+
+export type PublishSlot = {
+ weekday: number;
+ time: string;
+};
+
+export type PublishSourceRef = {
+ type: string;
+ persona_id?: string;
+ copy_mission_id?: string;
+ scan_post_id?: string;
+ manual_seed?: string;
+};
+
+export type PublishInventoryPolicy = {
+ account_id: string;
+ enabled: boolean;
+ target_daily_count: number;
+ low_stock_threshold: number;
+ lookahead_days: number;
+ timezone: string;
+ slots: PublishSlot[];
+ source_refs: PublishSourceRef[];
+ update_at: number;
+};
+
+export type PublishGuardPolicy = {
+ account_id: string;
+ enabled: boolean;
+ max_daily_posts: number;
+ min_interval_minutes: number;
+ consecutive_failure_pause_limit: number;
+ paused: boolean;
+ paused_reason?: string;
+ update_at: number;
+};
+
+export type PublishSlotInsights = {
+ account_id: string;
+ timezone: string;
+ slots: Array;
+};
+
+export type PublishQueueEvent = {
+ id: string;
+ queue_id: string;
+ event_type: string;
+ from_status?: string;
+ to_status?: string;
+ message?: string;
+ create_at: number;
+};
+
+export type PublishAlert = {
+ type: string;
+ severity: string;
+ message: string;
+ queue_id?: string;
+ account_id: string;
+ create_at: number;
+};
+
+export type PublishDashboardSummary = {
+ list: Array<{
+ account_id: string;
+ account_name: string;
+ pending_scheduled: number;
+ failed_count: number;
+ published_7d: number;
+ total_likes_7d: number;
+ total_replies_7d: number;
+ paused: boolean;
+ best_slot?: string;
+ low_slot?: string;
+ }>;
+ total_pending: number;
+ total_failed: number;
+ total_published_7d: number;
+ total_likes_7d: number;
+ total_replies_7d: number;
+};
+
+export type ThreadsDiagnostics = {
+ account_id: string;
+ checked_at: number;
+ api_connected: boolean;
+ token_expires_at?: number;
+ scopes: string[];
+ items: Array<{ scope: string; name: string; status: string; message: string; count: number }>;
+ message: string;
+};
+
+export type ThreadsPlaygroundResult = {
+ action: string;
+ ok: boolean;
+ message: string;
+ data: string;
+};
+
+export type MentionInboxItem = {
+ media_id: string;
+ author_username?: string;
+ text?: string;
+ permalink?: string;
+ shortcode?: string;
+ timestamp?: string;
+ media_type?: string;
+ is_reply: boolean;
+ is_quote_post: boolean;
+ has_replies: boolean;
+ root_post_id?: string;
+ parent_id?: string;
+ thread_post_text?: string;
+ parent_reply_text?: string;
+ reply_ready: boolean;
+ reply_ready_msg?: string;
+ synced_at: number;
+};
+
+export type SyncMentionInboxResult = {
+ synced: number;
+ ready: number;
+ total: number;
+};
+
+export type MentionInboxList = {
+ list: MentionInboxItem[];
+ pagination: Pagination;
+ ready_total?: number;
+ newest_synced_at?: number;
+};
+
+export type ThreadsReply = {
+ id: string;
+ text?: string;
+ username?: string;
+ permalink?: string;
+ timestamp?: string;
+ like_count?: number;
+ parent_id?: string;
+ replied_to_id?: string;
+ root_post_id?: string;
+ is_reply?: boolean;
+ is_reply_owned_by_me?: boolean;
+ has_replies?: boolean;
+ media_type?: string;
+ media_url?: string;
+ thumbnail_url?: string;
+ gif_url?: string;
+ media_children?: Array<{
+ id?: string;
+ media_type?: string;
+ media_url?: string;
+ thumbnail_url?: string;
+ }>;
+};
+
+export type ThreadsInspirationPost = {
+ id: string;
+ text?: string;
+ username?: string;
+ permalink?: string;
+ timestamp?: string;
+ like_count?: number;
+ reply_count?: number;
+};
+
+export type StylePreset = {
+ id: string;
+ persona_id: string;
+ name: string;
+ tone?: string;
+ cta?: string[];
+ banned_words?: string[];
+ notes?: string;
+ create_at: number;
+ update_at: number;
+};
+
+// ==================== Brand (full contract per backend) ====================
+export type ResearchItemData = {
+ title?: string;
+ url?: string;
+ snippet?: string;
+ query?: string;
+};
+
+export type ResearchMapData = {
+ audience_summary?: string;
+ content_goal?: string;
+ questions?: string[];
+ pillars?: string[];
+ exclusions?: string[];
+ research_items?: ResearchItemData[];
+ expand_strategy?: string;
+ patrol_keywords?: string[];
+};
+
+export type BrandProductData = {
+ id: string;
+ label: string;
+ product_context: string;
+ match_tags?: string[];
+ placement_url?: string; // 置入網址
+ create_at: number;
+ update_at: number;
+};
+
+export type BrandData = {
+ id: string;
+ display_name?: string;
+ topic_name?: string;
+ seed_query?: string;
+ brief?: string;
+ product_brief?: string;
+ product_context?: string;
+ product_id?: string;
+ products?: BrandProductData[];
+ target_audience?: string;
+ goals?: string;
+ research_map?: ResearchMapData;
+ create_at: number;
+ update_at: number;
+};
+
+export type ListBrandsData = { list: BrandData[] };
+
+export type PlacementTopicData = {
+ id: string;
+ brand_id: string;
+ brand_display_name?: string;
+ topic_name?: string;
+ seed_query?: string;
+ brief?: string;
+ product_id?: string;
+ research_map?: ResearchMapData;
+ create_at: number;
+ update_at: number;
+};
+
+export type ListPlacementTopicsData = { list: PlacementTopicData[] };
+
+export type KnowledgeGraphEvidenceData = {
+ url?: string;
+ snippet?: string;
+ query?: string;
+};
+
+export type BraveSourceData = {
+ query?: string;
+ title?: string;
+ url?: string;
+ snippet?: string;
+};
+
+export type KnowledgeGraphNodeData = {
+ id: string;
+ label: string;
+ node_kind: string;
+ type: string;
+ layer: number;
+ relation?: string;
+ placement_value?: string;
+ product_fit_score: number;
+ selected_for_scan: boolean;
+ relevance_tags: string[];
+ recency_tags: string[];
+ evidence?: KnowledgeGraphEvidenceData[];
+};
+
+export type KnowledgeGraphEdgeData = {
+ from: string;
+ to: string;
+ relation: string;
+};
+
+export type KnowledgeGraphData = {
+ id: string;
+ brand_id: string;
+ seed: string;
+ nodes: KnowledgeGraphNodeData[];
+ edges: KnowledgeGraphEdgeData[];
+ brave_sources?: BraveSourceData[];
+ expand_strategy?: string;
+ pain_tag_count: number;
+ generated_at: number;
+ create_at: number;
+ update_at: number;
+};
+
+export type ExpandKnowledgeGraphData = {
+ job_id: string;
+ status: string;
+ message: string;
+};
+
+export type ScanReplyData = {
+ external_id?: string;
+ author?: string;
+ text: string;
+ permalink?: string;
+ like_count?: number;
+ posted_at?: string;
+};
+
+export type GenerateOutreachDraftsData = {
+ id: string;
+ scan_post_id: string;
+ relevance: number;
+ reason: string;
+ drafts: Array<{ text: string; angle: string; rationale: string }>;
+ create_at: number;
+};
+
+export type PublishOutreachDraftData = {
+ scan_post_id: string;
+ reply_id?: string;
+ permalink?: string;
+ outreach_status: string;
+ published_permalink?: string;
+ message: string;
+};
+
+export type ScanPostData = {
+ id: string;
+ graph_node_id: string;
+ search_tag: string;
+ query_dimension: string;
+ recency_days?: number;
+ external_id: string;
+ permalink?: string;
+ author: string;
+ text: string;
+ priority: string;
+ placement_score: number;
+ product_fit_score: number;
+ solved_by_product: boolean;
+ source: string;
+ scan_job_id: string;
+ outreach_status?: string;
+ published_reply_id?: string;
+ published_permalink?: string;
+ outreach_update_at?: number;
+ posted_at?: string;
+ replies?: ScanReplyData[];
+ latest_draft?: GenerateOutreachDraftsData;
+ create_at: number;
+};
+
+export type ListBrandScanPostsData = {
+ list: ScanPostData[];
+ total: number;
+};
+
+export type ContentMatrixRowData = {
+ sort_order: number;
+ search_tag: string;
+ angle: string;
+ hook: string;
+ text: string;
+ reference_notes: string;
+ source_permalinks: string[];
+ rationale: string;
+};
+
+export type ContentMatrixData = {
+ id?: string;
+ brand_id: string;
+ rows: ContentMatrixRowData[];
+ generated_at: number;
+ create_at?: number;
+ update_at?: number;
+};
+
+export type BrandScanScheduleData = {
+ id?: string;
+ brand_id: string;
+ cron: string;
+ timezone: string;
+ enabled: boolean;
+ next_run_at?: number;
+ last_run_at?: number;
+};
+
+// Legacy aliases for existing PatrolPage usage (keep minimal surface)
+export type Brand = {
+ id: string;
+ display_name?: string;
+ topic_name?: string;
+ seed_query?: string;
+ brief?: string;
+ product_context?: string;
+ target_audience?: string;
+ goals?: string;
+};
+
+export type ScanPost = {
+ id: string;
+ author: string;
+ text: string;
+ permalink?: string;
+ priority?: string;
+ placement_score?: number;
+ product_fit_score?: number;
+ outreach_status?: string;
+};
+
+export type JobStepProgress = {
+ id: string;
+ status: string;
+ message?: string;
+ started_at?: number;
+ ended_at?: number;
+};
+
+export type JobRun = {
+ id: string;
+ template_type: string;
+ status: string;
+ phase?: string;
+ worker_type?: string;
+ progress?: { percentage?: number; summary?: string; steps?: JobStepProgress[] };
+ error?: string;
+ attempt?: number;
+ max_attempts?: number;
+ create_at: number;
+ update_at: number;
+};
+
+export type JobEvent = {
+ id: string;
+ job_id: string;
+ type: string;
+ from?: string;
+ to?: string;
+ message: string;
+ metadata?: Record;
+ create_at: number;
+};
+
+export type JobList = {
+ pagination: Pagination;
+ list: JobRun[];
+};
+
+export type JobSchedule = {
+ id: string;
+ template_type: string;
+ cron: string;
+ timezone: string;
+ enabled: boolean;
+ next_run_at?: number;
+ last_run_at?: number;
+};
+
+export type SettingValue = {
+ scope: string;
+ scope_id: string;
+ key: string;
+ value: unknown;
+ version?: number;
+};
+
+export const api = {
+ capabilities: () => apiRequest("/api/v1/members/me/capabilities", { auth: true }),
+ memberMe: () => apiRequest("/api/v1/members/me", { auth: true }),
+ placementSettings: () => apiRequest("/api/v1/members/me/placement-settings", { auth: true }),
+ updatePlacementSettings: (body: Record) =>
+ apiRequest("/api/v1/members/me/placement-settings", { method: "PATCH", body, auth: true }),
+
+ threadsAccounts: () => apiRequest("/api/v1/threads-accounts/", { auth: true }),
+ createThreadsAccount: (body: { display_name?: string; activate?: boolean }) =>
+ apiRequest("/api/v1/threads-accounts/", { method: "POST", body, auth: true }),
+ deleteThreadsAccount: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}`, { method: "DELETE", auth: true }),
+ activateThreadsAccount: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/activate`, { method: "POST", auth: true }),
+ getThreadsConnection: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/connection`, { auth: true }),
+ updateThreadsConnection: (id: string, body: Record) =>
+ apiRequest(`/api/v1/threads-accounts/${id}/connection`, { method: "PATCH", body, auth: true }),
+ importThreadsAccountSession: (id: string, storageState: string) =>
+ apiRequest<{ success: boolean; valid: boolean; synced: boolean; message: string; account_id: string; username?: string }>(
+ `/api/v1/threads-accounts/${id}/session/import`,
+ { method: "POST", body: { storageState }, auth: true }
+ ),
+ threadsAiSettings: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/ai-settings`, { auth: true }),
+ updateThreadsAiSettings: (id: string, body: Record) =>
+ apiRequest(`/api/v1/threads-accounts/${id}/ai-settings`, { method: "PUT", body, auth: true }),
+ threadsAccountProviderModels: (id: string, provider: string, apiKey?: string) =>
+ apiRequest(`/api/v1/threads-accounts/${id}/ai-settings/providers/${provider}/models`, {
+ method: "POST",
+ body: apiKey ? { api_key: apiKey } : {},
+ auth: true
+ }),
+ oauthConfig: () => apiRequest<{ enabled: boolean; redirect_uri: string; success_redirect: string; requires_https: boolean }>("/api/v1/threads-accounts/oauth/config", { auth: true }),
+ oauthLogs: () => apiRequest<{ list: Array<{ at: number; level: string; stage: string; message: string }> }>("/api/v1/threads-accounts/oauth/logs", { auth: true }),
+ startOauth: (accountId?: string) =>
+ apiRequest<{ authorize_url: string; account_id?: string; state?: string }>(
+ `/api/v1/threads-accounts/oauth/start${accountId ? `?account_id=${encodeURIComponent(accountId)}` : ""}`,
+ { auth: true }
+ ),
+ smokeTest: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/api/smoke-test`, { method: "POST", auth: true }),
+ threadsDiagnostics: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/diagnostics`, { auth: true }),
+ publishDashboardSummary: () => apiRequest("/api/v1/threads-accounts/publish-dashboard-summary", { auth: true }),
+ threadsPlayground: (id: string, body: Record) =>
+ apiRequest(`/api/v1/threads-accounts/${id}/api/playground`, { method: "POST", body, auth: true }),
+ syncMentionInbox: (accountId: string, limit = 25, maxPages = 4) =>
+ apiRequest(`/api/v1/threads-accounts/${accountId}/mention-inbox/sync`, {
+ method: "POST",
+ body: { limit, max_pages: maxPages },
+ auth: true
+ }),
+ listMentionInbox: (accountId: string, page = 1, pageSize = 8) =>
+ apiRequest(
+ `/api/v1/threads-accounts/${accountId}/mention-inbox?page=${page}&pageSize=${pageSize}`,
+ { auth: true }
+ ),
+ publishMentionReply: (accountId: string, mediaId: string, text: string) =>
+ apiRequest<{ media_id?: string; permalink?: string }>(
+ `/api/v1/threads-accounts/${accountId}/mention-inbox/${encodeURIComponent(mediaId)}/reply`,
+ { method: "POST", body: { text }, auth: true }
+ ),
+
+ publishQueue: (accountId: string, status = "", page = 1) =>
+ apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue?page=${page}&pageSize=20${status ? `&status=${status}` : ""}`, { auth: true }),
+ createPublishQueue: (accountId: string, body: { text: string; topic_tag?: string; scheduled_at?: number }) =>
+ apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue`, { method: "POST", body, auth: true }),
+ patchPublishQueue: (accountId: string, qid: string, body: { text?: string; topic_tag?: string; scheduled_at?: number }) =>
+ apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}`, { method: "PATCH", body, auth: true }),
+ cancelPublishQueue: (accountId: string, qid: string) =>
+ apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}/cancel`, { method: "POST", auth: true }),
+ deletePublishQueue: (accountId: string, qid: string) =>
+ apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}`, { method: "DELETE", auth: true }),
+ retryPublishQueue: (accountId: string, qid: string) =>
+ apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}/retry`, { method: "POST", auth: true }),
+ publishQueueEvents: (accountId: string, qid: string) =>
+ apiRequest<{ list: PublishQueueEvent[] }>(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}/events`, { auth: true }),
+ publishAlerts: (accountId: string) => apiRequest<{ list: PublishAlert[] }>(`/api/v1/threads-accounts/${accountId}/publish-alerts`, { auth: true }),
+ publishCalendar: (accountId: string, startAt: number, endAt: number, status = "") =>
+ apiRequest(
+ `/api/v1/threads-accounts/${accountId}/publish-calendar?startAt=${startAt}&endAt=${endAt}${status ? `&status=${status}` : ""}`,
+ { auth: true }
+ ),
+ publishInventoryPolicy: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-inventory-policy`, { auth: true }),
+ updatePublishInventoryPolicy: (accountId: string, body: Partial) =>
+ apiRequest(`/api/v1/threads-accounts/${accountId}/publish-inventory-policy`, { method: "PUT", body, auth: true }),
+ startPublishInventoryRefill: (accountId: string, count?: number) =>
+ apiRequest<{ job_id: string; status: string; message: string }>(`/api/v1/threads-accounts/${accountId}/publish-inventory/refill-jobs`, { method: "POST", body: { count }, auth: true }),
+ publishSlotInsights: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-slot-insights`, { auth: true }),
+ publishGuardPolicy: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-guard-policy`, { auth: true }),
+ updatePublishGuardPolicy: (accountId: string, body: Partial) =>
+ apiRequest(`/api/v1/threads-accounts/${accountId}/publish-guard-policy`, { method: "PUT", body, auth: true }),
+ resumePublishGuard: (accountId: string) =>
+ apiRequest(`/api/v1/threads-accounts/${accountId}/publish-guard/resume`, { method: "POST", auth: true }),
+ publishHealth: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-health?page=1&pageSize=10`, { auth: true }),
+ postPerformance: (accountId: string, limit = 30) =>
+ apiRequest(`/api/v1/threads-accounts/${accountId}/post-performance?limit=${limit}`, { auth: true }),
+ generateOwnPostReplyDraft: (
+ accountId: string,
+ body: {
+ media_id: string;
+ persona_id?: string;
+ reply_to_id?: string;
+ post_text?: string;
+ reply_text?: string;
+ thread_post_text?: string;
+ parent_reply_text?: string;
+ }
+ ) =>
+ apiRequest<{ text: string }>(`/api/v1/threads-accounts/${accountId}/own-post-reply-draft`, {
+ method: "POST",
+ body,
+ auth: true
+ }),
+ generateOwnPostFormula: (
+ accountId: string,
+ body: {
+ media_id: string;
+ persona_id?: string;
+ post_text?: string;
+ topic_tag?: string;
+ media_type?: string;
+ timestamp?: string;
+ like_count?: number;
+ reply_count?: number;
+ views?: number;
+ repost_count?: number;
+ quote_count?: number;
+ shares?: number;
+ force?: boolean;
+ }
+ ) =>
+ apiRequest(`/api/v1/threads-accounts/${accountId}/own-post-formula`, {
+ method: "POST",
+ body,
+ auth: true
+ }),
+ listOwnPostFormulas: (accountId: string) =>
+ apiRequest<{ list: OwnPostFormulaReview[] }>(`/api/v1/threads-accounts/${accountId}/own-post-formulas`, { auth: true }),
+
+ personas: () => apiRequest<{ list: Persona[] }>("/api/v1/personas/", { auth: true }),
+ createPersona: (body: { display_name?: string }) => apiRequest("/api/v1/personas/", { method: "POST", body, auth: true }),
+ updatePersona: (id: string, body: Record) => apiRequest(`/api/v1/personas/${id}`, { method: "PATCH", body, auth: true }),
+ deletePersona: (id: string) => apiRequest(`/api/v1/personas/${id}`, { method: "DELETE", auth: true }),
+ startStyleAnalysis: (id: string, benchmark_username: string) =>
+ apiRequest<{ job_id: string; status: string }>(`/api/v1/personas/${id}/style-analysis`, { method: "POST", body: { benchmark_username }, auth: true }),
+ startStyleAnalysisFromText: (
+ id: string,
+ body: { reference_texts?: string[]; raw_text?: string; source_label?: string }
+ ) =>
+ apiRequest<{ persona: Persona; post_count: number; message: string }>(
+ `/api/v1/personas/${id}/style-analysis-from-text`,
+ { method: "POST", body, auth: true }
+ ),
+ viralScanPosts: (personaId: string, limit = 30) =>
+ apiRequest<{ list: ViralScanPost[]; total: number }>(`/api/v1/personas/${personaId}/viral-scan-posts?limit=${limit}`, { auth: true }),
+ personaDrafts: (id: string) => apiRequest<{ list: CopyDraft[]; total: number }>(`/api/v1/personas/${id}/copy-drafts`, { auth: true }),
+ updateDraft: (personaId: string, draftId: string, body: Record) =>
+ apiRequest(`/api/v1/personas/${personaId}/copy-drafts/${draftId}`, { method: "PATCH", body, auth: true }),
+ pruneCopyDrafts: (personaId: string, body?: { keep?: number }) =>
+ apiRequest<{ deleted: number; kept: number; message: string }>(`/api/v1/personas/${personaId}/copy-drafts/prune`, { method: "POST", body: body ?? {}, auth: true }),
+ deleteDraft: (personaId: string, draftId: string) =>
+ apiRequest<{ draft_id: string; message: string }>(`/api/v1/personas/${personaId}/copy-drafts/${draftId}`, { method: "DELETE", auth: true }),
+ schedulePersonaDrafts: (personaId: string, body: { account_id: string; draft_ids: string[]; start_at?: number; timezone?: string; slots?: PublishSlot[]; mode?: string }) =>
+ apiRequest<{ scheduled: number; list: PublishQueueItem[]; message: string }>(`/api/v1/personas/${personaId}/copy-drafts/schedule`, { method: "POST", body, auth: true }),
+ contentInbox: (personaId: string, params?: { page?: number; pageSize?: number; status?: string; rangeStart?: number; rangeEnd?: number }) => {
+ const q = new URLSearchParams();
+ if (params?.page) q.set("page", String(params.page));
+ if (params?.pageSize) q.set("pageSize", String(params.pageSize));
+ if (params?.status) q.set("status", params.status);
+ if (params?.rangeStart) q.set("rangeStart", String(params.rangeStart));
+ if (params?.rangeEnd) q.set("rangeEnd", String(params.rangeEnd));
+ const suffix = q.toString() ? `?${q.toString()}` : "";
+ return apiRequest<{ pagination: Pagination; list: ContentInboxItem[] }>(`/api/v1/personas/${personaId}/content-inbox${suffix}`, { auth: true });
+ },
+ scheduleCopyDraft: (personaId: string, draftId: string, body: { account_id: string; topic_tag?: string; scheduled_at?: number }) =>
+ apiRequest<{ scheduled: number; list: PublishQueueItem[]; message: string }>(`/api/v1/personas/${personaId}/copy-drafts/${draftId}/schedule`, { method: "POST", body, auth: true }),
+ generateFromFormula: (personaId: string, formulaId: string, body: { account_id: string; topic: string; brief?: string; use_web_search?: boolean; draft_count?: number }) =>
+ apiRequest<{ list: CopyDraft[]; message: string }>(`/api/v1/personas/${personaId}/content-formulas/${formulaId}/generate`, { method: "POST", body, auth: true }),
+ contentFormulas: (accountId: string, params?: { page?: number; pageSize?: number; source_type?: string; tag?: string }) => {
+ const q = new URLSearchParams();
+ if (params?.page) q.set("page", String(params.page));
+ if (params?.pageSize) q.set("pageSize", String(params.pageSize));
+ if (params?.source_type) q.set("source_type", params.source_type);
+ if (params?.tag) q.set("tag", params.tag);
+ const suffix = q.toString() ? `?${q.toString()}` : "";
+ return apiRequest<{ pagination: Pagination; list: ContentFormula[] }>(`/api/v1/threads-accounts/${accountId}/content-formulas${suffix}`, { auth: true });
+ },
+ analyzeContentFormula: (accountId: string, body: Record) =>
+ apiRequest<{ formula: ContentFormula; message: string }>(`/api/v1/threads-accounts/${accountId}/content-formulas/analyze`, { method: "POST", body, auth: true }),
+ searchContentFormulaPosts: (accountId: string, body: { keyword: string; search_type?: string; limit?: number }) =>
+ apiRequest<{ list: ContentFormulaSearchPost[]; message: string }>(`/api/v1/threads-accounts/${accountId}/content-formulas/search-posts`, { method: "POST", body, auth: true }),
+ importOwnPostToFormula: (accountId: string, body: { media_id: string; label?: string }) =>
+ apiRequest<{ formula: ContentFormula; message: string }>(`/api/v1/threads-accounts/${accountId}/content-formulas/import-own-post`, { method: "POST", body, auth: true }),
+ deleteContentFormula: (accountId: string, formulaId: string) =>
+ apiRequest<{ formula_id: string; message: string }>(`/api/v1/threads-accounts/${accountId}/content-formulas/${formulaId}`, { method: "DELETE", auth: true }),
+ stylePresets: (personaId: string) => apiRequest<{ list: StylePreset[] }>(`/api/v1/personas/${personaId}/style-presets`, { auth: true }),
+ upsertStylePreset: (personaId: string, presetId: string, body: { name: string; tone?: string; cta?: string[]; banned_words?: string[]; notes?: string; apply?: boolean }) =>
+ apiRequest(`/api/v1/personas/${personaId}/style-presets/${presetId}`, { method: "PUT", body, auth: true }),
+ deleteStylePreset: (personaId: string, presetId: string) =>
+ apiRequest(`/api/v1/personas/${personaId}/style-presets/${presetId}`, { method: "DELETE", auth: true }),
+
+ generateTopicMatrix: (personaId: string, body: { topic: string; brief?: string; use_web_search?: boolean; draft_count?: number }) =>
+ apiRequest<{ list: CopyDraft[]; message: string }>(`/api/v1/personas/${personaId}/topic-matrix/generate`, { method: "POST", body, auth: true }),
+ startTopicMatrixJob: (personaId: string, body: { topic: string; content_plan_id?: string; brief?: string; use_web_search?: boolean; draft_count?: number }) =>
+ apiRequest<{ job_id: string; status: string; ai_provider: string; ai_model: string; message: string }>(`/api/v1/personas/${personaId}/topic-matrix/jobs`, { method: "POST", body, auth: true }),
+ startFormulaDraftJob: (personaId: string, body: { account_id: string; formula_id: string; topic: string; brief?: string; use_web_search?: boolean; draft_count?: number }) =>
+ apiRequest<{ job_id: string; status: string; ai_provider: string; ai_model: string; message: string }>(`/api/v1/personas/${personaId}/formula-draft/jobs`, { method: "POST", body, auth: true }),
+ startRewriteDraftJob: (personaId: string, body: { account_id: string; reference_text: string; topic: string; brief?: string; save_label?: string; use_web_search?: boolean; draft_count?: number }) =>
+ apiRequest<{ job_id: string; status: string; ai_provider: string; ai_model: string; message: string }>(`/api/v1/personas/${personaId}/rewrite-draft/jobs`, { method: "POST", body, auth: true }),
+ inspireCopyMission: (personaId: string, body: { keyword?: string; content_direction?: string; use_web_search?: boolean; avoid_topics?: string[] }) =>
+ apiRequest(`/api/v1/personas/${personaId}/copy-mission-inspiration`, { method: "POST", body, auth: true }),
+ topicCandidates: (personaId: string) =>
+ apiRequest<{ list: TopicCandidate[] }>(`/api/v1/personas/${personaId}/topic-candidates`, { auth: true }),
+ contentPlans: (personaId: string) =>
+ apiRequest<{ list: ContentPlan[] }>(`/api/v1/personas/${personaId}/content-plans`, { auth: true }),
+ updateContentPlan: (personaId: string, contentPlanId: string, body: Record) =>
+ apiRequest(`/api/v1/personas/${personaId}/content-plans/${contentPlanId}`, { method: "PATCH", body, auth: true }),
+ createFeedbackEvent: (personaId: string, body: { content_plan_id?: string; draft_id?: string; decision: string; note?: string; snapshot?: string }) =>
+ apiRequest(`/api/v1/personas/${personaId}/feedback-events`, { method: "POST", body, auth: true }),
+ createKnowledgeSource: (personaId: string, body: { source_type: string; title?: string; filename?: string; content?: string; content_base64?: string }) =>
+ apiRequest(`/api/v1/personas/${personaId}/knowledge-sources`, { method: "POST", body, auth: true }),
+ knowledgeSources: (personaId: string) =>
+ apiRequest<{ list: KnowledgeSource[] }>(`/api/v1/personas/${personaId}/knowledge-sources`, { auth: true }),
+ knowledgeChunks: (personaId: string) =>
+ apiRequest<{ list: KnowledgeChunk[] }>(`/api/v1/personas/${personaId}/knowledge-chunks`, { auth: true }),
+ formulaPools: (personaId: string) =>
+ apiRequest<{ list: FormulaPool[] }>(`/api/v1/personas/${personaId}/formula-pools`, { auth: true }),
+ createFormulaPool: (personaId: string, body: { type: string; name: string; pattern?: string; use_cases?: string[]; avoid?: string[]; weight?: number }) =>
+ apiRequest(`/api/v1/personas/${personaId}/formula-pools`, { method: "POST", body, auth: true }),
+
+ // Brand core
+ brands: () => apiRequest("/api/v1/brands/", { auth: true }),
+ createBrand: (body: { display_name?: string }) => apiRequest("/api/v1/brands/", { method: "POST", body, auth: true }),
+ getBrand: (id: string) => apiRequest(`/api/v1/brands/${id}`, { auth: true }),
+ updateBrand: (id: string, body: Record) => apiRequest(`/api/v1/brands/${id}`, { method: "PATCH", body, auth: true }),
+ deleteBrand: (id: string) => apiRequest(`/api/v1/brands/${id}`, { method: "DELETE", auth: true }),
+
+ // Brand Products
+ listBrandProducts: (id: string) => apiRequest<{ list: BrandProductData[] }>(`/api/v1/brands/${id}/products`, { auth: true }),
+ createBrandProduct: (id: string, body: { label: string; product_context: string; match_tags?: string[]; placement_url?: string }) =>
+ apiRequest(`/api/v1/brands/${id}/products`, { method: "POST", body, auth: true }),
+ updateBrandProduct: (id: string, productId: string, body: Record) =>
+ apiRequest(`/api/v1/brands/${id}/products/${productId}`, { method: "PATCH", body, auth: true }),
+ deleteBrandProduct: (id: string, productId: string) =>
+ apiRequest(`/api/v1/brands/${id}/products/${productId}`, { method: "DELETE", auth: true }),
+
+ // Knowledge Graph
+ expandKnowledgeGraph: (id: string, body: { seed_query: string; supplemental?: boolean; regenerate_map?: boolean; expand_strategy?: string }) =>
+ apiRequest(`/api/v1/brands/${id}/knowledge-graph/expand`, { method: "POST", body, auth: true }),
+ getKnowledgeGraph: (id: string) => apiRequest(`/api/v1/brands/${id}/knowledge-graph`, { auth: true }),
+ patchKnowledgeGraphNodes: (id: string, body: { updates: Array<{ node_id: string; selected_for_scan?: boolean; relevance_tags?: string[]; recency_tags?: string[] }> }) =>
+ apiRequest(`/api/v1/brands/${id}/knowledge-graph/nodes`, { method: "PATCH", body, auth: true }),
+
+ // Scan jobs & posts
+ startBrandScan: (id: string, body: Record) =>
+ apiRequest<{ job_id: string; status: string; message?: string }>(`/api/v1/brands/${id}/scan-jobs`, { method: "POST", body, auth: true }),
+ listBrandScanPosts: (id: string, params?: { priority?: string; recent_7d?: boolean; product_fit_min?: number; limit?: number }) => {
+ const qs = new URLSearchParams();
+ if (params?.priority) qs.set("priority", params.priority);
+ if (params?.recent_7d) qs.set("recent_7d", "true");
+ if (params?.product_fit_min != null) qs.set("product_fit_min", String(params.product_fit_min));
+ if (params?.limit) qs.set("limit", String(params.limit));
+ const q = qs.toString() ? `?${qs.toString()}` : "";
+ return apiRequest(`/api/v1/brands/${id}/scan-posts${q}`, { auth: true });
+ },
+
+ // Outreach
+ generateOutreachDrafts: (id: string, body: { scan_post_id: string; topic_id?: string; count?: number; voice_persona_id?: string; product_id?: string }) =>
+ apiRequest(`/api/v1/brands/${id}/outreach-drafts/generate`, { method: "POST", body, auth: true }),
+ // keep old simple name for PatrolPage (returns any for now)
+ generateOutreachDraftsLegacy: (id: string, body: Record) =>
+ apiRequest>(`/api/v1/brands/${id}/outreach-drafts/generate`, { method: "POST", body, auth: true }),
+ publishOutreachDraft: (id: string, body: { scan_post_id: string; text: string; confirm: boolean }) =>
+ apiRequest(`/api/v1/brands/${id}/outreach-drafts/publish`, { method: "POST", body, auth: true }),
+ patchScanPostOutreach: (id: string, postId: string, body: { outreach_status?: string }) =>
+ apiRequest(`/api/v1/brands/${id}/scan-posts/${postId}`, { method: "PATCH", body, auth: true }),
+
+ // Content Matrix
+ getBrandContentMatrix: (id: string) => apiRequest(`/api/v1/brands/${id}/content-matrix`, { auth: true }),
+ generateBrandContentMatrix: (id: string, body?: { count?: number }) =>
+ apiRequest(`/api/v1/brands/${id}/content-matrix/generate`, { method: "POST", body: body || {}, auth: true }),
+
+ // Scan Schedule
+ getBrandScanSchedule: (id: string) => apiRequest(`/api/v1/brands/${id}/scan-schedule`, { auth: true }),
+ upsertBrandScanSchedule: (id: string, body: { cron?: string; timezone?: string; enabled: boolean }) =>
+ apiRequest(`/api/v1/brands/${id}/scan-schedule`, { method: "PUT", body, auth: true }),
+
+ // Legacy simple (PatrolPage still uses these names)
+ brandScanPosts: (id: string) => apiRequest<{ list: ScanPost[]; total: number }>(`/api/v1/brands/${id}/scan-posts?limit=20`, { auth: true }),
+
+ // Placement Topics (海巡主題) - 主要用於海巡頁:新增主題、爬文、知識圖譜
+ placementTopics: () => apiRequest("/api/v1/placement/topics", { auth: true }),
+ createPlacementTopic: (body: { brand_id: string; topic_name: string; seed_query: string; brief: string; product_id?: string }) =>
+ apiRequest("/api/v1/placement/topics", { method: "POST", body, auth: true }),
+ getPlacementTopic: (id: string) => apiRequest(`/api/v1/placement/topics/${id}`, { auth: true }),
+ updatePlacementTopic: (id: string, body: Record) =>
+ apiRequest(`/api/v1/placement/topics/${id}`, { method: "PATCH", body, auth: true }),
+ deletePlacementTopic: (id: string) => apiRequest(`/api/v1/placement/topics/${id}`, { method: "DELETE", auth: true }),
+
+ // Placement Topic Knowledge Graph & Scan & Outreach
+ expandPlacementTopicGraph: (id: string, body: { seed_query: string; supplemental?: boolean; regenerate_map?: boolean; expand_strategy?: string }) =>
+ apiRequest(`/api/v1/placement/topics/${id}/knowledge-graph/expand`, { method: "POST", body, auth: true }),
+ getPlacementTopicGraph: (id: string) => apiRequest(`/api/v1/placement/topics/${id}/knowledge-graph`, { auth: true }),
+ patchPlacementTopicGraphNodes: (id: string, body: { updates: Array<{ node_id: string; selected_for_scan?: boolean; relevance_tags?: string[]; recency_tags?: string[] }> }) =>
+ apiRequest(`/api/v1/placement/topics/${id}/knowledge-graph/nodes`, { method: "PATCH", body, auth: true }),
+
+ startPlacementTopicScan: (
+ id: string,
+ body: {
+ graph_id?: string;
+ node_ids?: string[];
+ dual_track?: boolean;
+ patrol_mode?: boolean;
+ test_patrol?: boolean;
+ patrol_keywords?: string[];
+ }
+ ) =>
+ apiRequest<{ job_id: string; status: string; message?: string }>(`/api/v1/placement/topics/${id}/scan-jobs`, { method: "POST", body, auth: true }),
+ listPlacementTopicScanPosts: (id: string, params?: { priority?: string; recent_7d?: boolean; product_fit_min?: number; limit?: number }) => {
+ const qs = new URLSearchParams();
+ if (params?.priority) qs.set("priority", params.priority);
+ if (params?.recent_7d) qs.set("recent_7d", "true");
+ if (params?.product_fit_min != null) qs.set("product_fit_min", String(params.product_fit_min));
+ if (params?.limit) qs.set("limit", String(params.limit));
+ const q = qs.toString() ? `?${qs.toString()}` : "";
+ return apiRequest(`/api/v1/placement/topics/${id}/scan-posts${q}`, { auth: true });
+ },
+
+ generatePlacementTopicOutreachDrafts: (id: string, body: { scan_post_id: string; count?: number; voice_persona_id?: string; product_id?: string }) =>
+ apiRequest(`/api/v1/placement/topics/${id}/outreach-drafts/generate`, { method: "POST", body, auth: true }),
+ startPlacementTopicOutreachDraftJobs: (
+ id: string,
+ body: {
+ scan_post_id?: string;
+ scan_post_ids?: string[];
+ count?: number;
+ voice_persona_id: string;
+ product_id?: string;
+ regenerate?: boolean;
+ },
+ ) =>
+ apiRequest<{ jobs: Array<{ job_id: string; status: string; message: string }>; message: string }>(
+ `/api/v1/placement/topics/${id}/outreach-draft-jobs`,
+ { method: "POST", body, auth: true },
+ ),
+ publishPlacementTopicOutreachDraft: (id: string, body: { scan_post_id: string; text: string; confirm: boolean }) =>
+ apiRequest(`/api/v1/placement/topics/${id}/outreach-drafts/publish`, { method: "POST", body, auth: true }),
+ updatePlacementTopicOutreachDraft: (topicId: string, draftId: string, body: { draft_index: number; text: string }) =>
+ apiRequest(`/api/v1/placement/topics/${topicId}/outreach-drafts/${draftId}`, { method: "PATCH", body, auth: true }),
+ deletePlacementTopicScanPost: (topicId: string, postId: string) =>
+ apiRequest(`/api/v1/placement/topics/${topicId}/scan-posts/${postId}`, { method: "DELETE", auth: true }),
+ batchDeletePlacementTopicScanPosts: (topicId: string, postIds: string[]) =>
+ apiRequest<{ deleted_count: number }>(`/api/v1/placement/topics/${topicId}/scan-posts/batch-delete`, { method: "POST", body: { post_ids: postIds }, auth: true }),
+
+ jobs: (page = 1, pageSize = 30) => apiRequest(`/api/v1/jobs?page=${page}&pageSize=${pageSize}`, { auth: true }),
+ getJob: (id: string) => apiRequest(`/api/v1/jobs/${id}`, { auth: true }),
+ jobEvents: (jobId: string, limit = 40) =>
+ apiRequest<{ list: JobEvent[] }>(`/api/v1/jobs/${jobId}/events?limit=${limit}`, { auth: true }),
+ cancelJob: (id: string) => apiRequest(`/api/v1/jobs/${id}/cancel`, { method: "POST", auth: true }),
+ retryJob: (id: string) => apiRequest(`/api/v1/jobs/${id}/retry`, { method: "POST", auth: true }),
+ jobSchedules: () => apiRequest<{ list: JobSchedule[]; pagination: Pagination }>("/api/v1/job/schedules?page=1&pageSize=50", { auth: true }),
+ settings: (scope: string, scopeId: string) => apiRequest<{ list: SettingValue[]; pagination: Pagination }>(`/api/v1/settings/${scope}/${scopeId}`, { auth: true }),
+ permissions: () => apiRequest("/api/v1/permissions/me?include_tree=true", { auth: true })
+};
diff --git a/backend/web/src/auth/AuthContext.tsx b/backend/web/src/auth/AuthContext.tsx
new file mode 100644
index 0000000..b755f5e
--- /dev/null
+++ b/backend/web/src/auth/AuthContext.tsx
@@ -0,0 +1,108 @@
+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
+import { apiRequest, clearTokens, getAccessToken, setTokens, setUnauthorizedHandler } from "../api/client";
+
+export type MemberMe = {
+ tenant_id: string;
+ uid: string;
+ email: string;
+ display_name?: string;
+ status: string;
+ roles?: string[];
+};
+
+type AuthContextValue = {
+ member: MemberMe | null;
+ loading: boolean;
+ login: (tenantId: string, email: string, password: string) => Promise;
+ register: (tenantId: string, email: string, password: string, displayName?: string) => Promise;
+ logout: () => Promise;
+ reloadMember: () => Promise;
+};
+
+const AuthContext = createContext(null);
+
+type TokenPair = {
+ access_token: string;
+ refresh_token: string;
+};
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ const [member, setMember] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const reloadMember = useCallback(async () => {
+ if (!getAccessToken()) {
+ setMember(null);
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ try {
+ const data = await apiRequest("/api/v1/members/me", { auth: true });
+ setMember(data);
+ } catch {
+ clearTokens();
+ setMember(null);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ setUnauthorizedHandler(() => {
+ clearTokens();
+ setMember(null);
+ });
+ void reloadMember();
+ return () => setUnauthorizedHandler(undefined);
+ }, [reloadMember]);
+
+ const login = useCallback(
+ async (tenantId: string, email: string, password: string) => {
+ const data = await apiRequest("/api/v1/auth/login", {
+ method: "POST",
+ body: { tenant_id: tenantId, email, password }
+ });
+ setTokens(data.access_token, data.refresh_token);
+ await reloadMember();
+ },
+ [reloadMember]
+ );
+
+ const register = useCallback(
+ async (tenantId: string, email: string, password: string, displayName?: string) => {
+ const data = await apiRequest("/api/v1/auth/register", {
+ method: "POST",
+ body: { tenant_id: tenantId, email, password, display_name: displayName }
+ });
+ setTokens(data.access_token, data.refresh_token);
+ await reloadMember();
+ },
+ [reloadMember]
+ );
+
+ const logout = useCallback(async () => {
+ try {
+ await apiRequest("/api/v1/auth/logout", { method: "POST", auth: true });
+ } catch {
+ // Local logout still needs to complete if the server token is already invalid.
+ }
+ clearTokens();
+ setMember(null);
+ }, []);
+
+ const value = useMemo(
+ () => ({ member, loading, login, register, logout, reloadMember }),
+ [member, loading, login, register, logout, reloadMember]
+ );
+
+ return {children};
+}
+
+export function useAuth() {
+ const ctx = useContext(AuthContext);
+ if (!ctx) {
+ throw new Error("useAuth must be used within AuthProvider");
+ }
+ return ctx;
+}
diff --git a/backend/web/src/components/AcIcon.tsx b/backend/web/src/components/AcIcon.tsx
new file mode 100644
index 0000000..f7bb8aa
--- /dev/null
+++ b/backend/web/src/components/AcIcon.tsx
@@ -0,0 +1,34 @@
+type IconName =
+ | "home"
+ | "account"
+ | "publish"
+ | "persona"
+ | "mission"
+ | "patrol"
+ | "job"
+ | "settings"
+ | "spark"
+ | "more"
+ | "brand";
+
+const paths: Record = {
+ home: "M4 11.5 12 4l8 7.5V20a1 1 0 0 1-1 1h-5v-6h-4v6H5a1 1 0 0 1-1-1v-8.5Z",
+ account: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0H5Z",
+ publish: "M4 19V5l16 7-16 7Zm3-4.4 7.9-2.6L7 9.4v3.1l5 1.5-5 .6Z",
+ persona: "M7 5h10v4H7V5Zm-2 7h14v7H5v-7Zm3 2v3h2v-3H8Zm6 0v3h2v-3h-2Z",
+ mission: "M5 5h14v14H5V5Zm3 4h8V7H8v2Zm0 4h8v-2H8v2Zm0 4h5v-2H8v2Z",
+ patrol: "M12 3 4 7v6c0 4 3.3 7 8 8 4.7-1 8-4 8-8V7l-8-4Zm0 4 4 2v4c0 2.2-1.5 4-4 5-2.5-1-4-2.8-4-5V9l4-2Z",
+ job: "M6 4h12v4H6V4Zm0 6h12v4H6v-4Zm0 6h12v4H6v-4Z",
+ settings: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm8.5 5.5v-3l-2.2-.4a7.7 7.7 0 0 0-.7-1.6l1.3-1.9-2.1-2.1-1.9 1.3c-.5-.3-1.1-.5-1.7-.7L12.8 3h-3l-.4 2.1c-.6.2-1.1.4-1.7.7L5.9 4.5 3.8 6.6l1.3 1.9c-.3.5-.5 1.1-.7 1.6l-2.2.4v3l2.2.4c.2.6.4 1.1.7 1.6l-1.3 1.9 2.1 2.1 1.9-1.3c.5.3 1.1.5 1.7.7l.4 2.1h3l.4-2.1c.6-.2 1.1-.4 1.7-.7l1.9 1.3 2.1-2.1-1.3-1.9c.3-.5.5-1.1.7-1.6l2.1-.4Z",
+ spark: "M12 2l1.8 6.2L20 10l-6.2 1.8L12 18l-1.8-6.2L4 10l6.2-1.8L12 2Zm6 13 1 3 3 1-3 1-1 3-1-3-3-1 3-1 1-3Z",
+ more: "M5 12a2 2 0 1 0 0 .1V12Zm7 0a2 2 0 1 0 0 .1V12Zm7 0a2 2 0 1 0 0 .1V12Z",
+ brand: "M4 7h16v2l-2 2v7a1 1 0 0 1-1 1h-4v-5H9v5H5a1 1 0 0 1-1-1v-7L4 9V7Zm2 4 1 1v6h2v-4h6v4h2v-6l1-1V9H6v2Z"
+};
+
+export function AcIcon({ name, size = 22 }: { name: IconName; size?: number }) {
+ return (
+
+ );
+}
diff --git a/backend/web/src/components/AuthDecor.tsx b/backend/web/src/components/AuthDecor.tsx
new file mode 100644
index 0000000..e5f2eff
--- /dev/null
+++ b/backend/web/src/components/AuthDecor.tsx
@@ -0,0 +1,22 @@
+export function SceneDecor() {
+ return (
+
+ );
+}
+
+export function AuthTicketIcon() {
+ return (
+
+ );
+}
diff --git a/backend/web/src/components/AuthShell.tsx b/backend/web/src/components/AuthShell.tsx
new file mode 100644
index 0000000..e33a37f
--- /dev/null
+++ b/backend/web/src/components/AuthShell.tsx
@@ -0,0 +1,25 @@
+import { AuthTicketIcon, SceneDecor } from "./AuthDecor";
+import { ThemeToggle } from "./ThemeToggle";
+
+export function AuthShell({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
巡樓 Console
+
把找題、寫文、排程、追成效串成一條線。
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/backend/web/src/components/DevToolsPanel.tsx b/backend/web/src/components/DevToolsPanel.tsx
new file mode 100644
index 0000000..01dc77c
--- /dev/null
+++ b/backend/web/src/components/DevToolsPanel.tsx
@@ -0,0 +1,199 @@
+import { useEffect, useState } from "react";
+import { api, ThreadsConnection } from "../api/haixun";
+import { getAccessToken } from "../api/client";
+import { readActiveThreadsAccountId } from "../lib/activeAccount";
+import {
+ isExtensionBridgePresent,
+ pingExtensionBridge,
+ requestExtensionSync,
+ waitForExtensionBridge,
+} from "../lib/extensionSync";
+import { ExtensionInstallCard } from "./ExtensionInstallCard";
+import { Badge, Button, Card, Field, Textarea } from "./ui";
+
+type DevToolsPanelProps = {
+ accountId: string;
+ connection: ThreadsConnection;
+ onConnectionChange: (data: ThreadsConnection) => void;
+ onMessage: (message: string) => void;
+ onError: (message: string) => void;
+};
+
+export function DevToolsPanel({
+ accountId,
+ connection,
+ onConnectionChange,
+ onMessage,
+ onError,
+}: DevToolsPanelProps) {
+ const [open, setOpen] = useState(true);
+ const [extensionReady, setExtensionReady] = useState(false);
+ const [syncBusy, setSyncBusy] = useState(false);
+ const [manualState, setManualState] = useState("");
+ const [importBusy, setImportBusy] = useState(false);
+ const [showManualImport, setShowManualImport] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ const onBridgeMessage = (event: MessageEvent) => {
+ if (event.source !== window) return;
+ if (event.data?.type === "HAIXUN_EXTENSION_READY") setExtensionReady(true);
+ };
+ window.addEventListener("message", onBridgeMessage);
+ const timer = window.setInterval(() => {
+ if (cancelled) return;
+ if (isExtensionBridgePresent()) {
+ setExtensionReady(true);
+ return;
+ }
+ pingExtensionBridge();
+ }, 1500);
+ pingExtensionBridge();
+ void waitForExtensionBridge(6000).then((ready) => {
+ if (!cancelled && ready) setExtensionReady(true);
+ });
+ return () => {
+ cancelled = true;
+ window.removeEventListener("message", onBridgeMessage);
+ window.clearInterval(timer);
+ };
+ }, []);
+
+ const reloadConnection = async () => {
+ const data = await api.getThreadsConnection(accountId);
+ onConnectionChange(data);
+ return data;
+ };
+
+ const syncChromeSession = async () => {
+ setSyncBusy(true);
+ onError("");
+ onMessage("");
+ try {
+ const ready = extensionReady || isExtensionBridgePresent() || (await waitForExtensionBridge(4000));
+ if (!ready) {
+ throw new Error(
+ "找不到巡樓 Chrome 擴充。請到 chrome://extensions 載入 extension/haixun-threads-sync,按「重新載入」後刷新此頁(F5)",
+ );
+ }
+ setExtensionReady(true);
+
+ await api.memberMe();
+ const resolvedAccountId = accountId || readActiveThreadsAccountId();
+ if (!resolvedAccountId) {
+ throw new Error("請先在頂部選擇經營帳號,或到 Threads 帳號頁設為使用中後再同步");
+ }
+
+ const result = await requestExtensionSync({
+ accountId: resolvedAccountId,
+ serverUrl: window.location.origin,
+ });
+ if (result.success === false || result.valid === false) {
+ throw new Error(result.message || "Chrome session 同步失敗");
+ }
+ onMessage(result.message || "Chrome session 已同步");
+ await reloadConnection();
+ } catch (err) {
+ onError(err instanceof Error ? err.message : "Chrome session 同步失敗");
+ } finally {
+ setSyncBusy(false);
+ }
+ };
+
+ const importManualSession = async () => {
+ const trimmed = manualState.trim();
+ if (!trimmed) {
+ onError("請貼上 Playwright storage state JSON");
+ return;
+ }
+ setImportBusy(true);
+ onError("");
+ onMessage("");
+ try {
+ JSON.parse(trimmed);
+ const data = await api.importThreadsAccountSession(accountId, trimmed);
+ if (!data.valid) throw new Error(data.message || "Session 驗證失敗");
+ setManualState("");
+ onMessage(data.message || "Session 已匯入");
+ await reloadConnection();
+ } catch (err) {
+ if (err instanceof SyntaxError) {
+ onError("JSON 格式不正確,請貼上完整的 Playwright storage state");
+ } else {
+ onError(err instanceof Error ? err.message : "匯入失敗");
+ }
+ } finally {
+ setImportBusy(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {open ? (
+
+
+
+ {connection.browser_connected ? "Chrome Session 已同步" : "Chrome Session 未同步"}
+
+
+ {extensionReady ? "擴充已偵測" : "尚未偵測擴充"}
+
+
+
+
+
+
+
+
+ {showManualImport ? (
+
+
+
+
+
+ ) : null}
+
+ {!extensionReady ? (
+ <>
+
+ 安裝擴充後到 chrome://extensions 重新載入,再按 F5 刷新此頁。巡樓網址須與擴充選項一致(localhost 與 127.0.0.1 也要一致)。
+
+
+ >
+ ) : null}
+
+
+ 診斷:頁面 {window.location.origin}|JWT {getAccessToken() ? "有" : "無"}
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/backend/web/src/components/ExtensionInstallCard.tsx b/backend/web/src/components/ExtensionInstallCard.tsx
new file mode 100644
index 0000000..627b3ab
--- /dev/null
+++ b/backend/web/src/components/ExtensionInstallCard.tsx
@@ -0,0 +1,46 @@
+import {
+ EXTENSION_DOWNLOAD_URL,
+ EXTENSION_INSTALL_STEPS,
+ EXTENSION_VERSION,
+} from "../lib/extensionInstall";
+import { Badge } from "./ui";
+
+type ExtensionInstallCardProps = {
+ compact?: boolean;
+ showSteps?: boolean;
+};
+
+export function ExtensionInstallCard({ compact, showSteps = true }: ExtensionInstallCardProps) {
+ return (
+
+
+
+
Chrome 擴充套件
+
+ 開發模式(爬蟲)需安裝此擴充,才能從 Chrome 同步 Threads 登入態到巡樓。
+
+
+
v{EXTENSION_VERSION}
+
+
+ {showSteps ? (
+
+ {EXTENSION_INSTALL_STEPS.map((step) => (
+ -
+ {step}
+
+ ))}
+
+ ) : null}
+
+
+ 下載擴充套件(ZIP)
+
+
+ );
+}
\ No newline at end of file
diff --git a/backend/web/src/components/HoverHelpTip.tsx b/backend/web/src/components/HoverHelpTip.tsx
new file mode 100644
index 0000000..5695cbe
--- /dev/null
+++ b/backend/web/src/components/HoverHelpTip.tsx
@@ -0,0 +1,19 @@
+type Props = {
+ text: string;
+ label?: string;
+};
+
+export function HoverHelpTip({ text, label = "查看原因" }: Props) {
+ if (!text.trim()) return null;
+
+ return (
+
+
+
+ {text}
+
+
+ );
+}
\ No newline at end of file
diff --git a/backend/web/src/components/JobMonitor.tsx b/backend/web/src/components/JobMonitor.tsx
new file mode 100644
index 0000000..a5a7f1e
--- /dev/null
+++ b/backend/web/src/components/JobMonitor.tsx
@@ -0,0 +1,336 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { api, JobRun } from "../api/haixun";
+import { Badge, Button } from "./ui";
+import { formatNano } from "../lib/format";
+import { canCancelJob, canRetryJob } from "../lib/jobActions";
+import { jobTemplateLabel } from "../lib/jobLabels";
+import { jobStatusBadgeClass, jobStatusLabel } from "../lib/jobStatus";
+import { AcIcon } from "./AcIcon";
+import type { JobCreatedDetail } from "../lib/jobEvents";
+
+const ACTIVE_STATUSES = ["running", "queued", "pending", "waiting_worker", "cancel_requested"];
+const PANEL_STATUSES = [...ACTIVE_STATUSES, "failed"];
+
+function nowNano() {
+ return Date.now() * 1_000_000;
+}
+
+function optimisticJob(detail: JobCreatedDetail): JobRun {
+ return {
+ id: detail.jobId,
+ template_type: detail.templateType || detail.label || "任務",
+ status: detail.status || "queued",
+ progress: { percentage: 0, summary: "剛建立,等待執行…" },
+ create_at: nowNano(),
+ update_at: nowNano()
+ };
+}
+
+function JobMonitorItem({
+ job,
+ actionLoading,
+ onCancel,
+ onRetry,
+ showError
+}: {
+ job: JobRun;
+ actionLoading: boolean;
+ onCancel: () => void;
+ onRetry: () => void;
+ showError: boolean;
+}) {
+ return (
+
+
+ {jobTemplateLabel(job.template_type)}
+ {jobStatusLabel(job.status)}
+
+
+ {job.progress ? (
+
+ {job.progress.percentage ?? 0}%
+ {job.progress.summary ? ` · ${job.progress.summary}` : ""}
+
+ ) : null}
+
+ {showError && job.error?.trim() ? (
+
+ {job.error.trim()}
+
+ ) : null}
+
+
+
{formatNano(job.update_at)}
+
+ {canCancelJob(job) ? (
+
+ ) : null}
+ {canRetryJob(job) ? (
+
+ ) : null}
+
+
+
+ );
+}
+
+export function JobMonitor() {
+ const [jobs, setJobs] = useState([]);
+ const [open, setOpen] = useState(false);
+ const [actionJobId, setActionJobId] = useState("");
+ const [hot, setHot] = useState(false);
+ const pendingRef = useRef